diff --git a/CLAUDE.md b/CLAUDE.md index c9ad50c..26f8434 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,10 @@ event's length. It adds **no schema field**, so the feed contract is untouched. - **A tick is never removed except by the reader**, including ticks outside the window the feed now claims. A source quietly moving a date must not erase a fortnight's streak that exists nowhere else. +- **Detection is a default, not a verdict.** The reader can mark any event as repeating, or unmark + one detection got wrong (`progress.daily`, resolved by `resolveDaily`). Store an override only + when it *disagrees* with detection — recording agreement would freeze today's guess and stop a + better parser from ever reaching that event. ## Conventions diff --git a/README.md b/README.md index 4c21238..4ad71dc 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,11 @@ Repeating events are recognised from what the source actually printed — a logi wording like "daily", "check-in", "7-day". Nothing is assumed from a game's habits, and an event whose end was never announced gets a day count rather than a checklist of invented length. +That guess is only a starting point. Open any event and you can say **it repeats daily** — the +grind whose page never prints the word still gets a checklist — or dismiss one the wording caught +by mistake. Anything you mark joins today's dailies at the top of the page, so ticking it off is one +tap rather than a trip back into the event. + ## Sorting Two orders, and the toggle sits with the list rather than in settings: diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 90c7262..b806d20 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -211,6 +211,7 @@ Namespaced, versioned, and small. Nothing here ever goes to the server. | `status` | `"doing"` \| `"done"` \| absent | Where they are with it | | `effort` | `"quick"` \| `"short"` \| `"long"` \| `"grind"` \| absent | How much work they reckon it is | | `note` | free text | Anything worth remembering | +| `daily` | `true` \| `false` \| absent | Whether it repeats daily, overruling detection | An entry with none of the three set is deleted rather than kept, so the store stays a set of things the reader actually said something about. @@ -256,6 +257,13 @@ Dailiness is derived from the published event — `type: "login"`, or wording li "check-in", "7-day" in the title or summary — and never from a game's habits or an event's length. It adds no schema field, so nothing about the feed contract or the event ID changes. +**The reader overrules detection.** `progress.daily` records their answer and wins outright +(`resolveDaily`); absent means they have not said, so detection stands. An override that merely +agrees with detection is **not stored** (`dailyOverride`) — freezing today's guess into their data +would stop a later parser improvement from ever reaching that event. This is the only field in +`progress` that changes what the app *shows* rather than recording what the reader did, which is +why it lives beside their other notes rather than in the feed. + ### Migration from `completions` `completions` used membership to mean "done", which cannot express "started". `progress` replaces it diff --git a/src/client/App.tsx b/src/client/App.tsx index afe927a..2ffac8a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -17,7 +17,7 @@ import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { usePrefs } from "./state/usePrefs.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; -import { dailySummary, isDaily } from "../shared/daily.ts"; +import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import type { GameId } from "../shared/schema.ts"; type View = "soon" | "calendar"; @@ -85,9 +85,16 @@ export function App() { return daily.daysFor(id).length > 0 ? "doing" : "idle"; }; + /** + * Whether an event repeats, the reader's own answer included. Detection reads + * the source's wording; they can overrule it either way. + */ + const repeatsDaily = (row: RowEvent): boolean => + resolveDaily(row.event, prog.progress[row.event.id]?.daily); + /** Today's state for a repeating event, or undefined if it does not repeat. */ const dailyBadge = (row: RowEvent): DailyBadge | undefined => { - if (!isDaily(row.event)) return undefined; + if (!repeatsDaily(row)) return undefined; const summary = dailySummary({ startsMs: row.clock.startsMs, endsMs: row.clock.endsMs, @@ -255,6 +262,7 @@ export function App() { that expires tonight rather than next patch. */} !prefs.hiddenGames.includes(g))} + events={live.filter(repeatsDaily).map((r) => r.event)} region={prefs.region} now={now} daysFor={daily.daysFor} @@ -386,7 +394,10 @@ export function App() { note={prog.progress[openRow.event.id]?.note ?? ""} region={prefs.region} now={now} + daily={repeatsDaily(openRow)} + detectedDaily={isDaily(openRow.event)} dailyDays={daily.daysFor(openRow.event.id)} + onDaily={prog.setDaily} onToggleDay={daily.toggleDay} onStatus={prog.setStatus} onEffort={prog.setEffort} diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 08ae203..a31effc 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -1,6 +1,6 @@ import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts"; import { gameMeta } from "../../shared/games.ts"; -import type { GameId, Region } from "../../shared/schema.ts"; +import type { GachaEvent, GameId, Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; /** @@ -12,32 +12,42 @@ import { formatRemaining } from "../../shared/time.ts"; * than feed data. Ticking one is stored in exactly the same day log an event's * checklist uses, so streaks and exports work the same way for both. * + * Running events that repeat sit here too, so ticking today off never means + * opening a sheet to find the checklist. The checklist is still where the whole + * run lives — this is just today's line of it. + * * Sits above the event list because it is the one part of the page that is * answerable in ten seconds and expires tonight. */ export function Dailies({ games, + events, region, now, daysFor, onToggleDay, }: { games: GameId[]; + /** Live events that repeat daily — detected, or marked by the reader. */ + events: GachaEvent[]; region: Region; now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; }) { - if (games.length === 0) return null; + if (games.length === 0 && events.length === 0) return null; const today = dayKey(now, region); + const doneEvents = events.filter((e) => daysFor(e.id).includes(today)); const done = games.filter((g) => daysFor(dailiesId(g)).includes(today)); + const total = games.length + events.length; + const complete = done.length + doneEvents.length; return (

- Today's dailies · {done.length}/{games.length} + Today's dailies · {complete}/{total}

resets in {formatRemaining(msUntilReset(now, region))} @@ -48,61 +58,108 @@ export function Dailies({ {games.map((id) => { const game = gameMeta(id); const key = dailiesId(id); - const days = daysFor(key); - const isDone = days.includes(today); - const streak = streakOf(days, today); - return ( -

  • - + ariaLabel={`${game.name} dailies — ${game.dailyTasks}`} + days={daysFor(key)} + today={today} + onToggle={() => onToggleDay(key, today)} + /> +
  • + ); + })} + + {events.map((event) => { + const game = gameMeta(event.game); + return ( +
  • + onToggleDay(event.id, today)} + />
  • ); })}

    - {done.length === games.length + {complete === total ? "All done. Nothing else expires tonight." - : `${waiting(games.length - done.length)} still waiting on you today.`} + : `${waiting(total - complete)} still waiting on you today.`}

    ); } +/** + * One thing to tick off today. + * + * The same pill whether it is a game's standing chore or an event that repeats: + * to the reader at 23:50 they are the same job, and the distinction between + * "the app knows about this" and "a wiki published it" is ours, not theirs. + */ +function TickChip({ + label, + hue, + title, + ariaLabel, + days, + today, + onToggle, +}: { + label: string; + hue: string; + title: string; + ariaLabel: string; + days: string[]; + today: string; + onToggle: () => void; +}) { + const isDone = days.includes(today); + const streak = streakOf(days, today); + + return ( + + ); +} + function waiting(n: number): string { - return n === 1 ? "One game" : `${n} games`; + return n === 1 ? "One thing" : `${n} things`; } diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 589417d..103b028 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -6,7 +6,7 @@ import { pressure, pressureReason, type Effort } from "../../shared/effort.ts"; import type { Status } from "../state/useProgress.ts"; import { ProgressControls } from "./ProgressControls.tsx"; import { DailyChecklist } from "./DailyChecklist.tsx"; -import { isDaily } from "../../shared/daily.ts"; +import { dailyOverride } from "../../shared/daily.ts"; import type { Region } from "../../shared/schema.ts"; import { Meter, URGENCY_COLOR } from "./Meter.tsx"; @@ -19,7 +19,10 @@ export function EventDetail({ note, region, now, + daily, + detectedDaily, dailyDays, + onDaily, onToggleDay, onToggle, onIgnore, @@ -36,8 +39,13 @@ export function EventDetail({ note: string; region: Region; now: number; + /** Whether to treat this as repeating, the reader's answer included. */ + daily: boolean; + /** What the source's wording implies, so an override can fall back to it. */ + detectedDaily: boolean; /** Days already ticked off, for events that repeat. */ dailyDays: string[]; + onDaily: (id: string, daily: boolean | undefined) => void; onToggleDay: (id: string, day: string) => void; onToggle: (id: string) => void; onIgnore: (id: string) => void; @@ -132,16 +140,45 @@ export function EventDetail({ {/* A repeating event gets the checklist instead of nothing but a "mark done" — its work is spread over every day of the run, and one - tick cannot express that. */} - {isDaily(event) && ( - onToggleDay(event.id, day)} - /> + tick cannot express that. + + Detection reads the source's wording and is wrong in both + directions, so the reader can say. The control sits where the + checklist goes, which is the one place the answer visibly matters. */} + {daily ? ( + <> + onToggleDay(event.id, day)} + /> + + + ) : ( + )} (load); @@ -58,11 +78,7 @@ export function useProgress() { }; // An entry with nothing recorded is not worth keeping; drop it so the // store stays a set of things the reader actually said something about. - if ( - merged.status === undefined && - merged.effort === undefined && - (merged.note ?? "") === "" - ) { + if (isEmpty(merged)) { const { [id]: _removed, ...rest } = prev; return rest; } @@ -88,11 +104,7 @@ export function useProgress() { status: next, at: new Date().toISOString(), }; - if ( - merged.status === undefined && - merged.effort === undefined && - (merged.note ?? "") === "" - ) { + if (isEmpty(merged)) { const { [id]: _removed, ...rest } = prev; return rest; } @@ -102,6 +114,11 @@ export function useProgress() { [], ); + const setDaily = useCallback( + (id: string, daily: boolean | undefined) => patch(id, { daily }), + [patch], + ); + const setEffort = useCallback( (id: string, effort: Effort | undefined) => patch(id, { effort }), [patch], @@ -125,5 +142,14 @@ export function useProgress() { }); }, []); - return { progress, patch, setStatus, cycleStatus, setEffort, setNote, merge }; + return { + progress, + patch, + setStatus, + cycleStatus, + setDaily, + setEffort, + setNote, + merge, + }; } diff --git a/src/shared/daily.ts b/src/shared/daily.ts index e57deab..98d9cc1 100644 --- a/src/shared/daily.ts +++ b/src/shared/daily.ts @@ -76,6 +76,36 @@ export function dailiesId(game: GameId): string { return `dailies:${game}`; } +/** + * Whether to treat an event as repeating, given what the reader said about it. + * + * Detection reads the source's wording, which is right most of the time and + * wrong in both directions: a grind event whose page never prints the word + * "daily" still wants a checklist, and a banner whose blurb mentions "daily + * login rewards" does not. The reader's own answer is the better evidence, so + * it wins outright — `undefined` means they have not said, so detection stands. + */ +export function resolveDaily( + event: DailyCandidate, + override: boolean | undefined, +): boolean { + return override ?? isDaily(event); +} + +/** + * What to store when the reader asks for `desired`. + * + * Agreeing with detection stores nothing: an override that merely repeats what + * the parser already worked out would freeze today's guess into the reader's + * data, so a later parser improvement could never reach that event. + */ +export function dailyOverride( + desired: boolean, + detected: boolean, +): boolean | undefined { + return desired === detected ? undefined : desired; +} + /** Offset from UTC midnight to this region's reset instant. */ function shift(region: Region): number { return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR; diff --git a/test/daily.test.ts b/test/daily.test.ts index 98e7a4f..cc01fd1 100644 --- a/test/daily.test.ts +++ b/test/daily.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { dailiesId, dailyDays, + dailyOverride, dailySummary, dayKey, isDaily, msUntilReset, nextResetMs, + resolveDaily, streakOf, } from "../src/shared/daily.ts"; import { DAY, HOUR } from "../src/shared/time.ts"; @@ -56,6 +58,50 @@ describe("isDaily", () => { }); }); +describe("resolveDaily", () => { + const detected = { type: "login" as const, title: "Daily Check-In", summary: null }; + const plain = { type: "story" as const, title: "Chapter Three", summary: null }; + + test("detection stands until the reader says otherwise", () => { + expect(resolveDaily(detected, undefined)).toBe(true); + expect(resolveDaily(plain, undefined)).toBe(false); + }); + + test("the reader can mark an event the source never called daily", () => { + // The case this exists for: a grind whose page never prints the word, but + // which the player knows resets every day. + expect(resolveDaily(plain, true)).toBe(true); + }); + + test("the reader can unmark a false positive", () => { + // A banner whose blurb happens to mention "daily login rewards" should not + // be stuck with a twenty-box checklist the reader cannot dismiss. + expect(resolveDaily(detected, false)).toBe(false); + }); +}); + +describe("dailyOverride", () => { + test("agreeing with detection records nothing", () => { + // Storing "yes" on an event already detected as daily would freeze today's + // guess into the reader's data, so a later parser fix could never reach it. + expect(dailyOverride(true, true)).toBeUndefined(); + expect(dailyOverride(false, false)).toBeUndefined(); + }); + + test("disagreeing with detection records the disagreement", () => { + expect(dailyOverride(true, false)).toBe(true); + expect(dailyOverride(false, true)).toBe(false); + }); + + test("round-trips: overriding then changing back leaves no trace", () => { + const detectedDaily = false; + const on = dailyOverride(true, detectedDaily); + expect(resolveDaily({ type: "story", title: "x", summary: null }, on)).toBe(true); + const off = dailyOverride(false, detectedDaily); + expect(off).toBeUndefined(); + }); +}); + describe("dayKey", () => { test("the game day rolls at 04:00 server time, not midnight", () => { // Asia is UTC+8, so its 04:00 reset is 20:00 UTC the day before. Someone