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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-22 17:42:43 +02:00
1 parent 8cf01b427f
commit 88041d19e1
5 files changed
+77 -9

No files matched your search

+2 -1
View File
@@ -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<string | undefined>(undefined);
const GROUP_PAGE: Record<string, string> = { 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"),
@@ -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<SaveResult>;
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<typeof metaFor>[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({
</div>
);
})}
{version && (
// Spec 2026-10-22: only a recorded end is a date; the rest is an estimate.
<div className="trow">
<span>Version {version.number ?? "now"}</span>
<span>
{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 && (
<small>
<a href={entry.source} target="_blank" rel="noreferrer">
Source
</a>
{entry.note !== undefined && ` · ${entry.note}`}
</small>
)}
</span>
</div>
)}
</div>
<section className="grp">
<h3>Data sources</h3>
@@ -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
@@ -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) => {
+2 -1
View File
@@ -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 };
+38
View File
@@ -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(
<DisplayTimeContext.Provider value="UTC">
<GameSettings
state={migrateLegacy({}, [game], at, () => "account:one")}
initialGame={game}
sources={[]}
onAction={async () => ({ ok: true })}
now={Date.parse(at)}
tab={tab}
/>
</DisplayTimeContext.Provider>,
);
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 &amp; monthlies<\/span><span class="ls">[^<]*<\/span><span class="ls">3 built in/);
expect(html).toMatch(/Cycles<\/span><span class="ls">[^<]*<\/span><span class="ls">4 built in/);
});