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 <[email protected]>
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import { isCustomGameId, type LaneId } from "../../shared/custom.ts";
|
||
import type { Region } from "../../shared/schema.ts";
|
||
import { guessRegion } from "../../shared/time.ts";
|
||
import type { SortMode } from "./sort.ts";
|
||
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.
|
||
*
|
||
* Lives here rather than in `App` because it is the reader's answer to "how do
|
||
* I read this?", and component state loses it on every reload — a reader who
|
||
* prefers the timeline was being put back on the list each time they opened the
|
||
* page, with nothing to blame but the app forgetting.
|
||
*/
|
||
export type View = "soon" | "timeline";
|
||
|
||
export interface Prefs {
|
||
region: Region;
|
||
/**
|
||
* Games the reader has switched off.
|
||
*
|
||
* Still stored as the inverse, but no longer because a new game should
|
||
* appear by default — see `knownGames`, which is what decides that now. It
|
||
* stays the inverse because it is what every existing device has written
|
||
* down, and rewriting a live key space to say the same thing differently
|
||
* costs a migration and buys nothing.
|
||
*/
|
||
hiddenGames: LaneId[];
|
||
/**
|
||
* Every lane this reader has been offered.
|
||
*
|
||
* A game we add is a game they never asked for. Turning eleven lanes into
|
||
* fourteen under someone who plays two is not a feature arriving, it is
|
||
* their calendar filling with events they will never open — so a lane that
|
||
* is new *to them* arrives switched off, and the games chips in settings are
|
||
* where they take it up.
|
||
*
|
||
* Absent means "never recorded", which is not the same as "has been offered
|
||
* nothing": every existing reader is in that state, and seeding it from
|
||
* what is on screen is what stops this from switching their games off the
|
||
* first time they load a build that has it. Their own games (`mygame:`) are
|
||
* recorded here too but never auto-hidden — they asked for those by typing
|
||
* them in.
|
||
*/
|
||
knownGames?: LaneId[];
|
||
/**
|
||
* The order the reader put their games in.
|
||
*
|
||
* Absent means they have never placed one — not that they have no order, and
|
||
* not an empty order. Every install predating this is in that state, so the
|
||
* fallback has to be a rule rather than a stored value: `orderGames` sorts
|
||
* alphabetically by name, which is what the first-run picker already does.
|
||
* A game added later trails everything they placed by hand.
|
||
*
|
||
* `| undefined` is explicit because `exactOptionalPropertyTypes` is on and
|
||
* "Reset to A–Z" is a patch that writes the field away — `{ gameOrder:
|
||
* undefined }` has to be assignable for the reset to typecheck, and
|
||
* `JSON.stringify` drops it on the way to storage.
|
||
*
|
||
* Not a key space and not a migration: one more field in the single `prefs`
|
||
* blob, like `timelineGroup`. It rides the export for free. Standard
|
||
* progress import restores progress, dailies, ignores and the reader's own
|
||
* games — leaving local prefs intact — while "Import all" restores preferences
|
||
* alongside them, bringing game order, region, theme and active games back
|
||
* when migrating hosts.
|
||
*/
|
||
gameOrder?: LaneId[] | undefined;
|
||
/**
|
||
* One game to look at right now, or null for all of them.
|
||
*
|
||
* A lens, not a setting: it never changes `hiddenGames`, and a focus on a
|
||
* game that is switched off or has left the feed is ignored rather than
|
||
* obeyed (`resolveFocus`), so it can never leave the reader on a blank page
|
||
* with no visible cause.
|
||
*/
|
||
focusGame: LaneId | null;
|
||
/** How the list is ordered. Deadline order is the default and the fallback. */
|
||
sort: SortMode;
|
||
/**
|
||
* The view they were last reading. The list is the default: the page's whole
|
||
* claim is answering "what expires next" in one look, and the timeline
|
||
* answers "when is everything" — a slower question. One tap moves between
|
||
* them and the choice is remembered from then on.
|
||
*/
|
||
view: View;
|
||
/**
|
||
* How wide one day is on the timeline, in px.
|
||
*
|
||
* Stored as the measurement rather than a step number, so the ladder in
|
||
* `state/zoom.ts` can change without silently rescaling boards that were set
|
||
* before it did. Read through `snapDayWidth`, which is what makes a value
|
||
* from an older export — or a corrupted one — land on something renderable.
|
||
*/
|
||
timelineDayWidth: number;
|
||
/**
|
||
* How the timeline stacks its bars: a lane per game, or every game together
|
||
* in deadline order.
|
||
*
|
||
* Remembered for the same reason `view` and `timelineDayWidth` are — it is
|
||
* the reader's answer to "how do I read this?", and a board that went back to
|
||
* lanes on every reload would make them say it again each time.
|
||
*
|
||
* Defaults to `"game"`, which is the board every existing reader already has.
|
||
* A stored pref wins, so shipping this moves nobody's view.
|
||
*/
|
||
timelineGroup: TimelineGroup;
|
||
/**
|
||
* Whether events that have not started yet are shown at all — the board
|
||
* plots them, and the checklist keeps its "Not started yet" section.
|
||
*
|
||
* Off by default, and that is a claim about what this app is for: it answers
|
||
* *what expires next*, and a reader with fourteen lanes has a next patch
|
||
* queued behind every one of them. On the board it is also structural, since
|
||
* the window is drawn from what is plotted — showing the future pushes the
|
||
* right edge weeks past today and shrinks every running bar to make room.
|
||
*
|
||
* It sits with `showCompleted` and `showIgnored` because it is the same
|
||
* question: what is the reader allowed to look at. It was called
|
||
* `timelineUpcoming` while it governed only the board — see `adoptRenamed`,
|
||
* which carries a reader's stored answer across rather than resetting it.
|
||
*/
|
||
showUpcoming: boolean;
|
||
/**
|
||
* Whether those unstarted events keep to their own block on the board, under
|
||
* a "Not started yet" heading, or sit in one deadline order with everything
|
||
* else.
|
||
*
|
||
* Two readings of the same board, and both are right for somebody. Split
|
||
* answers "what is on now, and what is queued behind it" — the shape of a
|
||
* patch. Mixed answers "what runs out first", full stop, which is the
|
||
* question a Gantt chart is for: an event opening on Friday and closing on
|
||
* Sunday is a nearer deadline than one running now until October, and the
|
||
* split order can never show that.
|
||
*
|
||
* The board only: the checklist splits them structurally, into a section with
|
||
* a heading of its own, and always has.
|
||
*
|
||
* Defaults to `true`, the board as it was before this existed. Only read when
|
||
* `showUpcoming` is on — with nothing unstarted plotted there is no block to
|
||
* keep apart — but stored either way, so switching the parent back on
|
||
* restores the answer they gave rather than a default.
|
||
*/
|
||
timelineSplitUpcoming: boolean;
|
||
/**
|
||
* Whether to guess which events repeat daily from what the source printed.
|
||
* Off leaves only the ones the reader marked themselves; it never discards a
|
||
* mark or a logged day, so it is reversible.
|
||
*
|
||
* Off by default: the guess reads source wording and is wrong in both
|
||
* directions, so a reader starts with only the dailies they chose. Readers
|
||
* who already switched it on keep it — stored prefs win over this default.
|
||
*/
|
||
detectDaily: boolean;
|
||
/**
|
||
* Whether Today's dailies carries each game's standing chore — commissions,
|
||
* sanity, daily training.
|
||
*
|
||
* On by default, because every reader has these today and a setting that
|
||
* silently removes something on upgrade is worse than one nobody notices.
|
||
* Off hides only the chores: an event with a checklist is something the
|
||
* reader engaged with and stays either way. Like `detectDaily` it discards
|
||
* nothing — the ticks live under `dailies:<game>` and are never written from
|
||
* here, so switching back on restores every one of them.
|
||
*/
|
||
showChores: boolean;
|
||
showCompleted: boolean;
|
||
/** Reveal events the reader has ignored, so they can be restored. */
|
||
showIgnored: boolean;
|
||
/**
|
||
* Which ground the app is drawn on: `dark`, `light`, or `system` to follow
|
||
* the device.
|
||
*
|
||
* Dark is the default and not a placeholder — see `DEFAULT_THEME_CHOICE`. The
|
||
* value only decides colour: nothing about what is shown, sorted, counted or
|
||
* stored changes with it, which is why it can be flipped mid-read with
|
||
* nothing to save.
|
||
*/
|
||
theme: ThemeChoice;
|
||
/** False until the reader confirms or changes the guessed region. */
|
||
regionConfirmed: boolean;
|
||
/** False until the reader has picked their games on first run. */
|
||
onboarded: boolean;
|
||
/**
|
||
* Games whose built-in weekly tasks the reader turned on (routines.ts, PRD
|
||
* F17). Absent means none: built-ins are opt-in, so an install from before
|
||
* they existed gets none rather than all.
|
||
*/
|
||
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<Prefs> {
|
||
return {
|
||
onboarded: true,
|
||
hiddenGames: games.filter((g) => !chosen.includes(g)),
|
||
view,
|
||
changelogSeen: newestId(ENTRIES),
|
||
};
|
||
}
|
||
|
||
export function defaults(): Prefs {
|
||
return {
|
||
region: guessRegion(),
|
||
hiddenGames: [],
|
||
focusGame: null,
|
||
sort: "ending",
|
||
view: "soon",
|
||
timelineDayWidth: DEFAULT_DAY_WIDTH,
|
||
timelineGroup: "game",
|
||
showUpcoming: false,
|
||
timelineSplitUpcoming: true,
|
||
detectDaily: false,
|
||
showChores: true,
|
||
showCompleted: true,
|
||
showIgnored: false,
|
||
theme: DEFAULT_THEME_CHOICE,
|
||
regionConfirmed: false,
|
||
onboarded: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What to record and what to switch off when the set of lanes changes.
|
||
*
|
||
* Pure and separate from the hook because it decides whether a reader's games
|
||
* get switched off, which is the kind of thing that should be provable rather
|
||
* than watched for. Returns `null` when there is nothing to do, so the caller
|
||
* writes to storage only when something actually changed.
|
||
*
|
||
* Two cases it must not get wrong:
|
||
*
|
||
* - **`known` absent.** Every reader who installed before this existed is in
|
||
* that state, and it means "unrecorded", not "has been offered nothing".
|
||
* Seeding records what is already on their screen and switches nothing off.
|
||
* - **A lane they invented.** `mygame:` lanes are the reader asking for a game
|
||
* by typing it in, so they are recorded but never hidden. Only a lane that
|
||
* arrived because we added a source turns up switched off.
|
||
*/
|
||
export function adoptNewLanes(
|
||
lanes: readonly LaneId[],
|
||
known: readonly LaneId[] | undefined,
|
||
hidden: readonly LaneId[],
|
||
): Partial<Prefs> | null {
|
||
// An empty list is a feed that has not arrived, not a reader with no games.
|
||
if (lanes.length === 0) return null;
|
||
if (known === undefined) return { knownGames: [...lanes] };
|
||
|
||
const fresh = lanes.filter((lane) => !known.includes(lane));
|
||
if (fresh.length === 0) return null;
|
||
|
||
const unasked = fresh.filter(
|
||
(lane) => !isCustomGameId(lane) && !hidden.includes(lane),
|
||
);
|
||
return {
|
||
knownGames: [...known, ...fresh],
|
||
hiddenGames: [...hidden, ...unasked],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A stored `prefs` object with the one renamed field carried across.
|
||
*
|
||
* `timelineUpcoming` became `showUpcoming` when it stopped being about the
|
||
* board alone. Dropping the old name would not lose data — this is one blob
|
||
* under one key, not a key space — but it would silently reset the answer of
|
||
* every reader who had switched the future on, and they would have to find the
|
||
* setting again to say a thing they already said.
|
||
*
|
||
* A stored new name always wins, so this can never overwrite a fresher answer
|
||
* with a stale one; and once written back under the new name the old one is
|
||
* simply an unread leftover. Pure and exported so that is a test rather than a
|
||
* claim.
|
||
*/
|
||
export function adoptRenamed(
|
||
stored: Partial<Prefs> & { timelineUpcoming?: boolean },
|
||
): Partial<Prefs> {
|
||
const { timelineUpcoming, ...rest } = stored;
|
||
if (timelineUpcoming === undefined || rest.showUpcoming !== undefined) {
|
||
return rest;
|
||
}
|
||
return { ...rest, showUpcoming: timelineUpcoming };
|
||
}
|
||
|
||
/**
|
||
* Takes incoming preferences (e.g. from an imported backup file) and restores a
|
||
* clean, type-safe Prefs object, falling back to defaults for any missing or
|
||
* invalid values.
|
||
*/
|
||
export function restorePrefsValue(incoming: unknown): Prefs {
|
||
const base = defaults();
|
||
if (typeof incoming !== "object" || incoming === null) {
|
||
return base;
|
||
}
|
||
const raw = adoptRenamed(incoming as Partial<Prefs> & { timelineUpcoming?: boolean });
|
||
const result: Prefs = {
|
||
...base,
|
||
...raw,
|
||
};
|
||
|
||
// Region
|
||
if (result.region !== "america" && result.region !== "europe" && result.region !== "asia") {
|
||
result.region = base.region;
|
||
}
|
||
|
||
// Games arrays
|
||
if (!Array.isArray(result.hiddenGames)) {
|
||
result.hiddenGames = base.hiddenGames;
|
||
} else {
|
||
result.hiddenGames = result.hiddenGames.filter((g): g is LaneId => typeof g === "string");
|
||
}
|
||
|
||
if (result.knownGames !== undefined) {
|
||
if (!Array.isArray(result.knownGames)) {
|
||
delete result.knownGames;
|
||
} else {
|
||
result.knownGames = result.knownGames.filter((g): g is LaneId => typeof g === "string");
|
||
}
|
||
}
|
||
|
||
if (result.gameOrder !== undefined) {
|
||
if (!Array.isArray(result.gameOrder)) {
|
||
result.gameOrder = undefined;
|
||
} else {
|
||
result.gameOrder = result.gameOrder.filter((g): g is LaneId => typeof g === "string");
|
||
}
|
||
}
|
||
|
||
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];
|
||
if (value === undefined) continue;
|
||
result[key] = Array.isArray(value)
|
||
? value.filter((g): g is LaneId => typeof g === "string")
|
||
: undefined;
|
||
}
|
||
|
||
// focusGame
|
||
if (result.focusGame !== null && typeof result.focusGame !== "string") {
|
||
result.focusGame = null;
|
||
}
|
||
|
||
// Sort
|
||
if (result.sort !== "ending" && result.sort !== "doing") {
|
||
result.sort = base.sort;
|
||
}
|
||
|
||
// View
|
||
if (result.view !== "soon" && result.view !== "timeline") {
|
||
result.view = base.view;
|
||
}
|
||
|
||
// Zoom
|
||
if (typeof result.timelineDayWidth === "number") {
|
||
result.timelineDayWidth = snapDayWidth(result.timelineDayWidth);
|
||
} else {
|
||
result.timelineDayWidth = base.timelineDayWidth;
|
||
}
|
||
|
||
// Timeline grouping
|
||
if (result.timelineGroup !== "game" && result.timelineGroup !== "ending") {
|
||
result.timelineGroup = base.timelineGroup;
|
||
}
|
||
|
||
// Theme
|
||
if (result.theme !== "dark" && result.theme !== "light" && result.theme !== "system") {
|
||
result.theme = base.theme;
|
||
}
|
||
|
||
// Booleans
|
||
result.showUpcoming = typeof result.showUpcoming === "boolean" ? result.showUpcoming : base.showUpcoming;
|
||
result.timelineSplitUpcoming = typeof result.timelineSplitUpcoming === "boolean" ? result.timelineSplitUpcoming : base.timelineSplitUpcoming;
|
||
result.detectDaily = typeof result.detectDaily === "boolean" ? result.detectDaily : base.detectDaily;
|
||
result.showChores = typeof result.showChores === "boolean" ? result.showChores : base.showChores;
|
||
result.showCompleted = typeof result.showCompleted === "boolean" ? result.showCompleted : base.showCompleted;
|
||
result.showIgnored = typeof result.showIgnored === "boolean" ? result.showIgnored : base.showIgnored;
|
||
result.regionConfirmed = typeof result.regionConfirmed === "boolean" ? result.regionConfirmed : base.regionConfirmed;
|
||
result.onboarded = typeof result.onboarded === "boolean" ? result.onboarded : base.onboarded;
|
||
|
||
return result;
|
||
}
|
||
|
||
export function usePrefs() {
|
||
const [prefs, setPrefs] = useState<Prefs>(() => ({
|
||
...defaults(),
|
||
...adoptRenamed(readJson<Partial<Prefs>>(KEYS.prefs, {})),
|
||
}));
|
||
|
||
useEffect(() => {
|
||
writeJson(KEYS.prefs, prefs);
|
||
}, [prefs]);
|
||
|
||
const update = useCallback((patch: Partial<Prefs>) => {
|
||
setPrefs((prev) => ({ ...prev, ...patch }));
|
||
}, []);
|
||
|
||
const restorePrefs = useCallback((incoming: unknown) => {
|
||
setPrefs(restorePrefsValue(incoming));
|
||
}, []);
|
||
|
||
const toggleGame = useCallback((game: LaneId) => {
|
||
setPrefs((prev) => ({
|
||
...prev,
|
||
hiddenGames: prev.hiddenGames.includes(game)
|
||
? prev.hiddenGames.filter((g) => g !== game)
|
||
: [...prev.hiddenGames, game],
|
||
}));
|
||
}, []);
|
||
|
||
return { prefs, update, toggleGame, restorePrefs };
|
||
}
|
||
|