feat: let the reader mark an event as repeating daily
Dailiness was read off the source's wording alone, which is wrong in both directions: a grind that resets every day but whose page never prints "daily" got no checklist, and a banner whose blurb mentions "daily login rewards" got one nobody could dismiss. The reader's answer now wins. The control sits exactly where the checklist goes — the one place the answer visibly matters — so marking an event and ticking today off are the same gesture in the same place. An override is stored only when it disagrees with detection. Recording agreement would freeze today's guess into the reader's own data, so a later parser improvement could never reach that event. Marked events also join today's dailies at the top of the page, beside the per-game chores: at 23:50 a login campaign and a commission run are the same job, and ticking one should not mean opening a sheet to find its checklist. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6177633abc
commit
7f384eb9db
@@ -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
|
- **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
|
claims. A source quietly moving a date must not erase a fortnight's streak that exists nowhere
|
||||||
else.
|
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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Sorting
|
||||||
|
|
||||||
Two orders, and the toggle sits with the list rather than in settings:
|
Two orders, and the toggle sits with the list rather than in settings:
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
|||||||
| `status` | `"doing"` \| `"done"` \| absent | Where they are with it |
|
| `status` | `"doing"` \| `"done"` \| absent | Where they are with it |
|
||||||
| `effort` | `"quick"` \| `"short"` \| `"long"` \| `"grind"` \| absent | How much work they reckon it is |
|
| `effort` | `"quick"` \| `"short"` \| `"long"` \| `"grind"` \| absent | How much work they reckon it is |
|
||||||
| `note` | free text | Anything worth remembering |
|
| `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
|
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.
|
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.
|
"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.
|
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`
|
### Migration from `completions`
|
||||||
|
|
||||||
`completions` used membership to mean "done", which cannot express "started". `progress` replaces it
|
`completions` used membership to mean "done", which cannot express "started". `progress` replaces it
|
||||||
|
|||||||
+13
-2
@@ -17,7 +17,7 @@ import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
|
|||||||
import { usePrefs } from "./state/usePrefs.ts";
|
import { usePrefs } from "./state/usePrefs.ts";
|
||||||
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
||||||
import { clockFor, DAY, formatRemaining } from "../shared/time.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";
|
import type { GameId } from "../shared/schema.ts";
|
||||||
|
|
||||||
type View = "soon" | "calendar";
|
type View = "soon" | "calendar";
|
||||||
@@ -85,9 +85,16 @@ export function App() {
|
|||||||
return daily.daysFor(id).length > 0 ? "doing" : "idle";
|
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. */
|
/** Today's state for a repeating event, or undefined if it does not repeat. */
|
||||||
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
|
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
|
||||||
if (!isDaily(row.event)) return undefined;
|
if (!repeatsDaily(row)) return undefined;
|
||||||
const summary = dailySummary({
|
const summary = dailySummary({
|
||||||
startsMs: row.clock.startsMs,
|
startsMs: row.clock.startsMs,
|
||||||
endsMs: row.clock.endsMs,
|
endsMs: row.clock.endsMs,
|
||||||
@@ -255,6 +262,7 @@ export function App() {
|
|||||||
that expires tonight rather than next patch. */}
|
that expires tonight rather than next patch. */}
|
||||||
<Dailies
|
<Dailies
|
||||||
games={games.filter((g) => !prefs.hiddenGames.includes(g))}
|
games={games.filter((g) => !prefs.hiddenGames.includes(g))}
|
||||||
|
events={live.filter(repeatsDaily).map((r) => r.event)}
|
||||||
region={prefs.region}
|
region={prefs.region}
|
||||||
now={now}
|
now={now}
|
||||||
daysFor={daily.daysFor}
|
daysFor={daily.daysFor}
|
||||||
@@ -386,7 +394,10 @@ export function App() {
|
|||||||
note={prog.progress[openRow.event.id]?.note ?? ""}
|
note={prog.progress[openRow.event.id]?.note ?? ""}
|
||||||
region={prefs.region}
|
region={prefs.region}
|
||||||
now={now}
|
now={now}
|
||||||
|
daily={repeatsDaily(openRow)}
|
||||||
|
detectedDaily={isDaily(openRow.event)}
|
||||||
dailyDays={daily.daysFor(openRow.event.id)}
|
dailyDays={daily.daysFor(openRow.event.id)}
|
||||||
|
onDaily={prog.setDaily}
|
||||||
onToggleDay={daily.toggleDay}
|
onToggleDay={daily.toggleDay}
|
||||||
onStatus={prog.setStatus}
|
onStatus={prog.setStatus}
|
||||||
onEffort={prog.setEffort}
|
onEffort={prog.setEffort}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
||||||
import { gameMeta } from "../../shared/games.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";
|
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
|
* 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.
|
* 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
|
* Sits above the event list because it is the one part of the page that is
|
||||||
* answerable in ten seconds and expires tonight.
|
* answerable in ten seconds and expires tonight.
|
||||||
*/
|
*/
|
||||||
export function Dailies({
|
export function Dailies({
|
||||||
games,
|
games,
|
||||||
|
events,
|
||||||
region,
|
region,
|
||||||
now,
|
now,
|
||||||
daysFor,
|
daysFor,
|
||||||
onToggleDay,
|
onToggleDay,
|
||||||
}: {
|
}: {
|
||||||
games: GameId[];
|
games: GameId[];
|
||||||
|
/** Live events that repeat daily — detected, or marked by the reader. */
|
||||||
|
events: GachaEvent[];
|
||||||
region: Region;
|
region: Region;
|
||||||
now: number;
|
now: number;
|
||||||
daysFor: (id: string) => string[];
|
daysFor: (id: string) => string[];
|
||||||
onToggleDay: (id: string, day: string) => void;
|
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 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 done = games.filter((g) => daysFor(dailiesId(g)).includes(today));
|
||||||
|
const total = games.length + events.length;
|
||||||
|
const complete = done.length + doneEvents.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-hairline px-4 py-4">
|
<section className="border-b border-hairline px-4 py-4">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<h2 className="eyebrow">
|
<h2 className="eyebrow">
|
||||||
Today's dailies · {done.length}/{games.length}
|
Today's dailies · {complete}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="tnum text-[0.6875rem] text-faint">
|
<p className="tnum text-[0.6875rem] text-faint">
|
||||||
resets in {formatRemaining(msUntilReset(now, region))}
|
resets in {formatRemaining(msUntilReset(now, region))}
|
||||||
@@ -48,61 +58,108 @@ export function Dailies({
|
|||||||
{games.map((id) => {
|
{games.map((id) => {
|
||||||
const game = gameMeta(id);
|
const game = gameMeta(id);
|
||||||
const key = dailiesId(id);
|
const key = dailiesId(id);
|
||||||
const days = daysFor(key);
|
|
||||||
const isDone = days.includes(today);
|
|
||||||
const streak = streakOf(days, today);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={id}>
|
<li key={key}>
|
||||||
<button
|
<TickChip
|
||||||
type="button"
|
label={game.short}
|
||||||
onClick={() => onToggleDay(key, today)}
|
hue={game.hue}
|
||||||
aria-pressed={isDone}
|
|
||||||
aria-label={`${game.name} dailies — ${game.dailyTasks}${
|
|
||||||
isDone ? ", done today" : ", not done today"
|
|
||||||
}`}
|
|
||||||
title={game.dailyTasks}
|
title={game.dailyTasks}
|
||||||
className="flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
ariaLabel={`${game.name} dailies — ${game.dailyTasks}`}
|
||||||
style={{
|
days={daysFor(key)}
|
||||||
borderColor: isDone ? game.hue : "var(--color-hairline)",
|
today={today}
|
||||||
color: isDone ? game.hue : "var(--color-faint)",
|
onToggle={() => onToggleDay(key, today)}
|
||||||
background: isDone
|
/>
|
||||||
? `color-mix(in srgb, ${game.hue} 14%, transparent)`
|
</li>
|
||||||
: "transparent",
|
);
|
||||||
}}
|
})}
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden className="size-3">
|
{events.map((event) => {
|
||||||
<path
|
const game = gameMeta(event.game);
|
||||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
return (
|
||||||
fill="none"
|
<li key={event.id}>
|
||||||
stroke="currentColor"
|
<TickChip
|
||||||
strokeWidth="2.2"
|
label={event.title}
|
||||||
strokeLinecap="round"
|
hue={game.hue}
|
||||||
strokeLinejoin="round"
|
title={`${game.name} — ${event.title}`}
|
||||||
opacity={isDone ? 1 : 0.3}
|
ariaLabel={`${event.title} (${game.name})`}
|
||||||
/>
|
days={daysFor(event.id)}
|
||||||
</svg>
|
today={today}
|
||||||
{game.short}
|
onToggle={() => onToggleDay(event.id, today)}
|
||||||
{streak > 1 && (
|
/>
|
||||||
<span className="tnum text-[0.625rem] opacity-70">
|
|
||||||
{streak}d
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||||
{done.length === games.length
|
{complete === total
|
||||||
? "All done. Nothing else expires tonight."
|
? "All done. Nothing else expires tonight."
|
||||||
: `${waiting(games.length - done.length)} still waiting on you today.`}
|
: `${waiting(total - complete)} still waiting on you today.`}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={isDone}
|
||||||
|
aria-label={`${ariaLabel}${isDone ? ", done today" : ", not done today"}`}
|
||||||
|
title={title}
|
||||||
|
className="flex max-w-[15rem] items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: isDone ? hue : "var(--color-hairline)",
|
||||||
|
color: isDone ? hue : "var(--color-faint)",
|
||||||
|
background: isDone ? `color-mix(in srgb, ${hue} 14%, transparent)` : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
|
||||||
|
<path
|
||||||
|
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity={isDone ? 1 : 0.3}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{streak > 1 && (
|
||||||
|
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{streak}d</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function waiting(n: number): string {
|
function waiting(n: number): string {
|
||||||
return n === 1 ? "One game" : `${n} games`;
|
return n === 1 ? "One thing" : `${n} things`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
|
|||||||
import type { Status } from "../state/useProgress.ts";
|
import type { Status } from "../state/useProgress.ts";
|
||||||
import { ProgressControls } from "./ProgressControls.tsx";
|
import { ProgressControls } from "./ProgressControls.tsx";
|
||||||
import { DailyChecklist } from "./DailyChecklist.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 type { Region } from "../../shared/schema.ts";
|
||||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||||
|
|
||||||
@@ -19,7 +19,10 @@ export function EventDetail({
|
|||||||
note,
|
note,
|
||||||
region,
|
region,
|
||||||
now,
|
now,
|
||||||
|
daily,
|
||||||
|
detectedDaily,
|
||||||
dailyDays,
|
dailyDays,
|
||||||
|
onDaily,
|
||||||
onToggleDay,
|
onToggleDay,
|
||||||
onToggle,
|
onToggle,
|
||||||
onIgnore,
|
onIgnore,
|
||||||
@@ -36,8 +39,13 @@ export function EventDetail({
|
|||||||
note: string;
|
note: string;
|
||||||
region: Region;
|
region: Region;
|
||||||
now: number;
|
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. */
|
/** Days already ticked off, for events that repeat. */
|
||||||
dailyDays: string[];
|
dailyDays: string[];
|
||||||
|
onDaily: (id: string, daily: boolean | undefined) => void;
|
||||||
onToggleDay: (id: string, day: string) => void;
|
onToggleDay: (id: string, day: string) => void;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onIgnore: (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
|
{/* 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
|
"mark done" — its work is spread over every day of the run, and one
|
||||||
tick cannot express that. */}
|
tick cannot express that.
|
||||||
{isDaily(event) && (
|
|
||||||
<DailyChecklist
|
Detection reads the source's wording and is wrong in both
|
||||||
startsMs={clock.startsMs}
|
directions, so the reader can say. The control sits where the
|
||||||
endsMs={clock.endsMs}
|
checklist goes, which is the one place the answer visibly matters. */}
|
||||||
region={region}
|
{daily ? (
|
||||||
now={now}
|
<>
|
||||||
logged={dailyDays}
|
<DailyChecklist
|
||||||
onToggleDay={(day) => onToggleDay(event.id, day)}
|
startsMs={clock.startsMs}
|
||||||
/>
|
endsMs={clock.endsMs}
|
||||||
|
region={region}
|
||||||
|
now={now}
|
||||||
|
logged={dailyDays}
|
||||||
|
onToggleDay={(day) => onToggleDay(event.id, day)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDaily(event.id, dailyOverride(false, detectedDaily))}
|
||||||
|
className="mt-2 text-xs text-faint transition-colors hover:text-muted"
|
||||||
|
>
|
||||||
|
This isn't a daily event
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDaily(event.id, dailyOverride(true, detectedDaily))}
|
||||||
|
className="mt-5 flex w-full items-center gap-2.5 rounded-xl border border-dashed border-hairline px-4 py-3 text-left transition-colors hover:border-faint"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-base leading-none text-faint">+</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium">
|
||||||
|
It repeats daily
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs leading-relaxed text-faint">
|
||||||
|
Track it day by day, and tick today off as you go.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProgressControls
|
<ProgressControls
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export type Status = "doing" | "done";
|
|||||||
export interface Progress {
|
export interface Progress {
|
||||||
status?: Status | undefined;
|
status?: Status | undefined;
|
||||||
effort?: Effort | undefined;
|
effort?: Effort | undefined;
|
||||||
|
/**
|
||||||
|
* The reader's own answer to "does this repeat daily?", overriding what the
|
||||||
|
* source's wording implies. Absent means they have not said and detection
|
||||||
|
* stands — see resolveDaily in shared/daily.ts.
|
||||||
|
*/
|
||||||
|
daily?: boolean | undefined;
|
||||||
/** Anything the reader wants to remember about it. */
|
/** Anything the reader wants to remember about it. */
|
||||||
note?: string | undefined;
|
note?: string | undefined;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -42,6 +48,20 @@ function load(): ProgressMap {
|
|||||||
return seeded;
|
return seeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing recorded, so not worth a row. Every field the reader can set has to
|
||||||
|
* be listed here: one left out means an event they marked *only* with that
|
||||||
|
* field gets silently dropped on the next write.
|
||||||
|
*/
|
||||||
|
function isEmpty(p: Progress): boolean {
|
||||||
|
return (
|
||||||
|
p.status === undefined &&
|
||||||
|
p.effort === undefined &&
|
||||||
|
p.daily === undefined &&
|
||||||
|
(p.note ?? "") === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useProgress() {
|
export function useProgress() {
|
||||||
const [progress, setProgress] = useState<ProgressMap>(load);
|
const [progress, setProgress] = useState<ProgressMap>(load);
|
||||||
|
|
||||||
@@ -58,11 +78,7 @@ export function useProgress() {
|
|||||||
};
|
};
|
||||||
// An entry with nothing recorded is not worth keeping; drop it so the
|
// 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.
|
// store stays a set of things the reader actually said something about.
|
||||||
if (
|
if (isEmpty(merged)) {
|
||||||
merged.status === undefined &&
|
|
||||||
merged.effort === undefined &&
|
|
||||||
(merged.note ?? "") === ""
|
|
||||||
) {
|
|
||||||
const { [id]: _removed, ...rest } = prev;
|
const { [id]: _removed, ...rest } = prev;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
@@ -88,11 +104,7 @@ export function useProgress() {
|
|||||||
status: next,
|
status: next,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
if (
|
if (isEmpty(merged)) {
|
||||||
merged.status === undefined &&
|
|
||||||
merged.effort === undefined &&
|
|
||||||
(merged.note ?? "") === ""
|
|
||||||
) {
|
|
||||||
const { [id]: _removed, ...rest } = prev;
|
const { [id]: _removed, ...rest } = prev;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
@@ -102,6 +114,11 @@ export function useProgress() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setDaily = useCallback(
|
||||||
|
(id: string, daily: boolean | undefined) => patch(id, { daily }),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
|
||||||
const setEffort = useCallback(
|
const setEffort = useCallback(
|
||||||
(id: string, effort: Effort | undefined) => patch(id, { effort }),
|
(id: string, effort: Effort | undefined) => patch(id, { effort }),
|
||||||
[patch],
|
[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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,36 @@ export function dailiesId(game: GameId): string {
|
|||||||
return `dailies:${game}`;
|
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. */
|
/** Offset from UTC midnight to this region's reset instant. */
|
||||||
function shift(region: Region): number {
|
function shift(region: Region): number {
|
||||||
return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR;
|
return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR;
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
dailiesId,
|
dailiesId,
|
||||||
dailyDays,
|
dailyDays,
|
||||||
|
dailyOverride,
|
||||||
dailySummary,
|
dailySummary,
|
||||||
dayKey,
|
dayKey,
|
||||||
isDaily,
|
isDaily,
|
||||||
msUntilReset,
|
msUntilReset,
|
||||||
nextResetMs,
|
nextResetMs,
|
||||||
|
resolveDaily,
|
||||||
streakOf,
|
streakOf,
|
||||||
} from "../src/shared/daily.ts";
|
} from "../src/shared/daily.ts";
|
||||||
import { DAY, HOUR } from "../src/shared/time.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", () => {
|
describe("dayKey", () => {
|
||||||
test("the game day rolls at 04:00 server time, not midnight", () => {
|
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
|
// Asia is UTC+8, so its 04:00 reset is 20:00 UTC the day before. Someone
|
||||||
|
|||||||
Reference in New Issue
Block a user