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:
1 parent
8cf01b427f
commit
88041d19e1
5 files changed
+77
-9
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 & 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/);
|
||||
});
|
||||
Reference in new issue
Block a user