"What runs out first" is the question this app exists for, but it is not
the only one a reader arrives with. The other is "what was I in the
middle of?", and until now the list could not answer it.
Two modes, toggled from the list's own header rather than from settings,
because ordering is something you reach for while looking at a list.
Ticking a day off a daily counts as doing it without the reader also
setting the status — the tick already said so.
Sorting only ever groups. Both modes fall back to endingSoonestFirst
inside a group and live still beats upcoming, so choosing an order can
never cost the reader the deadline order the product is for.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
The bar tested for an entry in the progress store, but an entry appears
there the moment a reader records an effort or a note. Recording "this
looks like a grind" greyed the event out as though it were already done.
Ask whether it is done instead.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The row already opened the event; the tick sat on top of it, so the two
smallest targets on the page did different things a few pixels apart.
"Done" was never the only thing a reader wants to say about an event
either — status, effort and notes all live in the detail sheet.
The tick becomes a chevron: decorative, aria-hidden, and leaning towards
where the row is about to take you on hover. The full-bleed button is
the only control, so there is no second stop for anyone tabbing or using
a screen reader. Undo on a revealed ignored row stays a real button —
it is a real action with nowhere better to sit.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The feed was generated from checked-in fixtures and only moved when
somebody captured a page by hand. This fetches.
bun run refresh caches each page raw under snapshots/ and rebuilds the
feed from what it cached; build-feed prefers a snapshot and falls back to
the fixture, so a clean checkout and the container build stay offline and
reproducible. The workflow runs it twice a day and commits only when a
page's bytes actually changed — a 304, an identical body or a rejected
parse all leave the tree clean — then dispatches ci.yml, which already
knows how to test, build and deploy.
Scraping conduct is enforced in code rather than left to good
intentions: one request per source per cycle, a six-hour floor checked
per source, conditional requests, a User-Agent with a contact URL, and
robots.txt honoured — failing closed, because a permission we could not
read is not a permission we have. No retries; a retry is a second
request.
A body that yields zero events is rejected and the previous snapshot
kept, so a redesigned wiki shows up as a stale timestamp rather than an
emptied calendar. One source down is a warning; all of them down fails
the run, so a cycle that learned nothing is never committed.
Tested entirely offline against an injected fetch and clock — no request
has ever been made to a live wiki from this code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Scraped text becomes React content, JSON on disk and eventually SQLite
rows, so it gets cleaned at one seam: the parse wrapper in toAdapter().
Every source passes through it, a source added tomorrow is covered
without its author doing anything, and no parser can opt out — parsers
stay pure readers of one site's markup.
Removes script/style/comment content and residual tags, decodes entities
to a fixed point so an encoded tag cannot resurrect in a later decoder,
NFKC-normalises, strips control, zero-width and bidi-override characters
(an RTL override visually spoofs a title), bounds each field to the cap
the schema already declares, and requires sourceUrl to be absolute
http(s).
Three things it will not do: touch a date, drop an event it could clean
instead, or repair in silence — every repair and drop is logged by
default. Event IDs are localStorage keys, so an ID is recomputed only
when a sanitised title actually changed and the ID was minted the
standard way; all seven fixtures pass through unrepaired and
byte-identical, which is the regression guard.
Also stops decodeEntities throwing RangeError on an out-of-range
numeric reference, which would have taken a whole source's events down.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The done checkbox now cycles untouched → doing → done, in the order people
actually move through. Effort, status and a note are set from the detail
sheet.
Rows carry chips for what the reader said, and a "tight" or "running out of
time" flag when the remaining time no longer covers the effort they recorded.
The detail sheet shows the arithmetic behind that rather than just asserting
it, and calls it a rough guide.
Imports from before this change are mapped forward rather than dropped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Membership in a set can only say "done", so "started" was inexpressible.
Progress carries a status instead, plus an optional effort estimate and a
note.
The old completions key is read once to seed status: "done", and is never
written to or deleted. Someone who last opened the app six months ago still
has their marks under it, these live only in the browser, and nothing else
holds a copy to restore from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Four buckets named the way a player would describe them — quick, short, long,
grind — each carrying rough working hours.
The hours exist so effort can answer the question the calendar cannot: can I
still finish this? The same two days is comfortable for a quick event and
hopeless for a grind. The runway heuristic assumes about an hour of play a
day, which is deliberately modest — a warning that only fires once something
is already impossible is useless.
An event with no recorded effort returns "fine" and never warns. Inferring an
estimate in order to warn about it would be fabricating the reader's input.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
It still thanked only Game8 and hardcoded the studio list, so wiki.gg went
uncredited and unmentioned in the disclaimer after being added as a source.
Sources and studios are now read from the feed and the game registry, so
adding either credits the right people automatically. A hardcoded credit goes
stale the moment someone adds a source, and out of date is the one thing a
credit must not be.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ignoring made an event vanish with no way back except knowing the reveal
toggle existed — and the row you would click to undo is the row that just
disappeared.
Now: an undo appears the moment you ignore something, and revealed ignored
rows are labelled and carry a restore control where the done checkbox
normally sits, which is the most direct place for it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Clamping the window to now made the past unreachable — you could not see when
a running event began. Rendering from the earliest start instead buried today
off-screen, which is what the clamp was fixing.
The window now covers both and the container opens scrolled to today, so the
default view is unchanged and history is a scroll away. It reaches a week
past the oldest running event, so the range stays bounded by what is actually
live. Nothing is clipped at the left edge any more.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Mirrors the GitLab pipeline — typecheck, tests, feed sanity — and adds a
Pages deploy.
Pages serves from /<repo>/, and the app used absolute asset paths, so that
job would have shipped a site that 404s on everything. Assets now resolve
against a <base href> the build substitutes, the feed URL resolves against
document.baseURI so deep links work too, and the service worker derives its
paths from its own registration scope. Root-hosted builds are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ignoring is not completing. "Done" keeps an event visible and counted;
"not interested" removes it from both views, which is the entire point.
Completions and ignores are the same shape and want the same guarantees, so
they now share one mark-set implementation — union merge on import, never a
removal, since nothing else holds a copy. Both ride in the export file.
Ignored events stay recoverable: the count and a reveal toggle appear in
settings once there is something to reveal.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
First non-Game8 parser, and the first source that states region-scoped ends:
wiki.gg emits ISO timestamps with one timer per server region, so Endfield
events now carry exact times and separate Asia / Americas-Europe ends. That
gap is up to 13 hours, which is what regionEnds was built for.
Two sources for one game also exercised merge for real. It caught both
overlaps — including "Bedazzling Dawnstar" against Game8's "Bedazzling
Dawnstar Sign-In" — and flagged that the two disagree on the end date by 70
hours rather than averaging them into a date neither states.
Matching an appended qualifier needed a prefix test, not a subset test: a
subset would also fuse "Gold Clash" with "Gold Rush Clash Royale".
Also fixes build-feed picking fixtures by game rather than by source, which
handed the wiki.gg page to the Game8 parser once Endfield had two.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The meter encoded two things at once and neither was guessable from looking
at it — obvious to whoever built it, opaque to everyone else.
Every row now carries a plain caption under the bar ("Aug 10 – Aug 17 · 3 of
7 days left", "started Apr 22 · no end date announced"), which explains the
bar and doubles as the exact dates people wanted anyway. A legend above the
list explains the colour ramp, which no per-row text can — shown once,
because a legend repeated fifty times is noise.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Endfield was previously written off as undatable. That was wrong: a first
pass only inspected its Duration rows (all "Permanently Available") and its
year-less version grid, and missed an "Event | Schedule & Summary" table
whose cells read "Period: 08/09/26 - 08/30/26 During the event...". Two real
events, with a year.
Adds MM/DD/YY range parsing for that shape. Month-first ordering is not
assumed lightly — Endfield's own version grid reads 01/22, 04/17, 07/16 for
versions 1.0, 1.2 and 1.4, chronological only if the month comes first.
Also stops presenting unlock conditions as descriptions. Several templates
put "Reach Union Level 8" where a blurb would go; filling the summary slot
with text that never says what the event is is worse than leaving it empty.
Wuthering Waves correctly drops to zero summaries as a result.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The window ran from the earliest event start, which is months back, so today
sat far off-screen and every visit began with a scroll to find yourself.
It now opens at now. Events already running are clipped at the left edge and
faded there, the same way an unannounced end frays on the right — both say
the truth extends past what is drawn. The left edge also carries a month
label, since the window opens mid-month and the first real boundary can be
weeks away.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reader's question is answered entirely by data already on the device and
countdowns run off the local clock, so losing signal should not lose the app.
The shell is cache-first; the feed is network-first falling back to the last
copy seen, because stale events beat a blank screen. Webfonts are cached too
— without them an offline load silently drops to system faces and the whole
thing changes character.
Offline is surfaced in the header and above the footer rather than hidden:
stale data must never be presented as current.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Game8's editors compile the calendars this depends on, and the studios make
the games — both belong on the same screen as the data rather than one
navigation step away.
States plainly that this is an unofficial fan-made tool with no connection
to Game8, HoYoverse, Kuro Games, Hypergryph or Hotta Studio, and that the
source page is the authority when the two disagree.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three more Game8 templates. ZZZ spans each event name across two rows with a
rowspan, so a flat cell reader sees [title, "Start", date] then ["End",
date]; losing that pairing would silently halve the calendar.
Section matching also had to widen — these pages head their sections
"Featured Events", "List of Events and Schedule" and "Ongoing Events".
Verified independently of the parser: HSR's year-less summary table lists
six ongoing events and all six are captured via their detail rows, so
nothing is silently dropped.
28 events added across the three games.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Honkai: Star Rail and Wuthering Waves state a year on each side of a range
("Aug. 14, 2026 - Aug. 24, 2026"), which the year-on-the-end parser cannot
read.
Both also publish genuinely open-ended events — "Jul. 24, 2026 - End of
4.6", "July 10, 2026 - Permanent". Those keep their real start and report no
end, rather than being dropped or given a guessed one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A calendar full of games you don't play is worse than an empty one — it
buries the thing you came for.
Nothing is preselected; the button stays disabled until a game is picked,
which is clearer than guessing and hoping the reader notices. The choice is
stored as hiddenGames, the inverse, so a game added later shows up by
default rather than staying invisible.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The whole row is now one click target, which gives a generous tap area and
a single unambiguous hover region.
Hover transitions are fast and eased out (110-160ms) so a row responds on
the way in and settles on the way out — a slow symmetric fade is what makes
an interface feel laggy even at full framerate. The meter is the element
that visibly wakes up, since it is the signature. Press feedback lands on
touch as well as mouse, and every hover rule sits behind `hover: hover` so a
tap never leaves a row stuck looking hovered.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Column tables already carried a description column, but label/value detail
tables did not — Genshin events had no blurb at all. The page follows each
detail table with a sentence of prose, so take that, skipping the
call-to-action paragraphs, which are navigation rather than description.
Summary coverage goes from 13/22 events to 22/22.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Opens on the single event closest to expiring, at a size nothing else
competes with — the reader arrives with one question. Below it, live events
ordered by what ends soonest, then a quiet timeline view with one lane per
game.
Countdowns use tabular figures so ticking never reflows the row.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A discrete tick-strip rather than a smooth progress bar, borrowed from the
stamina meters these games all use — ticks read as a finite resource visibly
draining, which is the right metaphor for a limited-time event.
An event with no announced end gets a hatched strip, never a full meter.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Completions and prefs live in localStorage and never reach the server —
there is no account and nothing to log into. Reads never throw, so a corrupt
value costs a preference rather than the whole screen.
Import merges and never removes: a completion present on either side stays
completed, because nothing else holds a copy to restore from.
The feed client refuses a schemaVersion it does not know rather than
guessing at unfamiliar fields.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Tailwind v4 @theme carries the two colour axes as tokens: a deep blue-black
ground, per-game hues for identity, and a heat ramp for urgency that is
monotonic in salience (dim, blue, amber, red).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Time is this product's subject, so the vocabulary lives in one module:
remaining, progress through a window, and urgency. Urgency is derived from
absolute time left, deliberately independent of proportion — a 90-day event
with three hours left is as urgent as a 3-day one.
An unannounced end is never urgent and has no progress: "we don't know" and
"loads of time" are different facts, and conflating them is what this
product exists to avoid.
Games carry a hue for identity only. Urgency is a separate axis, so one
glance answers both "whose event is this?" and "how long have I got?".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Collapses the same event seen by two sources, matching on ID or on title
similarity plus start-date proximity. Proximity is the real guard against
false positives, since a rerun reuses its name months later.
Agreement from an independent source raises confidence; the same row seen
twice in one document does not. Disagreement on an end date is recorded as
a conflict for the review gate rather than averaged — splitting the
difference would publish a date neither source asserts.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Parsers are keyed by site template, not game: one Game8 parser serves
every Game8 page, and adapters bind a URL and game to a parser. Adding a
source for a known site is one SOURCES entry.
Game8 uses at least three templates. This handles label/value detail
tables (Genshin, 9 events) and column tables (NTE, 13 events). Its
image-grid schedule has no year and no end date, so Arknights: Endfield
is not supportable from that source and gets no adapter.
canParse is a structural tripwire: a site redesign fails the run loudly
instead of publishing an empty calendar that reads as "no events".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every date function returns null rather than inferring a missing year,
month or end date. A source that does not state an end yields
endsAt: null, never a plausible guess — a confidently wrong end date is
the failure this product exists to prevent.
Handles the three formats seen on Game8: "August 12, 2026",
"August 12 - September 21, 2026" (year applies to both ends, rolling the
start back when the range crosses New Year), and a slash datetime range.
Abbreviated months parse too, so a source switching to "Apr. 29" does not
silently drop events.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Schema-level invariants enforce that endsAt is null exactly when
endPrecision is "unknown", and that regionEnds is populated exactly when
regionScoped is set, so an event object cannot be constructed in a state
the UI would misrender.
Event IDs double as localStorage keys on the client, so slugify() and
eventId() carry a warning: changing them orphans every user's completion
marks with no server-side recovery.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>