From b22602f43126886263e2471033ac3bda0c6ed6fc Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sat, 13 Jun 2026 02:23:56 +0100 Subject: [PATCH] feat: TGKey branded type, PersistentMessage abstraction, createBuildEmbed factory, BaseLayout refactor --- data/updates/v0.8/examples/poll-leaves.json | 4 +- src/subcommands/poll/mark-left.ts | 10 +- src/systems/attendance.ts | 20 +-- src/systems/history.ts | 11 +- src/systems/leaves.ts | 11 +- src/systems/score.ts | 20 ++- src/systems/scores.ts | 11 +- src/systems/tg-key.ts | 52 +++++++ src/systems/tg.ts | 10 +- src/systems/updates.ts | 8 +- src/systems/wrank.ts | 7 +- src/types.ts | 1 - src/ui/poll/base-layout.ts | 150 +++++++++++++------- src/ui/poll/layouts/default.ts | 50 +------ src/ui/poll/layouts/side-by-side.ts | 55 +------ src/ui/types.ts | 4 +- 16 files changed, 218 insertions(+), 206 deletions(-) create mode 100644 src/systems/tg-key.ts diff --git a/data/updates/v0.8/examples/poll-leaves.json b/data/updates/v0.8/examples/poll-leaves.json index 49be87d..76ff9d1 100644 --- a/data/updates/v0.8/examples/poll-leaves.json +++ b/data/updates/v0.8/examples/poll-leaves.json @@ -27,7 +27,7 @@ "userKey": "ayana", "displayName": "Ayana", "characterName": "«MonkeyHunter»", - "characterClass": "GL", + "characterClass": "DM", "characterLevel": 79, "characterNation": "Procyon", "votedAt": "19:46" @@ -36,7 +36,7 @@ "no": [], "leaves": [ { - "characterName": "»Flash«", + "characterName": " «Deystroyer»", "historyKey": "2026-06-11-20" } ], diff --git a/src/subcommands/poll/mark-left.ts b/src/subcommands/poll/mark-left.ts index cf0272d..8495652 100644 --- a/src/subcommands/poll/mark-left.ts +++ b/src/subcommands/poll/mark-left.ts @@ -5,11 +5,7 @@ import { Leaves } from "@systems/leaves"; import { polls, updatePollMessage } from "@systems/poll"; import { CharacterRegistry } from "@registry/character-registry"; import { replyAndDelete } from "@utils"; - -function getCurrentHistoryKey(slot: number): string { - const date = new Date().toISOString().slice(0, 10); - return `${date}-${slot}`; -} +import { TGKey } from "@systems/tg-key"; export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise { 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]; if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); - const historyKey = getCurrentHistoryKey(slot); + const historyKey = TGKey.current({ slot }); Leaves.mark({ characterName: char.name, @@ -59,7 +55,7 @@ export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction) const slot = [...polls.keys()][0]; 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 }); const channel = interaction.channel as TextChannel; diff --git a/src/systems/attendance.ts b/src/systems/attendance.ts index 1d3ce80..11e1142 100644 --- a/src/systems/attendance.ts +++ b/src/systems/attendance.ts @@ -11,11 +11,12 @@ import fs from "fs"; import { Paths } from "@paths"; - import { UserKey, HistoryKey } from "@types"; + import { UserKey } from "@types"; import { Store } from "@systems/store"; - + import { TGKey } from "@systems/tg-key"; + interface AttendanceData { - [historyKey: HistoryKey]: UserKey[]; + [historyKey: TGKey]: UserKey[]; } let _data: AttendanceData = {}; @@ -34,8 +35,7 @@ function save(): void { * Snapshot attendance from poll state at lock time. */ snapshot(slot: number, lockedYesKeys: Set): void { - const date = new Date().toISOString().slice(0, 10); - const historyKey = `${date}-${slot}` as HistoryKey; + const historyKey = TGKey.current({ slot }); _data[historyKey] = [...lockedYesKeys]; save(); }, @@ -43,21 +43,21 @@ function save(): void { /** * Get players who attended a specific TG. */ - players(historyKey: HistoryKey): UserKey[] { + players(historyKey: TGKey): UserKey[] { return _data[historyKey] ?? []; }, /** * Check if a specific player attended. */ - includes(historyKey: HistoryKey, userKey: UserKey): boolean { + includes(historyKey: TGKey, userKey: UserKey): boolean { return (_data[historyKey] ?? []).includes(userKey); }, /** * Check if all attendees have submitted scores. */ - allSubmitted(historyKey: HistoryKey): boolean { + allSubmitted(historyKey: TGKey): boolean { const players = _data[historyKey] ?? []; if (players.length === 0) return false; try { @@ -74,7 +74,7 @@ function save(): void { /** * Get all history keys (for listing past TGs). */ - all(): HistoryKey[] { - return Object.keys(_data) as HistoryKey[]; + all(): TGKey[] { + return Object.keys(_data) as TGKey[]; }, }; \ No newline at end of file diff --git a/src/systems/history.ts b/src/systems/history.ts index e752155..46ce31c 100644 --- a/src/systems/history.ts +++ b/src/systems/history.ts @@ -4,23 +4,22 @@ import { TGResult, TGScore, Nation } from "../types"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; +import { TGKey } from "@systems/tg-key"; 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 { return path.join(HISTORY_DIR, `${key}.json`); } 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 { 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 { diff --git a/src/systems/leaves.ts b/src/systems/leaves.ts index 6e34696..cd539bc 100644 --- a/src/systems/leaves.ts +++ b/src/systems/leaves.ts @@ -15,18 +15,19 @@ * Leaves.formatIndicator({ characterName }) */ - import { UserKey, CharName, HistoryKey } from "@types"; + import { UserKey, CharName } from "@types"; import { Store } from "@systems/store"; import { Paths } from "@paths"; import { Emoji } from "@systems/emojis"; import { Runtime } from "@systems/runtime"; + import { TGKey } from "@systems/tg-key"; Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" }); // ─── Types ──────────────────────────────────────────────────────────────────── interface LeaveRecord { - historyKey: HistoryKey; + historyKey: TGKey; markedBy: UserKey; markedAt: string; } @@ -60,7 +61,7 @@ mark({ characterName, ownerKey, historyKey, markedBy }: { characterName: CharName; ownerKey: UserKey; - historyKey: HistoryKey; + historyKey: TGKey; markedBy: UserKey; }): void { if (!_data[characterName]) { @@ -75,7 +76,7 @@ unmark({ characterName, historyKey }: { characterName: CharName; - historyKey: HistoryKey; + historyKey: TGKey; }): void { if (!_data[characterName]) return; _data[characterName].history = _data[characterName].history.filter( @@ -87,7 +88,7 @@ hasLeft({ characterName, historyKey }: { characterName: CharName; - historyKey: HistoryKey; + historyKey: TGKey; }): boolean { return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false; }, diff --git a/src/systems/score.ts b/src/systems/score.ts index a05ad2a..cf6a81e 100644 --- a/src/systems/score.ts +++ b/src/systems/score.ts @@ -9,10 +9,11 @@ * 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 { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; + import { TGKey } from "@systems/tg-key"; export interface TGScore { userKey: UserKey; @@ -48,14 +49,14 @@ previousRank?: number; } - function getHistoryPath(historyKey: HistoryKey): string { + function getHistoryPath(historyKey: TGKey): string { 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: [] }); } -function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void { +function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { 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({ character, slot, date }: { + get({ character, slot }: { character: Character; slot: SlotHour; - date?: string; }): TGScore | null { - const d = date ?? new Date().toISOString().slice(0, 10); - const historyKey = `${d}-${slot}` as HistoryKey; + const historyKey = TGKey.current({ slot }); const history = loadHistory(historyKey); return history.scores.find( (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 { const week = WRank.currentWeek(); const entry = WRank.entry(character.name, character.nation); - const allKeys = Object.keys(week.scoreIndex[character.name] ?? {}) as HistoryKey[]; const scores: TGScore[] = []; 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( (s) => s.userKey === character.ownerKey && s.characterName === character.name ); @@ -127,7 +125,7 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void submittedByOfficer?: boolean; }): void { const date = new Date().toISOString().slice(0, 10); - const historyKey = `${date}-${slot}` as HistoryKey; + const historyKey = TGKey.current({ slot }); const history = loadHistory(historyKey); // Snapshot W.Rank before recording score diff --git a/src/systems/scores.ts b/src/systems/scores.ts index d7ee543..cb625c8 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -2,6 +2,7 @@ import { TGScore, Nation, ClassKey } from "../types"; import { Config } from "./config"; import { upsertScore, todayString } from "./history"; import { WRank } from "./wrank"; +import { TGKey } from "@systems/tg-key"; // Normalize a slot string to a 24h integer hour // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" @@ -33,10 +34,10 @@ export function normalizeSlot(input: string): number | null { // Detect which slot a submission belongs to based on current time export function detectSlot(): number | null { - const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); - const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000; - const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000; - const now = Date.now(); + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); + const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000; + const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000; + const now = Date.now(); for (const slot of slots) { const today = new Date(); @@ -71,7 +72,7 @@ export interface ScoreSubmission { export function submitScore(sub: ScoreSubmission): void { 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 = { userKey: sub.userKey, diff --git a/src/systems/tg-key.ts b/src/systems/tg-key.ts new file mode 100644 index 0000000..84f5876 --- /dev/null +++ b/src/systems/tg-key.ts @@ -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); + }, + }; \ No newline at end of file diff --git a/src/systems/tg.ts b/src/systems/tg.ts index 3ae944a..074eb2d 100644 --- a/src/systems/tg.ts +++ b/src/systems/tg.ts @@ -12,13 +12,13 @@ * TG.getWeeklySummary({ character }) */ - import { Nation, Character, UserKey, HistoryKey, SlotHour } from "@types"; + import { Nation, Character, UserKey, SlotHour } from "@types"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; import { Score, TGScore, WeeklySummary } from "@systems/score"; import { Attendance } from "@systems/attendance"; - import { Nations } from "@systems/nations"; import { Config } from "@systems/config"; + import { TGKey } from "@systems/tg-key"; export const TG = { // ── Week ────────────────────────────────────────────────────────────────── @@ -66,7 +66,7 @@ // ── Attendance ──────────────────────────────────────────────────────────── getAttendance({ historyKey, nation }: { - historyKey: HistoryKey; + historyKey: TGKey; nation?: Nation; }): UserKey[] { const players = Attendance.players(historyKey); @@ -75,7 +75,7 @@ return players; }, - allSubmitted(historyKey: HistoryKey): boolean { + allSubmitted(historyKey: TGKey): boolean { return Attendance.allSubmitted(historyKey); }, @@ -86,7 +86,7 @@ slot: SlotHour; date?: string; }): TGScore | null { - return Score.get({ character, slot, date }); + return Score.get({ character, slot }); }, getWeeklySummary({ character }: { character: Character }): WeeklySummary { diff --git a/src/systems/updates.ts b/src/systems/updates.ts index 0e2836d..8e846ab 100644 --- a/src/systems/updates.ts +++ b/src/systems/updates.ts @@ -24,6 +24,7 @@ import { WRank } from "@systems/wrank"; import { Leaves } from "@systems/leaves"; import { PersistentMessage } from "@systems/persistent-message"; + import { TGKey } from "@systems/tg-key"; const log = Logger.for("updates"); @@ -79,7 +80,7 @@ }[]; leaves?: { characterName: string; - historyKey: string; + historyKey: TGKey; }[]; } @@ -223,7 +224,10 @@ try { PollUI.setLayout(example.layout); 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}`); embeds.push(exampleEmbed); } finally { diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 03139c0..a7814be 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -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 { Bringer } from "@systems/bringer"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@paths"; +import { TGKey } from "@systems/tg-key"; import { Runtime } from "@systems/runtime"; import { Logger } from "@systems/logger"; import { CharacterRegistry } from "@registry/character-registry"; @@ -40,7 +41,7 @@ export interface WRankEntry { export interface WRankWeek { weekKey: string; entries: Record; - scoreIndex: Record; + scoreIndex: Record; bringer: { [Nation.Capella]: string | null; [Nation.Procyon]: string | null; @@ -146,7 +147,7 @@ export const WRank = { cls: ClassKey, nation: Nation, pts: number, - historyKey: HistoryKey + historyKey: TGKey ): void { const week = ensureWeek(WRank.weekKey()); const list = week.entries[nation]; diff --git a/src/types.ts b/src/types.ts index b9294d4..5acc41b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,6 @@ export type UserKey = string; export type DiscordId = string; export type CharName = string; export type SlotHour = number; -export type HistoryKey = string; export type VoteType = "yes" | "no"; export type ConfirmType = "yes" | "no"; diff --git a/src/ui/poll/base-layout.ts b/src/ui/poll/base-layout.ts index 55d8b0b..4e38eed 100644 --- a/src/ui/poll/base-layout.ts +++ b/src/ui/poll/base-layout.ts @@ -2,24 +2,18 @@ * BaseLayout — shared poll layout functions. * All layouts inherit these via BaseLayout.methods() spread. * 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 { WRank, WRankEntry } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; import { Leaves } from "@systems/leaves"; import { Emoji } from "@systems/emojis"; + import { TGKey } from "@systems/tg-key"; import { format } from "@format"; - import { PollRowContext } from "@ui/types"; + import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; // ─── W.Rank formatting ──────────────────────────────────────────────────────── @@ -41,8 +35,8 @@ // ─── Row formatting ─────────────────────────────────────────────────────────── export function formatRow(entry: VoteEntry, context: PollRowContext): string { - const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); - const nation = entry.characterNation; + const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); + const nation = entry.characterNation; const wRankEntry = entry.characterName && entry.characterNation ? WRank.entry(entry.characterName, entry.characterNation) @@ -76,8 +70,8 @@ // Nation emoji prefix if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; - // Cockroach indicator (left TG) - if (entry.userKey && entry.characterName && context.historyKey) { + // Cockroach indicator + if (entry.characterName && context.historyKey) { if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; } @@ -91,7 +85,7 @@ export function buildContext( entries: VoteEntry[], nation: Nation, - options?: { showNationEmoji?: boolean; historyKey?: string } + options?: { showNationEmoji?: boolean; historyKey?: TGKey } ): PollRowContext { const nationHasRank = entries.some((e) => { 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( allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] @@ -129,34 +141,14 @@ .join("\n"); } - // ─── Embed helpers ──────────────────────────────────────────────────────────── - - 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 { + export function resolveColor(state: PollState): number { if (state.confirmed === "yes") return 0x57f287; if (state.confirmed === "no") return 0xed4245; if (state.locked) return 0x888888; return 0xe8a317; } - export function resolveTitle(state: any, yesByNation: Record): string { + export function resolveTitle(state: PollState, yesByNation: Record): string { const capellaEmoji = Emoji.get("capella"); const procyonEmoji = Emoji.get("procyon"); const counts = !state.locked && state.confirmed === null @@ -169,16 +161,16 @@ 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 === "no") return Config.get({ section: "poll", key: "confirmNo" }); if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); 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; - noVoters: VoteEntry[]; + noVoters: VoteEntry[]; allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]; } { const yesByNation: Record = { @@ -189,10 +181,7 @@ const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; for (const entry of state.yes.values()) { - const nation: Nation = entry.characterNation === Nation.Procyon - ? Nation.Procyon - : Nation.Capella; - + const nation: Nation = (entry.characterNation as Nation) ?? Nation.Capella; yesByNation[nation].push(entry); allMessages.push({ entry, voteType: "yes" }); } @@ -204,13 +193,76 @@ 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 ─────────────────────────────────────────────────────── export const BaseLayout = { methods() { - return { - formatRow, - buildContext, - }; + return { formatRow, buildContext }; }, }; \ No newline at end of file diff --git a/src/ui/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts index a9036da..13a1ea1 100644 --- a/src/ui/poll/layouts/default.ts +++ b/src/ui/poll/layouts/default.ts @@ -1,53 +1,9 @@ -import { EmbedBuilder } from "discord.js"; -import { PollState, Nation, VoteEntry } from "@types"; -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; -} +import { PollLayout } from "@ui/types"; +import { BaseLayout, createBuildEmbed } from "../base-layout"; export const defaultLayout: PollLayout = { ...BaseLayout.methods(), name: "default", description: "Standard vertical layout with nation-separated fields", - buildEmbed, + buildEmbed: createBuildEmbed({ inline: false }), }; \ No newline at end of file diff --git a/src/ui/poll/layouts/side-by-side.ts b/src/ui/poll/layouts/side-by-side.ts index 7cc9a6a..901e0ff 100644 --- a/src/ui/poll/layouts/side-by-side.ts +++ b/src/ui/poll/layouts/side-by-side.ts @@ -1,58 +1,9 @@ -import { EmbedBuilder } from "discord.js"; -import { PollState, Nation } from "@types"; -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; -} +import { PollLayout } from "@ui/types"; +import { BaseLayout, createBuildEmbed } from "../base-layout"; export const sideBySideLayout: PollLayout = { ...BaseLayout.methods(), name: "side-by-side", description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)", - buildEmbed, + buildEmbed: createBuildEmbed({ inline: true, maxInlinePlayers: 5 }), }; \ No newline at end of file diff --git a/src/ui/types.ts b/src/ui/types.ts index 5ee58e2..91bdecb 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -4,6 +4,7 @@ import { EmbedBuilder } from "discord.js"; import { PollState, VoteEntry, Nation } from "@types"; + import { TGKey } from "@systems/tg-key"; // ─── Poll ───────────────────────────────────────────────────────────────────── @@ -11,12 +12,13 @@ nationHasRank: boolean; nationHasDelta: boolean; showNationEmoji?: boolean; - historyKey?: string + historyKey?: TGKey; } export interface PollEmbedOptions { overrideLockMsg?: string; showScoreButton?: boolean; + historyKey?: TGKey; } export interface PollLayout {