feat: TGKey branded type, PersistentMessage abstraction, createBuildEmbed factory, BaseLayout refactor

This commit is contained in:
Nuno Duque Nunes 2026-06-13 02:23:56 +01:00
parent a4d772b81d
commit b22602f431
16 changed files with 218 additions and 206 deletions

View file

@ -27,7 +27,7 @@
"userKey": "ayana", "userKey": "ayana",
"displayName": "Ayana", "displayName": "Ayana",
"characterName": "«MonkeyHunter»", "characterName": "«MonkeyHunter»",
"characterClass": "GL", "characterClass": "DM",
"characterLevel": 79, "characterLevel": 79,
"characterNation": "Procyon", "characterNation": "Procyon",
"votedAt": "19:46" "votedAt": "19:46"
@ -36,7 +36,7 @@
"no": [], "no": [],
"leaves": [ "leaves": [
{ {
"characterName": "»Flash«", "characterName": " «Deystroyer»",
"historyKey": "2026-06-11-20" "historyKey": "2026-06-11-20"
} }
], ],

View file

@ -5,11 +5,7 @@ import { Leaves } from "@systems/leaves";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { TGKey } from "@systems/tg-key";
function getCurrentHistoryKey(slot: number): string {
const date = new Date().toISOString().slice(0, 10);
return `${date}-${slot}`;
}
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
@ -27,7 +23,7 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const historyKey = getCurrentHistoryKey(slot); const historyKey = TGKey.current({ slot });
Leaves.mark({ Leaves.mark({
characterName: char.name, characterName: char.name,
@ -59,7 +55,7 @@ export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const historyKey = getCurrentHistoryKey(slot); const historyKey = TGKey.current({ slot });
Leaves.unmark({ characterName: char.name, historyKey }); Leaves.unmark({ characterName: char.name, historyKey });
const channel = interaction.channel as TextChannel; const channel = interaction.channel as TextChannel;

View file

@ -11,11 +11,12 @@
import fs from "fs"; import fs from "fs";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { UserKey, HistoryKey } from "@types"; import { UserKey } from "@types";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { TGKey } from "@systems/tg-key";
interface AttendanceData { interface AttendanceData {
[historyKey: HistoryKey]: UserKey[]; [historyKey: TGKey]: UserKey[];
} }
let _data: AttendanceData = {}; let _data: AttendanceData = {};
@ -34,8 +35,7 @@ function save(): void {
* Snapshot attendance from poll state at lock time. * Snapshot attendance from poll state at lock time.
*/ */
snapshot(slot: number, lockedYesKeys: Set<UserKey>): void { snapshot(slot: number, lockedYesKeys: Set<UserKey>): void {
const date = new Date().toISOString().slice(0, 10); const historyKey = TGKey.current({ slot });
const historyKey = `${date}-${slot}` as HistoryKey;
_data[historyKey] = [...lockedYesKeys]; _data[historyKey] = [...lockedYesKeys];
save(); save();
}, },
@ -43,21 +43,21 @@ function save(): void {
/** /**
* Get players who attended a specific TG. * Get players who attended a specific TG.
*/ */
players(historyKey: HistoryKey): UserKey[] { players(historyKey: TGKey): UserKey[] {
return _data[historyKey] ?? []; return _data[historyKey] ?? [];
}, },
/** /**
* Check if a specific player attended. * Check if a specific player attended.
*/ */
includes(historyKey: HistoryKey, userKey: UserKey): boolean { includes(historyKey: TGKey, userKey: UserKey): boolean {
return (_data[historyKey] ?? []).includes(userKey); return (_data[historyKey] ?? []).includes(userKey);
}, },
/** /**
* Check if all attendees have submitted scores. * Check if all attendees have submitted scores.
*/ */
allSubmitted(historyKey: HistoryKey): boolean { allSubmitted(historyKey: TGKey): boolean {
const players = _data[historyKey] ?? []; const players = _data[historyKey] ?? [];
if (players.length === 0) return false; if (players.length === 0) return false;
try { try {
@ -74,7 +74,7 @@ function save(): void {
/** /**
* Get all history keys (for listing past TGs). * Get all history keys (for listing past TGs).
*/ */
all(): HistoryKey[] { all(): TGKey[] {
return Object.keys(_data) as HistoryKey[]; return Object.keys(_data) as TGKey[];
}, },
}; };

View file

@ -4,23 +4,22 @@ import { TGResult, TGScore, Nation } from "../types";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key";
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history"); const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
function historyKey(date: string, slot: number): string {
return `${date}-${String(slot).padStart(2, "0")}`;
}
function historyPath(key: string): string { function historyPath(key: string): string {
return path.join(HISTORY_DIR, `${key}.json`); return path.join(HISTORY_DIR, `${key}.json`);
} }
export function loadResult(date: string, slot: number): TGResult | null { export function loadResult(date: string, slot: number): TGResult | null {
return Store.read(historyPath(historyKey(date, slot))); const path = historyPath(TGKey.from({ date, slot }));
return Store.read(path);
} }
export function saveResult(result: TGResult): void { export function saveResult(result: TGResult): void {
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
Store.write(historyPath(historyKey(result.date, result.slot)), result); const path = historyPath(TGKey.from({ date: result.date, slot: result.slot }));
Store.write(path, result);
} }
export function upsertScore(score: TGScore): void { export function upsertScore(score: TGScore): void {

View file

@ -15,18 +15,19 @@
* Leaves.formatIndicator({ characterName }) * Leaves.formatIndicator({ characterName })
*/ */
import { UserKey, CharName, HistoryKey } from "@types"; import { UserKey, CharName } from "@types";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
import { TGKey } from "@systems/tg-key";
Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" }); Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" });
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface LeaveRecord { interface LeaveRecord {
historyKey: HistoryKey; historyKey: TGKey;
markedBy: UserKey; markedBy: UserKey;
markedAt: string; markedAt: string;
} }
@ -60,7 +61,7 @@
mark({ characterName, ownerKey, historyKey, markedBy }: { mark({ characterName, ownerKey, historyKey, markedBy }: {
characterName: CharName; characterName: CharName;
ownerKey: UserKey; ownerKey: UserKey;
historyKey: HistoryKey; historyKey: TGKey;
markedBy: UserKey; markedBy: UserKey;
}): void { }): void {
if (!_data[characterName]) { if (!_data[characterName]) {
@ -75,7 +76,7 @@
unmark({ characterName, historyKey }: { unmark({ characterName, historyKey }: {
characterName: CharName; characterName: CharName;
historyKey: HistoryKey; historyKey: TGKey;
}): void { }): void {
if (!_data[characterName]) return; if (!_data[characterName]) return;
_data[characterName].history = _data[characterName].history.filter( _data[characterName].history = _data[characterName].history.filter(
@ -87,7 +88,7 @@
hasLeft({ characterName, historyKey }: { hasLeft({ characterName, historyKey }: {
characterName: CharName; characterName: CharName;
historyKey: HistoryKey; historyKey: TGKey;
}): boolean { }): boolean {
return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false; return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false;
}, },

View file

@ -9,10 +9,11 @@
* Score.submit({ character, borrowedFrom, pts, k, d, slot }) * Score.submit({ character, borrowedFrom, pts, k, d, slot })
*/ */
import { Character, Nation, UserKey, HistoryKey, SlotHour } from "@types"; import { Character, Nation, UserKey, SlotHour } from "@types";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key";
export interface TGScore { export interface TGScore {
userKey: UserKey; userKey: UserKey;
@ -48,14 +49,14 @@
previousRank?: number; previousRank?: number;
} }
function getHistoryPath(historyKey: HistoryKey): string { function getHistoryPath(historyKey: TGKey): string {
return Paths.data("tg-history", `${historyKey}.json`); return Paths.data("tg-history", `${historyKey}.json`);
} }
function loadHistory(historyKey: HistoryKey): { scores: TGScore[] } { function loadHistory(historyKey: TGKey): { scores: TGScore[] } {
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] }); return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] });
} }
function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void { function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
Store.write(getHistoryPath(historyKey), data); Store.write(getHistoryPath(historyKey), data);
} }
@ -63,13 +64,11 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
/** /**
* Get a score for a character in a specific TG. * Get a score for a character in a specific TG.
*/ */
get({ character, slot, date }: { get({ character, slot }: {
character: Character; character: Character;
slot: SlotHour; slot: SlotHour;
date?: string;
}): TGScore | null { }): TGScore | null {
const d = date ?? new Date().toISOString().slice(0, 10); const historyKey = TGKey.current({ slot });
const historyKey = `${d}-${slot}` as HistoryKey;
const history = loadHistory(historyKey); const history = loadHistory(historyKey);
return history.scores.find( return history.scores.find(
(s) => s.userKey === character.ownerKey && s.characterName === character.name (s) => s.userKey === character.ownerKey && s.characterName === character.name
@ -82,11 +81,10 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
getWeeklySummary({ character }: { character: Character }): WeeklySummary { getWeeklySummary({ character }: { character: Character }): WeeklySummary {
const week = WRank.currentWeek(); const week = WRank.currentWeek();
const entry = WRank.entry(character.name, character.nation); const entry = WRank.entry(character.name, character.nation);
const allKeys = Object.keys(week.scoreIndex[character.name] ?? {}) as HistoryKey[];
const scores: TGScore[] = []; const scores: TGScore[] = [];
for (const historyKey of (week.scoreIndex[character.name] ?? [])) { for (const historyKey of (week.scoreIndex[character.name] ?? [])) {
const history = loadHistory(historyKey as HistoryKey); const history = loadHistory(historyKey as TGKey);
const score = history.scores.find( const score = history.scores.find(
(s) => s.userKey === character.ownerKey && s.characterName === character.name (s) => s.userKey === character.ownerKey && s.characterName === character.name
); );
@ -127,7 +125,7 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
submittedByOfficer?: boolean; submittedByOfficer?: boolean;
}): void { }): void {
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
const historyKey = `${date}-${slot}` as HistoryKey; const historyKey = TGKey.current({ slot });
const history = loadHistory(historyKey); const history = loadHistory(historyKey);
// Snapshot W.Rank before recording score // Snapshot W.Rank before recording score

View file

@ -2,6 +2,7 @@ import { TGScore, Nation, ClassKey } from "../types";
import { Config } from "./config"; import { Config } from "./config";
import { upsertScore, todayString } from "./history"; import { upsertScore, todayString } from "./history";
import { WRank } from "./wrank"; import { WRank } from "./wrank";
import { TGKey } from "@systems/tg-key";
// Normalize a slot string to a 24h integer hour // Normalize a slot string to a 24h integer hour
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
@ -71,7 +72,7 @@ export interface ScoreSubmission {
export function submitScore(sub: ScoreSubmission): void { export function submitScore(sub: ScoreSubmission): void {
const date = sub.date ?? todayString(); const date = sub.date ?? todayString();
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`; const historyKey = TGKey.from({ date, slot: sub.slot });
const score: TGScore = { const score: TGScore = {
userKey: sub.userKey, userKey: sub.userKey,

52
src/systems/tg-key.ts Normal file
View file

@ -0,0 +1,52 @@
/**
* TGKey branded type for TG session identifiers.
* Format: "YYYY-MM-DD-SLOT" e.g. "2026-06-11-20"
*
* Usage:
* import { TGKey } from "@systems/tg-key";
*
* const key = TGKey.current({ slot: 20 })
* const key = TGKey.from({ date: "2026-06-11", slot: 20 })
* TGKey.parse(key) // { date: "2026-06-11", slot: 20 }
* TGKey.toHistoryPath(key) // ".../tg-history/2026-06-11-20.json"
*/
import { Paths } from "@paths";
export type TGKey = string & { readonly __brand: "TGKey" };
export const TGKey = {
from({ date, slot }: { date: Date | string; slot: number }): TGKey {
const d = date instanceof Date ? date.toISOString().slice(0, 10) : date;
const s = String(slot).padStart(2, "0");
return `${d}-${s}` as TGKey;
},
current({ slot }: { slot: number }): TGKey {
return TGKey.from({ date: new Date(), slot });
},
parse(key: TGKey): { date: string; slot: number } {
const parts = key.split("-");
return {
date: parts.slice(0, 3).join("-"), // "YYYY-MM-DD"
slot: parseInt(parts[3], 10),
};
},
toHistoryPath(key: TGKey): string {
return Paths.data("tg-history", `${key}.json`);
},
/** Format for display: "11/06/2026 · 20:00" */
toDisplay(key: TGKey): string {
const { date, slot } = TGKey.parse(key);
const [year, month, day] = date.split("-");
return `${day}/${month}/${year} · ${slot}:00`;
},
/** Check if a string is a valid TGKey */
isValid(key: string): key is TGKey {
return /^\d{4}-\d{2}-\d{2}-\d{1,2}$/.test(key);
},
};

View file

@ -12,13 +12,13 @@
* TG.getWeeklySummary({ character }) * TG.getWeeklySummary({ character })
*/ */
import { Nation, Character, UserKey, HistoryKey, SlotHour } from "@types"; import { Nation, Character, UserKey, SlotHour } from "@types";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Score, TGScore, WeeklySummary } from "@systems/score"; import { Score, TGScore, WeeklySummary } from "@systems/score";
import { Attendance } from "@systems/attendance"; import { Attendance } from "@systems/attendance";
import { Nations } from "@systems/nations";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { TGKey } from "@systems/tg-key";
export const TG = { export const TG = {
// ── Week ────────────────────────────────────────────────────────────────── // ── Week ──────────────────────────────────────────────────────────────────
@ -66,7 +66,7 @@
// ── Attendance ──────────────────────────────────────────────────────────── // ── Attendance ────────────────────────────────────────────────────────────
getAttendance({ historyKey, nation }: { getAttendance({ historyKey, nation }: {
historyKey: HistoryKey; historyKey: TGKey;
nation?: Nation; nation?: Nation;
}): UserKey[] { }): UserKey[] {
const players = Attendance.players(historyKey); const players = Attendance.players(historyKey);
@ -75,7 +75,7 @@
return players; return players;
}, },
allSubmitted(historyKey: HistoryKey): boolean { allSubmitted(historyKey: TGKey): boolean {
return Attendance.allSubmitted(historyKey); return Attendance.allSubmitted(historyKey);
}, },
@ -86,7 +86,7 @@
slot: SlotHour; slot: SlotHour;
date?: string; date?: string;
}): TGScore | null { }): TGScore | null {
return Score.get({ character, slot, date }); return Score.get({ character, slot });
}, },
getWeeklySummary({ character }: { character: Character }): WeeklySummary { getWeeklySummary({ character }: { character: Character }): WeeklySummary {

View file

@ -24,6 +24,7 @@
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Leaves } from "@systems/leaves"; import { Leaves } from "@systems/leaves";
import { PersistentMessage } from "@systems/persistent-message"; import { PersistentMessage } from "@systems/persistent-message";
import { TGKey } from "@systems/tg-key";
const log = Logger.for("updates"); const log = Logger.for("updates");
@ -79,7 +80,7 @@
}[]; }[];
leaves?: { leaves?: {
characterName: string; characterName: string;
historyKey: string; historyKey: TGKey;
}[]; }[];
} }
@ -223,7 +224,10 @@
try { try {
PollUI.setLayout(example.layout); PollUI.setLayout(example.layout);
const state = buildExamplePollState(exampleData); const state = buildExamplePollState(exampleData);
const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: `🪲 ${example.caption}` }); const exampleEmbed = PollUI.buildEmbed(state, {
overrideLockMsg: `🪲 ${example.caption}`,
historyKey: exampleData.leaves?.[0]?.historyKey
});
exampleEmbed.setTitle(`📋 Example — ${example.caption}`); exampleEmbed.setTitle(`📋 Example — ${example.caption}`);
embeds.push(exampleEmbed); embeds.push(exampleEmbed);
} finally { } finally {

View file

@ -1,9 +1,10 @@
import { HistoryKey, UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types"; import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { TGKey } from "@systems/tg-key";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
import { Logger } from "@systems/logger"; import { Logger } from "@systems/logger";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
@ -40,7 +41,7 @@ export interface WRankEntry {
export interface WRankWeek { export interface WRankWeek {
weekKey: string; weekKey: string;
entries: Record<Nation, SerializableWRankEntry[]>; entries: Record<Nation, SerializableWRankEntry[]>;
scoreIndex: Record<CharName, HistoryKey[]>; scoreIndex: Record<CharName, TGKey[]>;
bringer: { bringer: {
[Nation.Capella]: string | null; [Nation.Capella]: string | null;
[Nation.Procyon]: string | null; [Nation.Procyon]: string | null;
@ -146,7 +147,7 @@ export const WRank = {
cls: ClassKey, cls: ClassKey,
nation: Nation, nation: Nation,
pts: number, pts: number,
historyKey: HistoryKey historyKey: TGKey
): void { ): void {
const week = ensureWeek(WRank.weekKey()); const week = ensureWeek(WRank.weekKey());
const list = week.entries[nation]; const list = week.entries[nation];

View file

@ -4,7 +4,6 @@ export type UserKey = string;
export type DiscordId = string; export type DiscordId = string;
export type CharName = string; export type CharName = string;
export type SlotHour = number; export type SlotHour = number;
export type HistoryKey = string;
export type VoteType = "yes" | "no"; export type VoteType = "yes" | "no";
export type ConfirmType = "yes" | "no"; export type ConfirmType = "yes" | "no";

View file

@ -2,24 +2,18 @@
* BaseLayout shared poll layout functions. * BaseLayout shared poll layout functions.
* All layouts inherit these via BaseLayout.methods() spread. * All layouts inherit these via BaseLayout.methods() spread.
* Override only what differs in each layout. * Override only what differs in each layout.
*
* Usage:
* export const myLayout: PollLayout = {
* ...BaseLayout.methods(),
* name: "my-layout",
* description: "...",
* buildEmbed(state, options) { ... }, // override
* };
*/ */
import { VoteEntry, Nation } from "@types"; import { EmbedBuilder } from "discord.js";
import { VoteEntry, Nation, PollState } from "@types";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { WRank, WRankEntry } from "@systems/wrank"; import { WRank, WRankEntry } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Leaves } from "@systems/leaves"; import { Leaves } from "@systems/leaves";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { TGKey } from "@systems/tg-key";
import { format } from "@format"; import { format } from "@format";
import { PollRowContext } from "@ui/types"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
// ─── W.Rank formatting ──────────────────────────────────────────────────────── // ─── W.Rank formatting ────────────────────────────────────────────────────────
@ -76,8 +70,8 @@
// Nation emoji prefix // Nation emoji prefix
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
// Cockroach indicator (left TG) // Cockroach indicator
if (entry.userKey && entry.characterName && context.historyKey) { if (entry.characterName && context.historyKey) {
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
} }
@ -91,7 +85,7 @@
export function buildContext( export function buildContext(
entries: VoteEntry[], entries: VoteEntry[],
nation: Nation, nation: Nation,
options?: { showNationEmoji?: boolean; historyKey?: string } options?: { showNationEmoji?: boolean; historyKey?: TGKey }
): PollRowContext { ): PollRowContext {
const nationHasRank = entries.some((e) => { const nationHasRank = entries.some((e) => {
if (!e.characterName) return false; if (!e.characterName) return false;
@ -113,7 +107,25 @@
}; };
} }
// ─── Message formatting ─────────────────────────────────────────────────────── // ─── Shared embed helpers ─────────────────────────────────────────────────────
export function formatNationField(
nation: Nation,
yesEntries: VoteEntry[],
noVoters: VoteEntry[],
showNoInline: boolean,
historyKey?: TGKey
): string {
const context = buildContext(yesEntries, nation, { historyKey });
const noEntries = showNoInline
? noVoters.filter((e) => e.characterNation === nation)
: [];
const lines = [
...yesEntries.map((e) => formatRow(e, context)),
...noEntries.map((e) => `${formatRow(e, context)}`),
];
return lines.length > 0 ? lines.join("\n") : "—";
}
export function formatMessages( export function formatMessages(
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
@ -129,34 +141,14 @@
.join("\n"); .join("\n");
} }
// ─── Embed helpers ──────────────────────────────────────────────────────────── export function resolveColor(state: PollState): number {
export function formatNationField(
nation: Nation,
yesEntries: VoteEntry[],
noVoters: VoteEntry[],
showNoInline: boolean,
historyKey?: string
): string {
const context = buildContext(yesEntries, nation, { historyKey });
const noEntries = showNoInline
? noVoters.filter((e) => e.characterNation === nation)
: [];
const lines = [
...yesEntries.map((e) => formatRow(e, context)),
...noEntries.map((e) => `${formatRow(e, context)}`),
];
return lines.length > 0 ? lines.join("\n") : "—";
}
export function resolveColor(state: any): number {
if (state.confirmed === "yes") return 0x57f287; if (state.confirmed === "yes") return 0x57f287;
if (state.confirmed === "no") return 0xed4245; if (state.confirmed === "no") return 0xed4245;
if (state.locked) return 0x888888; if (state.locked) return 0x888888;
return 0xe8a317; return 0xe8a317;
} }
export function resolveTitle(state: any, yesByNation: Record<Nation, VoteEntry[]>): string { export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
const capellaEmoji = Emoji.get("capella"); const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon"); const procyonEmoji = Emoji.get("procyon");
const counts = !state.locked && state.confirmed === null const counts = !state.locked && state.confirmed === null
@ -169,14 +161,14 @@
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
} }
export function resolveFooter(state: any, noCount: number, overrideLockMsg?: string): string { export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" }); if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
} }
export function buildYesByNation(state: any): { export function buildYesByNation(state: PollState): {
yesByNation: Record<Nation, VoteEntry[]>; yesByNation: Record<Nation, VoteEntry[]>;
noVoters: VoteEntry[]; noVoters: VoteEntry[];
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]; allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
@ -189,10 +181,7 @@
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
for (const entry of state.yes.values()) { for (const entry of state.yes.values()) {
const nation: Nation = entry.characterNation === Nation.Procyon const nation: Nation = (entry.characterNation as Nation) ?? Nation.Capella;
? Nation.Procyon
: Nation.Capella;
yesByNation[nation].push(entry); yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" }); allMessages.push({ entry, voteType: "yes" });
} }
@ -204,13 +193,76 @@
return { yesByNation, noVoters, allMessages }; return { yesByNation, noVoters, allMessages };
} }
// ─── buildEmbed factory ───────────────────────────────────────────────────────
export function createBuildEmbed({ inline, maxInlinePlayers = 5 }: {
inline: boolean;
maxInlinePlayers?: number;
}) {
return function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
const historyKey = options?.historyKey
?? TGKey.current({ slot: state.slot });
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
// Auto-stack to vertical if too many players
const maxPlayers = Math.max(
yesByNation[Nation.Capella].length,
yesByNation[Nation.Procyon].length
);
const useInline = inline && maxPlayers <= maxInlinePlayers;
const embed = new EmbedBuilder()
.setTitle(resolveTitle(state, yesByNation))
.setColor(resolveColor(state))
.setTimestamp();
if (useInline) {
// Side-by-side — no spacer needed
embed.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: true,
},
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: true,
},
);
} else {
// Vertical — spacer between nations
embed.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: false,
},
);
}
const msgSection = formatMessages(allMessages);
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
return embed;
};
}
// ─── BaseLayout factory ─────────────────────────────────────────────────────── // ─── BaseLayout factory ───────────────────────────────────────────────────────
export const BaseLayout = { export const BaseLayout = {
methods() { methods() {
return { return { formatRow, buildContext };
formatRow,
buildContext,
};
}, },
}; };

View file

@ -1,53 +1,9 @@
import { EmbedBuilder } from "discord.js"; import { PollLayout } from "@ui/types";
import { PollState, Nation, VoteEntry } from "@types"; import { BaseLayout, createBuildEmbed } from "../base-layout";
import { Emoji } from "@systems/emojis";
import { PollLayout, PollEmbedOptions } from "@ui/types";
import {
BaseLayout,
buildYesByNation,
formatNationField,
formatMessages,
resolveColor,
resolveTitle,
resolveFooter,
} from "../base-layout";
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = false; // default layout stacks no-voters
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(resolveTitle(state, yesByNation))
.setColor(resolveColor(state))
.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: false,
},
)
.setTimestamp();
const msgSection = formatMessages(allMessages);
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
return embed;
}
export const defaultLayout: PollLayout = { export const defaultLayout: PollLayout = {
...BaseLayout.methods(), ...BaseLayout.methods(),
name: "default", name: "default",
description: "Standard vertical layout with nation-separated fields", description: "Standard vertical layout with nation-separated fields",
buildEmbed, buildEmbed: createBuildEmbed({ inline: false }),
}; };

View file

@ -1,58 +1,9 @@
import { EmbedBuilder } from "discord.js"; import { PollLayout } from "@ui/types";
import { PollState, Nation } from "@types"; import { BaseLayout, createBuildEmbed } from "../base-layout";
import { Emoji } from "@systems/emojis";
import { PollLayout, PollEmbedOptions } from "@ui/types";
import {
BaseLayout,
buildYesByNation,
formatNationField,
formatMessages,
resolveColor,
resolveTitle,
resolveFooter,
} from "../base-layout";
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = false;
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const maxPlayers = Math.max(
yesByNation[Nation.Capella].length,
yesByNation[Nation.Procyon].length
);
const useInline = maxPlayers <= 5;
const embed = new EmbedBuilder()
.setTitle(resolveTitle(state, yesByNation))
.setColor(resolveColor(state))
.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: useInline,
},
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: useInline,
},
)
.setTimestamp();
const msgSection = formatMessages(allMessages);
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
return embed;
}
export const sideBySideLayout: PollLayout = { export const sideBySideLayout: PollLayout = {
...BaseLayout.methods(), ...BaseLayout.methods(),
name: "side-by-side", name: "side-by-side",
description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)", description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)",
buildEmbed, buildEmbed: createBuildEmbed({ inline: true, maxInlinePlayers: 5 }),
}; };

View file

@ -4,6 +4,7 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types"; import { PollState, VoteEntry, Nation } from "@types";
import { TGKey } from "@systems/tg-key";
// ─── Poll ───────────────────────────────────────────────────────────────────── // ─── Poll ─────────────────────────────────────────────────────────────────────
@ -11,12 +12,13 @@
nationHasRank: boolean; nationHasRank: boolean;
nationHasDelta: boolean; nationHasDelta: boolean;
showNationEmoji?: boolean; showNationEmoji?: boolean;
historyKey?: string historyKey?: TGKey;
} }
export interface PollEmbedOptions { export interface PollEmbedOptions {
overrideLockMsg?: string; overrideLockMsg?: string;
showScoreButton?: boolean; showScoreButton?: boolean;
historyKey?: TGKey;
} }
export interface PollLayout { export interface PollLayout {