From 99af24e0d3e4896c0e3971b51e513514ce27d800 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 6 Nov 2026 11:16:31 +0100 Subject: [PATCH] feat(routines): model Catch up per account The old panel resolved titles and days inline, which is how raw IDs reached readers: a followed pass is placed under a feed-cycle ID no lookup knew. A pure model lets every rule be tested without React. The count starts at a reader's first tick because nothing records when they began following an item. Co-Authored-By: Claude Opus 5.5 --- src/client/state/reader/catchUp.ts | 406 +++++++++++++++++++++++++++++ test/catch-up.test.tsx | 282 ++++++++++++++++++++ 2 files changed, 688 insertions(+) create mode 100644 src/client/state/reader/catchUp.ts create mode 100644 test/catch-up.test.tsx diff --git a/src/client/state/reader/catchUp.ts b/src/client/state/reader/catchUp.ts new file mode 100644 index 0000000..0a782a5 --- /dev/null +++ b/src/client/state/reader/catchUp.ts @@ -0,0 +1,406 @@ +import { catchUpDays } from "../../../shared/daily.ts"; +import { metaFor } from "../../../shared/games.ts"; +import { readGoal } from "../../../shared/goal.ts"; +import { isTimerPeriod, type RoutinePeriod } from "../../../shared/period.ts"; +import { + clockFromRevision, + readerClockFor, + readerDayKey, + resolveReaderClock, +} from "../../../shared/readerClock.ts"; +import { routinesFor } from "../../../shared/routines.ts"; +import { TaskSchema } from "../../../shared/task.ts"; +import { formatInZone } from "../displayTime.ts"; +import { recordOf } from "./actions.ts"; +import { contentEvents } from "./contentRouting.ts"; +import { routineRows, type RoutineRow } from "./routines.ts"; +import type { Account, ClockRevision, ReaderState } from "./schema.ts"; + +/** + * What the Routines page's Catch up section draws for one account (spec + * 2026-11-06). Pure, so the rules that keep it honest are tested without React: + * the strip's bounds are PRD F17's, and the count only says what a reader's own + * ticks can support, because nothing records when they began following. + */ +export interface CatchUpDay { + key: string; + ticked: boolean; + today: boolean; + disabled: boolean; +} +export interface CatchUpValue { + period: string; + label: string; + text: string; +} +export interface CatchUpItem { + id: string; + revisionId: string; + title: string; + /** Empty in Older records, where the group's heading says what it is. */ + kind: string; + /** Null for a number and for a routine with no earlier period to catch up on. */ + days: CatchUpDay[] | null; + /** Ticks outside the strip: stored, so still reachable (PRD F17). */ + earlier: CatchUpDay[]; + /** Newest first; null when this item is not measured in numbers. */ + values: CatchUpValue[] | null; + behind: number; +} +export interface CatchUpGroup { + id: string; + title: string; + note: string | null; + items: CatchUpItem[]; +} +export interface AccountCatchUp { + accountId: string; + items: CatchUpItem[]; + older: CatchUpGroup[]; + behind: number; +} + +const SPAN = 14; +const KIND: Record = { + day: "Dailies", + week: "Weeklies", + month: "Monthlies", + cycle: "Cycles", +}; +const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/; + +const formatKey = (key: string, options: Intl.DateTimeFormatOptions) => + new Date(`${key}T00:00:00Z`).toLocaleDateString("en-GB", { + timeZone: "UTC", + ...options, + }); + +function periodLabel(key: string, scope: string): string { + if (!DATE_KEY.test(key)) return key; + const day = formatKey(key, { day: "numeric", month: "short" }); + if (scope === "week") return `Week of ${day}`; + if (scope === "month") return formatKey(key, { month: "short", year: "numeric" }); + if (scope === "day") return day; + return `From ${day}`; +} + +const loggedDays = (raw: unknown): string[] => { + const days = recordOf(raw).days; + return Array.isArray(days) + ? days.filter((d): d is string => typeof d === "string") + : []; +}; + +/** A task or built-in, whichever owns this id. */ +function shapeOf( + state: ReaderState, + account: Account, + id: string, +): { + period: RoutinePeriod; + measure: { kind: string; target?: number | undefined; unit?: string | undefined }; +} | null { + const builtin = routinesFor(account.game).find((r) => r.id === id); + if (builtin) return builtin; + const task = TaskSchema.safeParse(state.tasks[id]); + return task.success ? task.data : null; +} + +function valuesOf( + state: ReaderState, + account: Account, + id: string, + raw: unknown, + scope: string, +): CatchUpValue[] { + const data = state.data[account.id]; + const measure = shapeOf(state, account, id)?.measure; + const goal = readGoal(recordOf(data?.progress[id]).goal); + const target = + measure?.target ?? (goal?.measure === "amount" ? goal.target : undefined); + const unit = measure?.unit ? ` ${measure.unit}` : ""; + return Object.entries(recordOf(raw)) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([period, entry]) => { + const value = recordOf(entry).value; + return { + period, + label: periodLabel(period, scope), + text: + typeof value !== "number" + ? "unreadable amount" + : target === undefined + ? `${value}${unit}` + : `${value} of ${target}${unit}`, + }; + }); +} + +/** A raw ID is never shown: it means nothing to a reader. */ +export function catchUpTitle( + state: ReaderState, + account: Account, + id: string, + rows: readonly RoutineRow[], +): string { + if (id.startsWith("dailies:")) return metaFor(account.game, {}).dailyTasks; + const row = rows.find((r) => r.id === id || r.placementId === id); + if (row) return row.title; + const builtin = routinesFor(account.game).find((r) => r.id === id); + if (builtin) return builtin.title; + const task = recordOf(state.tasks[id]).title; + if (typeof task === "string") return task; + const event = contentEvents(state).find((e) => e.id === id); + if (event) return event.title; + return id.startsWith("feed-cycle:") ? "Battle pass" : "Removed item"; +} + +function revisionNote(revision: ClockRevision): string { + const reset = revision.reset + ? `${revision.reset.hour}:${String(revision.reset.minute).padStart(2, "0")}` + : "Automatic"; + const when = revision.effectiveAt.startsWith("1970") + ? "Migrated history" + : formatInZone(revision.effectiveAt, true, null); + return `${revision.region} · ${reset} · ${when}`; +} + +const isMigrated = (revision: ClockRevision) => + revision.effectiveAt.startsWith("1970"); + +/** The ids a reset timing holds something for: an emptied log is not a record. */ +function loggedIds(state: ReaderState, account: Account, revisionId: string) { + const data = state.data[account.id]!; + const daily = data.daily[revisionId] ?? {}; + const amounts = data.amounts[revisionId] ?? {}; + return [...new Set([...Object.keys(daily), ...Object.keys(amounts)])].filter( + (id) => + loggedDays(daily[id]).length > 0 || + Object.keys(recordOf(amounts[id])).length > 0, + ); +} + +/** Names a day the reader unticked this visit, in `catchUpFor`'s `kept`. */ +export const keepKey = ( + accountId: string, + revisionId: string, + id: string, + day: string, +) => JSON.stringify([accountId, revisionId, id, day]); + +export function catchUpFor( + state: ReaderState, + account: Account, + now: number, + /** + * Older days are offered because they are recorded, so unticking one would + * take its button away and a mis-tap could not be undone. These stay offered, + * unticked, for the visit. + */ + kept: ReadonlySet = new Set(), +): AccountCatchUp { + const data = state.data[account.id]; + if (!data) return { accountId: account.id, items: [], older: [], behind: 0 }; + const keptDays = [...kept].map( + (k) => JSON.parse(k) as [string, string, string, string], + ).filter(([accountId]) => accountId === account.id); + const keptFor = (revisionId: string, id: string) => + keptDays + .filter(([, rev, item]) => rev === revisionId && item === id) + .map(([, , , day]) => day); + const heldIds = (revisionId: string) => [ + ...new Set([ + ...loggedIds(state, account, revisionId), + ...keptDays.filter(([, rev]) => rev === revisionId).map(([, , id]) => id), + ]), + ]; + const current = resolveReaderClock(account, now); + const revision = account.clocks.find((c) => c.id === current.revision); + const floor = + !revision || isMigrated(revision) ? null : Date.parse(revision.effectiveAt); + const rows = routineRows(state, account.id, now); + const events = contentEvents(state); + + const items = rows + .filter((row) => row.placement.followed && !row.placementId) + .map((row): CatchUpItem => { + const id = row.id, + shape = shapeOf(state, account, id), + today = readerDayKey(now, row.clock), + isEvent = + row.task === null && + !id.startsWith("routine:") && + !id.startsWith("dailies:"); + // When it opens on the reader's server, not `startsAt`: a day-precision + // date opens at that server's reset, which is a game-day after midnight UTC. + const event = isEvent ? events.find((e) => e.id === id) : undefined; + const notBefore = Math.max( + floor ?? -Infinity, + event ? readerClockFor(event, account, now).startsMs : -Infinity, + ); + const stripless = + row.status.measure === "amount" || + (shape !== null && isTimerPeriod(shape.period)); + const ticked = new Set(row.days); + const strip = stripless + ? [] + : catchUpDays( + now, + row.clock.region, + account.game, + notBefore === -Infinity ? null : notBefore, + SPAN, + row.clock, + ); + const shown = new Set(strip); + const first = row.days.length ? [...row.days].sort()[0]! : null; + const countable = + row.cadence === "day" && + row.status.measure === "days" && + readGoal(recordOf(data.progress[id]).goal) === undefined; + const behind = + countable && first !== null + ? strip.filter((k) => k >= first && k < today && !ticked.has(k)).length + : 0; + const amounts = data.amounts[current.revision]?.[id]; + return { + id, + revisionId: current.revision, + title: row.title, + kind: isEvent ? "Event" : KIND[row.cadence], + days: stripless + ? null + : strip.map((key) => ({ + key, + ticked: ticked.has(key), + today: key === today, + disabled: false, + })), + earlier: [...new Set([...ticked, ...keptFor(current.revision, id)])] + .filter((key) => !shown.has(key)) + .sort() + .map((key) => ({ + key, + ticked: ticked.has(key), + today: false, + disabled: key > today, + })), + values: + stripless || Object.keys(recordOf(amounts)).length + ? valuesOf(state, account, id, amounts, row.status.scope) + : null, + behind, + }; + }); + + const listed = new Set(items.map((i) => i.id)); + const older: CatchUpGroup[] = []; + const recorded = (id: string, revisionId: string, days: CatchUpDay[]) => { + const amounts = data.amounts[revisionId]?.[id]; + const unit = shapeOf(state, account, id)?.period; + return { + id, + revisionId, + title: catchUpTitle(state, account, id, rows), + kind: "", + days, + earlier: [], + values: Object.keys(recordOf(amounts)).length + ? valuesOf(state, account, id, amounts, unit?.unit ?? "event") + : null, + behind: 0, + } satisfies CatchUpItem; + }; + + // Recorded days only: offering new ones could claim a day after an event ended. + const gone = heldIds(current.revision) + .filter((id) => !listed.has(id)) + .map((id) => { + const today = readerDayKey(now, current); + const ticked = new Set(loggedDays(data.daily[current.revision]?.[id])); + return recorded( + id, + current.revision, + [...new Set([...ticked, ...keptFor(current.revision, id)])].sort().map((key) => ({ + key, + ticked: ticked.has(key), + today: false, + disabled: key > today, + })), + ); + }); + if (gone.length) + older.push({ + id: current.revision, + title: "Ended or no longer followed", + note: null, + items: gone, + }); + + const revisions = account.clocks + .slice() + .sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt)); + revisions.forEach((rev, index) => { + if (rev.id === current.revision) return; + const next = revisions[index + 1]; + const last = next ? Math.min(now, Date.parse(next.effectiveAt) - 1) : now; + const clock = clockFromRevision(rev); + const groupItems = heldIds(rev.id).map((id) => { + // A routine with its own reset hour keeps its own day labels, as its row does. + const own = TaskSchema.safeParse(state.tasks[id]); + const hour = own.success ? own.data.resetHour : undefined; + const itemClock = + hour === undefined ? clock : { ...clock, resetHour: hour, resetMinute: 0 }; + const today = readerDayKey(last, itemClock); + const ticked = new Set(loggedDays(data.daily[rev.id]?.[id])); + const offered = catchUpDays( + last, + rev.region, + account.game, + isMigrated(rev) ? null : Date.parse(rev.effectiveAt), + SPAN, + itemClock, + ); + return recorded( + id, + rev.id, + [...new Set([...offered, ...ticked, ...keptFor(rev.id, id)])].sort().map((key) => ({ + key, + ticked: ticked.has(key), + today: false, + disabled: key > today, + })), + ); + }); + if (groupItems.length) + older.push({ + id: rev.id, + title: "Before reset timing changed", + note: revisionNote(rev), + items: groupItems, + }); + }); + + return { + accountId: account.id, + items, + older, + behind: items.reduce((sum, item) => sum + item.behind, 0), + }; +} + +export function defaultCatchUpAccount( + list: readonly AccountCatchUp[], +): string | null { + return (list.find((a) => a.behind > 0) ?? list[0])?.accountId ?? null; +} + +/** Keeps a live choice even at zero, so a tick never moves the list under the reader. */ +export function pickAccount( + selected: string | null, + list: readonly AccountCatchUp[], +): string | null { + return list.some((a) => a.accountId === selected) + ? selected + : defaultCatchUpAccount(list); +} diff --git a/test/catch-up.test.tsx b/test/catch-up.test.tsx new file mode 100644 index 0000000..994f376 --- /dev/null +++ b/test/catch-up.test.tsx @@ -0,0 +1,282 @@ +import { expect, test } from "bun:test"; +import { migrateLegacy } from "../src/client/state/reader/migration.ts"; +import { pairKey, type ReaderState } from "../src/client/state/reader/schema.ts"; +import { + catchUpFor, + catchUpTitle, + defaultCatchUpAccount, + keepKey, + pickAccount, + type AccountCatchUp, +} from "../src/client/state/reader/catchUp.ts"; +import { routineRows } from "../src/client/state/reader/routines.ts"; +import { sampleEvent } from "./helpers/reader.ts"; + +const at = "2026-11-06T12:00:00.000Z", + now = Date.parse(at); +const pin = { followed: true, checklist: true, timeline: false }; +const ID = "account:one"; + +function setup(game = "endfield"): { state: ReaderState; revision: string } { + const state = migrateLegacy({}, [game], at, () => ID); + return { state, revision: state.accounts[ID]!.clocks[0]!.id }; +} +const ofAccount = (state: ReaderState): AccountCatchUp => + catchUpFor(state, state.accounts[ID]!, now); +const itemOf = (state: ReaderState, id: string) => { + const item = ofAccount(state).items.find((i) => i.id === id); + if (!item) throw new Error(`${id} is not listed`); + return item; +}; +const tick = (s: ReaderState, rev: string, id: string, days: unknown) => { + (s.data[ID]!.daily[rev] ??= {})[id] = { days }; +}; +const DAILY = "dailies:endfield"; + +test("a feed-cycle id with no live row reads Battle pass", () => { + const { state } = setup(); + const title = catchUpTitle(state, state.accounts[ID]!, "feed-cycle:endfield:battle-pass", []); + expect(title).toBe("Battle pass"); +}); + +test("a followed battle pass is titled from its event and gets no strip", () => { + const { state } = setup("genshin"); + const pass = { + ...sampleEvent, + id: "genshin:moontrace:2026-10-23", + title: "Moontrace", + startsAt: "2026-10-23T00:00:00.000Z", + endsAt: "2026-12-02T00:00:00.000Z", + sourceUrl: "https://genshin-impact.fandom.com/wiki/Battle_Pass/Moontrace", + }; + state.contentEvents = { [pass.id]: pass }; + const placement = "feed-cycle:genshin:battle-pass"; + state.accounts[ID]!.routines[placement] = pin; + const rows = routineRows(state, ID, now); + expect(rows.some((r) => r.placementId === placement)).toBe(true); + expect(catchUpTitle(state, state.accounts[ID]!, placement, rows)).toBe("Moontrace"); + const ids = ofAccount(state).items.map((i) => i.id); + expect(ids).not.toContain(placement); + expect(ids).not.toContain(pass.id); +}); + +test("an unknown id reads Removed item", () => { + const { state } = setup(); + expect(catchUpTitle(state, state.accounts[ID]!, "mystery:thing", [])).toBe("Removed item"); +}); + +test("dailies use the name Today's dailies uses", () => { + const { state } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + expect(itemOf(state, DAILY).title).toBe("Daily missions"); + expect(itemOf(state, DAILY).kind).toBe("Dailies"); +}); + +test("a daily strip holds 14 days ending today", () => { + const { state } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + const days = itemOf(state, DAILY).days!; + expect(days).toHaveLength(14); + expect(days[0]!.key).toBe("2026-10-24"); + expect(days.at(-1)).toMatchObject({ key: "2026-11-06", today: true, disabled: false }); + expect(days.filter((d) => d.today)).toHaveLength(1); +}); + +test("an amount routine gets values, not days", () => { + const { state, revision } = setup(); + const id = "routine:endfield:weekly-routine"; + state.accounts[ID]!.routines[id] = pin; + (state.data[ID]!.amounts[revision] ??= {})[id] = { + "2026-11-02": { value: 8 }, + "2026-10-26": { value: 10 }, + }; + const item = itemOf(state, id); + expect(item.title).toBe("Weekly Routine"); + expect(item.kind).toBe("Weeklies"); + expect(item.days).toBeNull(); + expect(item.values![0]).toMatchObject({ label: "Week of 2 Nov", text: "8 of 10 points" }); + expect(item.values![1]!.label).toBe("Week of 26 Oct"); +}); + +test("an amount routine with nothing logged has an empty list, not a strip", () => { + const { state } = setup(); + const id = "routine:endfield:weekly-routine"; + state.accounts[ID]!.routines[id] = pin; + expect(itemOf(state, id).days).toBeNull(); + expect(itemOf(state, id).values).toEqual([]); +}); + +test("a banner routine has no strip", () => { + const { state } = setup(); + const id = "routine:endfield:aic-quota-permits"; + state.accounts[ID]!.routines[id] = pin; + expect(itemOf(state, id).days).toBeNull(); +}); + +test("behind counts unticked days since the first tick, not today", () => { + const { state, revision } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + tick(state, revision, DAILY, ["2026-11-03", "2026-11-05"]); + expect(itemOf(state, DAILY).behind).toBe(1); + expect(ofAccount(state).behind).toBe(1); +}); + +test("a never-ticked routine counts nothing", () => { + const { state } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + expect(itemOf(state, DAILY).behind).toBe(0); +}); + +test("a weekly counts nothing", () => { + const { state, revision } = setup(); + const id = "routine:endfield:weekly-routine"; + state.accounts[ID]!.routines[id] = pin; + tick(state, revision, id, ["2026-10-26"]); + expect(itemOf(state, id).behind).toBe(0); +}); + +const dailyEvent = { + ...sampleEvent, + id: "genshin:login:2026-10-30", + title: "Login Campaign", + startsAt: "2026-11-01T00:00:00.000Z", + endsAt: "2026-11-20T00:00:00.000Z", +}; +function withEvent() { + const made = setup("genshin"); + made.state.seenEvents[pairKey(ID, dailyEvent.id)] = { + accountId: ID, + event: dailyEvent, + firstSeenAt: at, + lastSeenAt: at, + clockRevision: made.revision, + }; + return made; +} + +test("a daily event is titled, typed Event, and its strip starts when it did", () => { + const { state } = withEvent(); + state.data[ID]!.progress[dailyEvent.id] = { daily: true }; + const item = itemOf(state, dailyEvent.id); + expect(item.title).toBe("Login Campaign"); + expect(item.kind).toBe("Event"); + expect(item.days![0]!.key).toBe("2026-11-01"); + expect(item.days!.length).toBeLessThan(14); +}); + +test("an event with a goal counts nothing", () => { + const { state, revision } = withEvent(); + state.data[ID]!.progress[dailyEvent.id] = { + goal: { period: "week", measure: "days", required: 3, weekStartsOn: 1 }, + }; + tick(state, revision, dailyEvent.id, ["2026-11-02"]); + expect(itemOf(state, dailyEvent.id).behind).toBe(0); +}); + +test("a tick before the strip is under earlier", () => { + const { state, revision } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + tick(state, revision, DAILY, ["2026-09-01", "2026-11-05"]); + const item = itemOf(state, DAILY); + expect(item.earlier.map((d) => d.key)).toEqual(["2026-09-01"]); + expect(item.earlier[0]).toMatchObject({ ticked: true, disabled: false }); + expect(item.days!.find((d) => d.key === "2026-11-05")!.ticked).toBe(true); +}); + +test("a tick after today is disabled, and kept", () => { + const { state, revision } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + tick(state, revision, DAILY, ["2026-11-07"]); + expect(itemOf(state, DAILY).earlier[0]).toMatchObject({ key: "2026-11-07", disabled: true }); +}); + +test("unreadable logs do not throw", () => { + const { state, revision } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + const id = "routine:endfield:weekly-routine"; + state.accounts[ID]!.routines[id] = pin; + tick(state, revision, DAILY, "x"); + (state.data[ID]!.amounts[revision] ??= {})[id] = { "2026-11-02": { value: "x" } }; + expect(itemOf(state, DAILY).days!.every((d) => !d.ticked)).toBe(true); + expect(itemOf(state, id).values![0]!.text).toBe("unreadable amount"); +}); + +test("a logged item no longer followed lands in Older records with recorded days only", () => { + const { state, revision } = setup(); + const id = "routine:endfield:focus-commissions"; + tick(state, revision, id, ["2026-10-01"]); + const { items, older } = ofAccount(state); + expect(items.map((i) => i.id)).not.toContain(id); + expect(older).toHaveLength(1); + expect(older[0]!.title).toBe("Ended or no longer followed"); + expect(older[0]!.items[0]).toMatchObject({ title: "Etchspace Salvage: Focus Commissions" }); + expect(older[0]!.items[0]!.days!.map((d) => d.key)).toEqual(["2026-10-01"]); +}); + +test("an earlier reset timing is its own older group", () => { + const { state, revision } = setup(); + const account = state.accounts[ID]!; + const first = account.clocks[0]!; + account.clocks.push({ ...first, id: "rev-2", effectiveAt: "2026-11-04T00:00:00.000Z" }); + tick(state, revision, DAILY, ["2026-11-01"]); + const { older } = ofAccount(state); + expect(older).toHaveLength(1); + expect(older[0]!.title).toBe("Before reset timing changed"); + expect(older[0]!.note).toContain("Migrated history"); + const item = older[0]!.items[0]!; + expect(item.revisionId).toBe(revision); + expect(item.days!.find((d) => d.key === "2026-11-01")!.ticked).toBe(true); +}); + +test("an item with nothing logged is not in older", () => { + const { state, revision } = setup(); + tick(state, revision, "routine:endfield:focus-commissions", []); + expect(ofAccount(state).older).toEqual([]); +}); + +test("a recorded day unticked this visit stays offered, so a mis-tap can be undone", () => { + const { state, revision } = setup(); + const id = "routine:endfield:focus-commissions"; + const kept = new Set([keepKey(ID, revision, id, "2026-10-01")]); + tick(state, revision, id, []); + const older = catchUpFor(state, state.accounts[ID]!, now, kept).older; + expect(older[0]!.items[0]!.days).toEqual([ + { key: "2026-10-01", ticked: false, today: false, disabled: false }, + ]); +}); + +test("an earlier day unticked this visit stays under earlier", () => { + const { state, revision } = setup(); + state.accounts[ID]!.routines[DAILY] = pin; + const kept = new Set([keepKey(ID, revision, DAILY, "2026-09-01")]); + const item = catchUpFor(state, state.accounts[ID]!, now, kept).items[0]!; + expect(item.earlier).toEqual([ + { key: "2026-09-01", ticked: false, today: false, disabled: false }, + ]); +}); + +test("a day unticked on another account is not offered here", () => { + const { state, revision } = setup(); + const kept = new Set([keepKey("account:other", revision, DAILY, "2026-09-01")]); + expect(catchUpFor(state, state.accounts[ID]!, now, kept).older).toEqual([]); +}); + +const summary = (accountId: string, behind: number): AccountCatchUp => ({ + accountId, + behind, + items: [], + older: [], +}); + +test("defaultCatchUpAccount picks the first behind, else first, else null", () => { + expect(defaultCatchUpAccount([summary("a", 0), summary("b", 2), summary("c", 1)])).toBe("b"); + expect(defaultCatchUpAccount([summary("a", 0), summary("b", 0)])).toBe("a"); + expect(defaultCatchUpAccount([])).toBeNull(); +}); + +test("pickAccount keeps a live selection, and falls back when it is gone", () => { + const list = [summary("a", 0), summary("b", 2)]; + expect(pickAccount("a", list)).toBe("a"); + expect(pickAccount("gone", list)).toBe("b"); + expect(pickAccount(null, list)).toBe("b"); +});