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:
Lucas Winther
2026-08-15 22:14:37 +02:00
co-authored by Claude Opus 5
parent 6177633abc
commit 7f384eb9db
9 changed files with 293 additions and 69 deletions
+13 -2
View File
@@ -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. */}
<Dailies
games={games.filter((g) => !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}
+102 -45
View File
@@ -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 (
<section className="border-b border-hairline px-4 py-4">
<div className="flex items-baseline justify-between gap-3">
<h2 className="eyebrow">
Today's dailies · {done.length}/{games.length}
Today's dailies · {complete}/{total}
</h2>
<p className="tnum text-[0.6875rem] text-faint">
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 (
<li key={id}>
<button
type="button"
onClick={() => onToggleDay(key, today)}
aria-pressed={isDone}
aria-label={`${game.name} dailies — ${game.dailyTasks}${
isDone ? ", done today" : ", not done today"
}`}
<li key={key}>
<TickChip
label={game.short}
hue={game.hue}
title={game.dailyTasks}
className="flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: isDone ? game.hue : "var(--color-hairline)",
color: isDone ? game.hue : "var(--color-faint)",
background: isDone
? `color-mix(in srgb, ${game.hue} 14%, transparent)`
: "transparent",
}}
>
<svg viewBox="0 0 16 16" aria-hidden className="size-3">
<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>
{game.short}
{streak > 1 && (
<span className="tnum text-[0.625rem] opacity-70">
{streak}d
</span>
)}
</button>
ariaLabel={`${game.name} dailies — ${game.dailyTasks}`}
days={daysFor(key)}
today={today}
onToggle={() => onToggleDay(key, today)}
/>
</li>
);
})}
{events.map((event) => {
const game = gameMeta(event.game);
return (
<li key={event.id}>
<TickChip
label={event.title}
hue={game.hue}
title={`${game.name} — ${event.title}`}
ariaLabel={`${event.title} (${game.name})`}
days={daysFor(event.id)}
today={today}
onToggle={() => onToggleDay(event.id, today)}
/>
</li>
);
})}
</ul>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
{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.`}
</p>
</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 {
return n === 1 ? "One game" : `${n} games`;
return n === 1 ? "One thing" : `${n} things`;
}
+48 -11
View File
@@ -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) && (
<DailyChecklist
startsMs={clock.startsMs}
endsMs={clock.endsMs}
region={region}
now={now}
logged={dailyDays}
onToggleDay={(day) => 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 ? (
<>
<DailyChecklist
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
+37 -11
View File
@@ -9,6 +9,12 @@ export type Status = "doing" | "done";
export interface Progress {
status?: Status | 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. */
note?: string | undefined;
at: string;
@@ -42,6 +48,20 @@ function load(): ProgressMap {
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() {
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
// 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,
};
}