feat(custom): store the reader's games and events, and back them up

Adds the two localStorage stores behind PRD F13 and joins their events to the
feed's in App, so they sort, filter, focus, expire and tick through exactly the
same code paths rather than a parallel set.

Three decisions worth naming:

- Export carries customGames and customEvents. These exist in one browser and
  nowhere else — not in the feed, not on a server — so an export without them
  would be a backup that loses the half the reader typed. Import merges by id
  like every other set and never removes.
- A date is read in the reader's timezone, and a bare end date means the end of
  that day. Someone who types 20 Aug means the 20th where they are; the feed's
  00:00Z day boundaries are a parser declining to guess a time the source never
  printed, which is a different situation from being told directly.
- An impossible date is refused rather than rolled over, because Date.parse
  turns 30 February into 2 March and a silently shifted date is the failure this
  product exists to prevent.

Deleting a game is refused while it still holds events, and deleting an event
leaves its marks and logged days alone — reaching into three stores on one tap
is how a misclick costs somebody a streak.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 18:19:59 +02:00
co-authored by Claude Opus 5
parent 3702ee7a4c
commit d046758671
4 changed files with 386 additions and 23 deletions
+40
View File
@@ -16,6 +16,7 @@ import { metaFor } from "../src/shared/games.ts";
import { dailiesId } from "../src/shared/daily.ts";
import { eventId, GameId } from "../src/shared/schema.ts";
import { clockFor } from "../src/shared/time.ts";
import { readerInstant } from "../src/client/state/useCustom.ts";
const AT = "2026-08-17T12:00:00.000Z";
@@ -244,3 +245,42 @@ describe("knownLane", () => {
expect(knownLane("mygame:gone", mine)).toBe(false);
});
});
describe("readerInstant", () => {
// Timezone-independent assertions on purpose: the point of this helper is
// that it reads a typed date in the *reader's* zone, so the tests check the
// relationships that must hold in any of them rather than pinning UTC.
const localDate = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA"); // YYYY-MM-DD in local time
test("a typed date comes back as that same date where the reader is", () => {
// Someone who types 20 August means the 20th where they are, and has to see
// the 20th back — not the 19th because a server is five hours behind.
for (const boundary of ["start", "end"] as const) {
const iso = readerInstant("2026-08-20", null, boundary);
expect(iso).not.toBeNull();
expect(localDate(iso!)).toBe("2026-08-20");
}
});
test("a bare start is the beginning of the day and a bare end is the end of it", () => {
// Which is how a person reads "20 Aug 3 Sep": through the 3rd, not up to
// the first second of it.
const start = readerInstant("2026-08-20", null, "start")!;
const end = readerInstant("2026-08-20", null, "end")!;
expect(Date.parse(end) - Date.parse(start)).toBe(86_399_000);
});
test("a stated time is kept", () => {
const iso = readerInstant("2026-08-20", "18:30", "start")!;
const d = new Date(iso);
expect(d.getHours()).toBe(18);
expect(d.getMinutes()).toBe(30);
});
test("returns null for a date it cannot read, rather than a wrong one", () => {
expect(readerInstant("", null, "start")).toBeNull();
expect(readerInstant("not-a-date", null, "start")).toBeNull();
expect(readerInstant("2026-02-30", null, "start")).toBeNull();
});
});