feat: track events that repeat daily, and each game's dailies
A login campaign is not one job with a deadline. It is twenty small jobs on twenty separate deadlines, and a day you miss is gone whatever you do afterwards — which a single "done" tick cannot express. Repeating events now get a checklist: today's tick, a strip of every day in the run showing what you got and what you missed, the streak, and how many chances are left. Past days stay editable, because people tick up later and a checklist you cannot correct stops being trusted after the first mistake. Alongside it sits today's dailies — commissions, sanity, daily training — one tick per game, keyed `dailies:<game>`, since no source publishes those and they are the only thing on the page that expires tonight rather than next patch. Days roll at 04:00 server time per region, not midnight: finishing at 02:00 is still yesterday, and a naive UTC date would tick the wrong box for four hours every night. Dailiness is read off what the source published — a login event type, or "daily"/"check-in"/"7-day" wording — never from a game's habits or an event's length. That adds no schema field, so the feed contract and every event ID are untouched. An unannounced end yields a tick count, not a checklist of invented length, and 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. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad3647bee2
commit
ccc5d369bd
@@ -18,6 +18,11 @@ export const KEYS = {
|
||||
*/
|
||||
completions: `${NS}:completions`,
|
||||
progress: `${NS}:progress`,
|
||||
/**
|
||||
* Which game-days of a repeating event the reader has ticked off. Separate
|
||||
* from `progress` because it is a growing list per event, not one record.
|
||||
*/
|
||||
daily: `${NS}:daily`,
|
||||
ignored: `${NS}:ignored`,
|
||||
prefs: `${NS}:prefs`,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { KEYS, readJson, writeJson } from "./storage.ts";
|
||||
|
||||
export interface DailyLog {
|
||||
/** Game-day keys (`YYYY-MM-DD`) the reader has ticked off, oldest first. */
|
||||
days: string[];
|
||||
/** When the log was last touched. Used to resolve import conflicts. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type DailyLogMap = Record<string, DailyLog>;
|
||||
|
||||
/**
|
||||
* Which days of a repeating event the reader has done.
|
||||
*
|
||||
* Kept apart from `progress` rather than nested inside it: progress is one
|
||||
* record per event and this is a growing list per event, and merging on import
|
||||
* means different things for the two (last-write-wins versus union). A daily
|
||||
* log is also the only store here that can lose *work* rather than a
|
||||
* preference — a fortnight's login streak exists nowhere else — so nothing in
|
||||
* this module ever removes a day the reader did not remove themselves.
|
||||
*/
|
||||
export function useDailyLog() {
|
||||
const [logs, setLogs] = useState<DailyLogMap>(() =>
|
||||
readJson<DailyLogMap>(KEYS.daily, {}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
writeJson(KEYS.daily, logs);
|
||||
}, [logs]);
|
||||
|
||||
const toggleDay = useCallback((id: string, day: string) => {
|
||||
setLogs((prev) => {
|
||||
const current = prev[id]?.days ?? [];
|
||||
const next = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort();
|
||||
if (next.length === 0) {
|
||||
const { [id]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [id]: { days: next, at: new Date().toISOString() } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const daysFor = useCallback(
|
||||
(id: string): string[] => logs[id]?.days ?? [],
|
||||
[logs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Union merge on import: every day either side recorded is a day the reader
|
||||
* actually played, so keeping both is the only answer that cannot lose one.
|
||||
*/
|
||||
const merge = useCallback((incoming: DailyLogMap) => {
|
||||
setLogs((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [id, log] of Object.entries(incoming)) {
|
||||
// An imported file is untrusted input; a malformed entry is skipped
|
||||
// rather than allowed to take the store down with it.
|
||||
if (!Array.isArray(log?.days)) continue;
|
||||
const days = log.days.filter((d): d is string => typeof d === "string");
|
||||
if (days.length === 0) continue;
|
||||
const union = [...new Set([...(next[id]?.days ?? []), ...days])].sort();
|
||||
const at = next[id]?.at;
|
||||
const incomingAt =
|
||||
typeof log.at === "string" ? log.at : new Date().toISOString();
|
||||
next[id] = {
|
||||
days: union,
|
||||
at: at === undefined || incomingAt > at ? incomingAt : at,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { logs, toggleDay, daysFor, merge };
|
||||
}
|
||||
Reference in New Issue
Block a user