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 <noreply@anthropic.com>
This commit is contained in:
1 parent
64a441eefc
commit
99af24e0d3
2 files changed
+688
No files matched your search
@@ -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<RoutineRow["cadence"], string> = {
|
||||
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<string> = 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user