From a74bbd3efec0914cd481510f5fdcc9bd255c1a41 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 14:24:05 +0200 Subject: [PATCH] feat(changelog): remember the newest change the reader has seen First-run setup marks a new reader up to date, since changes mean nothing before you've used the app. An absent marker therefore means an install from before the changelog, which is what lets the launch be announced to existing readers once. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 8 ++------ src/client/state/usePrefs.ts | 29 +++++++++++++++++++++++++++++ test/changelog.test.ts | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index e86b361..e93cbe4 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -19,7 +19,7 @@ import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useTasks } from "./state/useTasks.ts"; import { useAmounts } from "./state/useAmounts.ts"; -import { adoptNewLanes, usePrefs, type Prefs } from "./state/usePrefs.ts"; +import { adoptNewLanes, firstRunPrefs, usePrefs, type Prefs } from "./state/usePrefs.ts"; import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts"; import { snapDayWidth } from "./state/zoom.ts"; import { useCustom, type EventDraft } from "./state/useCustom.ts"; @@ -487,11 +487,7 @@ export function App() { - update({ - onboarded: true, - hiddenGames: games.filter((g) => !chosen.includes(g)), - view: chosenView, - }) + update(firstRunPrefs(games, chosen, chosenView)) } /> diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 44a9555..d03bdb0 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -7,6 +7,7 @@ import { KEYS, readJson, writeJson } from "./storage.ts"; import type { TimelineGroup } from "./lanes.ts"; import { DEFAULT_THEME_CHOICE, type ThemeChoice } from "./theme.ts"; import { DEFAULT_DAY_WIDTH, snapDayWidth } from "./zoom.ts"; +import { ENTRIES, newestId } from "../../shared/changelog.ts"; /** * Which of the two views the reader is looking at. @@ -192,6 +193,30 @@ export interface Prefs { routineGames?: LaneId[] | undefined; /** Games already asked "track its weekly tasks?", so the question is asked once. */ routinesAsked?: LaneId[] | undefined; + /** + * The newest changelog entry the reader dismissed (PRD F18). Absent means + * unrecorded, not "seen nothing": first-run setup sets it, so an absent value + * is an install from before the changelog, shown the launch window once. + */ + changelogSeen?: string | undefined; +} + +/** + * What finishing first-run setup writes. A new reader is marked up to date + * with the changelog: a list of changes means nothing before you've used the + * thing it changes. + */ +export function firstRunPrefs( + games: readonly LaneId[], + chosen: readonly LaneId[], + view: View, +): Partial { + return { + onboarded: true, + hiddenGames: games.filter((g) => !chosen.includes(g)), + view, + changelogSeen: newestId(ENTRIES), + }; } export function defaults(): Prefs { @@ -321,6 +346,10 @@ export function restorePrefsValue(incoming: unknown): Prefs { } } + if (result.changelogSeen !== undefined && typeof result.changelogSeen !== "string") { + result.changelogSeen = undefined; + } + // Built-in tasks: string arrays, or absent. for (const key of ["routineGames", "routinesAsked"] as const) { const value: unknown = result[key]; diff --git a/test/changelog.test.ts b/test/changelog.test.ts index 7d96ed2..85911a2 100644 --- a/test/changelog.test.ts +++ b/test/changelog.test.ts @@ -63,3 +63,22 @@ describe("what a reader has not seen", () => { expect(compareUrl("https://r", "a1b2c3d", "e4f5a6b")).toBe("https://r/compare/a1b2c3d...e4f5a6b"); }); }); + +describe("the seen marker in prefs", () => { + test("restore keeps a string and drops anything else", async () => { + const { restorePrefsValue } = await import("../src/client/state/usePrefs.ts"); + expect(restorePrefsValue({ changelogSeen: "check-in-goals" }).changelogSeen).toBe("check-in-goals"); + expect(restorePrefsValue({ changelogSeen: 7 }).changelogSeen).toBeUndefined(); + expect(restorePrefsValue({}).changelogSeen).toBeUndefined(); + }); + + test("first-run setup marks everything seen", async () => { + const { firstRunPrefs } = await import("../src/client/state/usePrefs.ts"); + expect(firstRunPrefs(["genshin", "hsr"], ["hsr"], "soon")).toEqual({ + onboarded: true, + hiddenGames: ["genshin"], + view: "soon", + changelogSeen: newestId(ENTRIES), + }); + }); +});