From 88041d19e139366eddea8099088e0973a40e8981 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 22 Oct 2026 17:42:43 +0200 Subject: [PATCH] feat(settings): show a game's version timer on its About tab A routine that resets with the version is offered, tallied and switched with the cycles everywhere routines are sorted by kind, not only in the strip. Co-Authored-By: Claude Opus 5.5 --- src/client/components/Controls.tsx | 3 +- .../components/redesign/GameSettings.tsx | 39 ++++++++++++++++--- .../components/redesign/SetupWizard.tsx | 3 +- src/client/state/reader/onboarding.ts | 3 +- test/game-settings.test.tsx | 38 ++++++++++++++++++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index d4d935f..66494bc 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -10,6 +10,7 @@ import { YourOwn } from "./YourOwn.tsx"; import { isCustomGameId } from "../../shared/custom.ts"; import { exceptionLine, setFor, shownFor, type PerGame } from "../../shared/perGame.ts"; import { asOfLabel, offered, routinesFor } from "../../shared/routines.ts"; +import { cadenceOf } from "../../shared/period.ts"; const SettingsFilter = createContext(undefined); const GROUP_PAGE: Record = { Games: "games", "Server region": "time", Appearance: "appearance", "What you see": "lists", "Your own games and events": "general", "Your progress": "backup" }; @@ -117,7 +118,7 @@ export function Controls({ ...(own.tasks ?? []).map((t) => t.period.unit), ...routineGames .filter((g) => shownFor(routines, g)) - .flatMap((g) => routinesFor(g).filter((r) => offered(r, standardPulls)).map((r) => r.period.unit)), + .flatMap((g) => routinesFor(g).filter((r) => offered(r, standardPulls)).map((r) => cadenceOf(r.period))), ]; const taskPeriods = { week: periods.includes("week"), diff --git a/src/client/components/redesign/GameSettings.tsx b/src/client/components/redesign/GameSettings.tsx index 36f469d..37fd1e3 100644 --- a/src/client/components/redesign/GameSettings.tsx +++ b/src/client/components/redesign/GameSettings.tsx @@ -28,6 +28,8 @@ import { useContext } from "react"; import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts"; import { resolveReaderClock } from "../../../shared/readerClock.ts"; import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts"; +import { VERSIONS, versionWindow } from "../../../shared/versions.ts"; +import { cadenceOf } from "../../../shared/period.ts"; export type ActionHandler = (action: ReaderAction) => Promise; export const GAME_TABS = ["setup", "codes", "about"] as const; export type GameTab = (typeof GAME_TABS)[number]; @@ -46,6 +48,7 @@ export function GameSettings({ initialGame, tab: heldTab, onTab, + now = Date.now(), }: { state: ReaderState; sources: SourceHealth[]; @@ -55,6 +58,8 @@ export function GameSettings({ // When the parent holds the tab, switching games keeps the reader on it. tab?: GameTab; onTab?: (tab: GameTab) => void; + /** Fixed in tests; the live clock otherwise. */ + now?: number; }) { const renderedMeta = useGameMeta(); const games = orderGames( @@ -94,7 +99,8 @@ export function GameSettings({ state.customGames as Parameters[1], ); const zone = useContext(DisplayTimeContext), - now = Date.now(), + version = versionWindow(selected, now), + entry = VERSIONS.find((v) => v.game === selected && version?.number === v.number), short = renderedMeta(selected).short; const freshCodes = codes ? newCodes(codes, state.codeSeen[selected] ?? [], selected).length @@ -186,6 +192,27 @@ export function GameSettings({ ); })} + {version && ( + // Spec 2026-10-22: only a recorded end is a date; the rest is an estimate. +
+ Version {version.number ?? "now"} + + {version.end !== undefined + ? `ends ${formatInZone(version.end, true, zone)} (in ${formatRemaining(version.end - now)})` + : version.estimate !== undefined + ? `next version expected ~${formatInZone(version.estimate, false, zone)} (estimate)` + : "next version not yet dated"} + {entry && ( + + + Source + + {entry.note !== undefined && ` ยท ${entry.note}`} + + )} + +
+ )}

Data sources

@@ -600,8 +627,8 @@ function AccountSetup({ const builtins = routinesFor(a.game); const availability = { dailies: !a.game.startsWith("mygame:"), - weeklyMonthly: builtins.some((r) => r.period.unit !== "cycle"), - cycles: builtins.some((r) => r.period.unit === "cycle"), + weeklyMonthly: builtins.some((r) => cadenceOf(r.period) !== "cycle"), + cycles: builtins.some((r) => cadenceOf(r.period) === "cycle"), }; /** * Saved on each click. Only the kind that changed touches its routines, so @@ -622,7 +649,7 @@ function AccountSetup({ }; if (changed.includes("dailies")) place(`dailies:${a.game}`, next.dailies); for (const r of builtins) { - const kind = r.period.unit === "cycle" ? "cycles" : "weeklyMonthly"; + const kind = cadenceOf(r.period) === "cycle" ? "cycles" : "weeklyMonthly"; if (changed.includes(kind)) place(r.id, next[kind]); } const result = await onAction({ @@ -657,7 +684,7 @@ function AccountSetup({ about: "Weekly goals and monthly restocks, such as bosses and shop pulls", ids: builtins - .filter((r) => r.period.unit !== "cycle") + .filter((r) => cadenceOf(r.period) !== "cycle") .map((r) => r.id), }, { @@ -666,7 +693,7 @@ function AccountSetup({ about: "Routines on a game-specific schedule, such as a 14-day endgame mode", ids: builtins - .filter((r) => r.period.unit === "cycle") + .filter((r) => cadenceOf(r.period) === "cycle") .map((r) => r.id), }, ] as const diff --git a/src/client/components/redesign/SetupWizard.tsx b/src/client/components/redesign/SetupWizard.tsx index a7a841d..4a731e7 100644 --- a/src/client/components/redesign/SetupWizard.tsx +++ b/src/client/components/redesign/SetupWizard.tsx @@ -5,6 +5,7 @@ import { GAMES, metaFor, type GameMeta } from "../../../shared/games.ts"; import { offered, routinesFor } from "../../../shared/routines.ts"; import type { ReaderState, TrackingIntent } from "../../state/reader/schema.ts"; import type { SetupDraft } from "../../state/reader/onboarding.ts"; +import { cadenceOf } from "../../../shared/period.ts"; // A translation of the Round 6 B onboarding (design/mockups/round6/b-rail.html, `onboarding()`): // preferences, a two-column game list, one tracking table and view cards. @@ -87,7 +88,7 @@ export function SetupWizard({ : routinesFor(g).filter( (r) => offered(r, standard) && - (r.period.unit === "cycle") === (k === "cycles"), + (cadenceOf(r.period) === "cycle") === (k === "cycles"), ).length; const withGames = (choose: (current: string[]) => string[]) => set((d) => { diff --git a/src/client/state/reader/onboarding.ts b/src/client/state/reader/onboarding.ts index 41f6d3e..297a804 100644 --- a/src/client/state/reader/onboarding.ts +++ b/src/client/state/reader/onboarding.ts @@ -3,6 +3,7 @@ import type { View } from "../usePrefs.ts"; import { routinesFor, offered } from "../../../shared/routines.ts"; import { createAccount, changeAccountRegion } from "./accounts.ts"; import type { AccountId, ReaderState, TrackingIntent } from "./schema.ts"; +import { cadenceOf } from "../../../shared/period.ts"; export interface SetupDraft { region: Region; interface: "relaxed" | "structured"; @@ -46,7 +47,7 @@ export function setupReader( for (const r of routinesFor(game)) { const followed = offered(r, existing.prefs.standardPulls === true) && - (r.period.unit === "cycle" ? intent.cycles : intent.weeklyMonthly); + (cadenceOf(r.period) === "cycle" ? intent.cycles : intent.weeklyMonthly); routines[r.id] = { followed, checklist: followed, timeline: followed }; } accounts[a.id] = { ...a, intent, routines }; diff --git a/test/game-settings.test.tsx b/test/game-settings.test.tsx index 9db6f12..9ff18ff 100644 --- a/test/game-settings.test.tsx +++ b/test/game-settings.test.tsx @@ -79,3 +79,41 @@ test("game settings shows the tab the parent holds, so switching games keeps it" expect(html(game)).toContain("Redeem codes"); } }); + +import { DisplayTimeContext, formatInZone } from "../src/client/state/displayTime.ts"; +// Spec 2026-10-22: a game's version timer sits on its About tab. +const about = (game: string, at: string, tab: "about" | "setup" = "about") => + render( + + "account:one")} + initialGame={game} + sources={[]} + onAction={async () => ({ ok: true })} + now={Date.parse(at)} + tab={tab} + /> + , + ); +test("a game's About tab shows its version, when it ends and where that comes from", () => { + const html = about("wuwa", "2026-10-22T14:30:00.000Z"); + expect(html).toContain("Version 3.7"); + expect(html).toContain(`ends ${formatInZone("2026-11-11T10:30:00Z", true, "UTC")} (in 19d 20h)`); + expect(html).toContain('href="https://allthings.how/wuthering-waves-3-7-banners-schedule-hsin-and-suoming-release-dates/"'); + expect(html).toContain("Confirmed in game, 2026-10-22."); +}); +test("past the recorded end, the About tab gives the next version and an estimate", () => { + const html = about("wuwa", "2026-11-12T00:00:00.000Z"); + expect(html).toContain("Version 3.8"); + expect(html).toContain(`next version expected ~${formatInZone("2026-12-23T10:30:00Z", false, "UTC")} (estimate)`); +}); +test("a game with no version table has no version row", () => { + expect(about("genshin", "2026-10-22T14:30:00.000Z")).not.toContain("Version "); +}); +test("a routine that resets with the version is offered with the cycles", () => { + // Spec 2026-10-22, decision 10: Tower of Adversity and the three shop lines + // (this count has always included the standard line). + const html = about("wuwa", "2026-10-22T14:30:00.000Z", "setup"); + expect(html).toMatch(/Weeklies & monthlies<\/span>[^<]*<\/span>3 built in/); + expect(html).toMatch(/Cycles<\/span>[^<]*<\/span>4 built in/); +});