feat: the redesigned Event Clock

The new reader app replaces the old UI: Checklist with a rail (focus
pills, Next to expire, today's dailies, pinned routines), Timeline in a
new and a classic look, Routines, Archive and a Settings area, in a
Structured (default) and a Relaxed interface. It follows the approved
Round 6 B and Round 7 mockups, corrected screen by screen with the owner.

Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data
is migrated on first load, a recovery copy is written first, and the v1
keys are never removed, so a rollback finds them unchanged. Redeem codes
for Genshin arrive as their own feed.

Also: live's urgency ramp, editable per theme; completion and
section-complete fireworks; a returning-user tour; the design mockups,
specs, plans, reviews and handoff record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 01:55:16 +02:00
1 parent ddd9673f86
commit 50c6e91b00
284 files changed
+45100 -1628

No files matched your search

+1
View File
@@ -0,0 +1 @@
58245
+1
View File
@@ -0,0 +1 @@
e13012f97798f2f41fa46ae58204a4bcd50a142b56e7cca42efec09e7633540f
@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A · Every game has its own setup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=Public+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root{
--ground:#12141c;--surface:#1b1e2a;--raised:#232739;--hairline:#2c3145;
--ink:#e9ebf3;--ink-strong:#fff;--muted:#888fa6;--faint:#5a6178;--glow:#1a1e2c;
--calm:#55607f;--near:#5b8def;--soon:#e8b33c;--critical:#ff4d6a;
--scrim:rgb(18 20 28 / .8);
--read:"Chakra Petch",ui-sans-serif,system-ui,sans-serif;
--lang:"Public Sans",ui-sans-serif,system-ui,sans-serif;
--bar:44px;
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--ground) radial-gradient(120% 70% at 50% -10%,var(--glow),var(--ground) 60%) no-repeat;color:var(--ink);font:400 14px/1.6 var(--lang);min-height:100vh;overflow-x:hidden}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
:focus-visible{outline:2px solid var(--near);outline-offset:2px;border-radius:4px}
.tnum{font-variant-numeric:tabular-nums}
/* ---------- prototype chrome (deliberately not the app) ---------- */
.mock{position:fixed;inset:0 0 auto 0;height:var(--bar);z-index:50;background:#d4d8e1;color:#20242f;border-bottom:1px dashed #7d8496;display:flex;align-items:center;gap:6px;padding:0 12px;font:500 12px/1 system-ui,sans-serif;overflow-x:auto;white-space:nowrap}
.mock b{font-weight:700;margin-right:8px}
.mock button{padding:6px 9px;border-radius:5px;border:1px solid transparent}
.mock button:hover{background:#c3c8d3}
.mock button[aria-current="true"]{background:#20242f;color:#eef0f5}
.mock .sep{width:1px;height:20px;background:#9aa1b2;margin:0 4px;flex:none}
.mock .why{border-color:#20242f}
.why-panel{position:fixed;top:var(--bar);right:0;bottom:0;width:min(440px,100%);z-index:49;background:#e9ebf0;color:#20242f;border-left:1px dashed #7d8496;padding:20px 22px 40px;overflow:auto;font:400 13.5px/1.6 system-ui,sans-serif;transform:translateX(100%);transition:transform .18s ease-out}
.why-panel.open{transform:none}
.why-panel h3{font:700 13px/1.3 system-ui;margin:18px 0 6px}
.why-panel h3:first-child{margin-top:0}
.why-panel code{font-size:12px;background:#d4d8e1;padding:1px 4px;border-radius:3px}
.why-panel pre{background:#20242f;color:#e9ebf3;font-size:11.5px;line-height:1.5;padding:10px 12px;border-radius:6px;overflow:auto}
.why-panel ul{padding-left:18px;margin:4px 0}
/* ---------- app shell ---------- */
.app{padding-top:var(--bar)}
.shell{max-width:1320px;margin:0 auto;border-inline:1px solid var(--hairline);min-height:calc(100vh - var(--bar))}
header.top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;border-bottom:1px solid var(--hairline)}
.wordmark{font:700 18px/1 var(--read);letter-spacing:.02em}
.wordmark span{color:var(--near)}
.sub{font-size:12px;color:var(--faint);margin-top:4px}
.tabs{display:flex;padding:2px;border:1px solid var(--hairline);border-radius:8px;gap:2px;position:relative}
.tabs button{font:500 12px/1 var(--lang);padding:8px 12px;border-radius:6px;color:var(--faint);display:flex;align-items:center;gap:6px;transition:color .12s}
.tabs button:hover{color:var(--muted)}
.tabs button[aria-selected="true"]{background:var(--raised);color:var(--ink)}
.tabs .divider{width:1px;background:var(--hairline);margin:4px 2px}
.tabs svg{width:14px;height:14px}
.tabs .hl{box-shadow:0 0 0 2px var(--near);animation:pulse 1.4s ease-in-out 3}
@keyframes pulse{50%{box-shadow:0 0 0 5px rgb(91 141 239 / .25)}}
.callout{position:absolute;right:0;top:calc(100% + 10px);width:300px;background:var(--surface);border:1px solid var(--near);border-radius:12px;padding:14px;z-index:5;font-size:13px;color:var(--muted)}
.callout b{color:var(--ink);font-weight:600}
.callout button{margin-top:10px}
.cols{display:grid;grid-template-columns:22rem 1fr}
.rail{border-right:1px solid var(--hairline);position:sticky;top:var(--bar);align-self:start;max-height:calc(100vh - var(--bar));overflow:auto}
.rail section{padding:20px 16px;border-bottom:1px solid var(--hairline)}
.legend{font:600 11px/1 var(--read);letter-spacing:.06em;color:var(--faint);display:flex;align-items:center;gap:8px;min-height:22px}
.legend .r{margin-left:auto;display:flex;align-items:center;gap:8px;letter-spacing:0;font-weight:500}
.gear{width:22px;height:22px;display:grid;place-items:center;border-radius:6px;color:var(--faint)}
.gear:hover{color:var(--ink);background:var(--raised)}
.gear svg{width:14px;height:14px}
/* headline */
.headline{position:relative;overflow:hidden}
.headline .wash{position:absolute;inset:-40% -20% auto -20%;height:120%;background:radial-gradient(closest-side,var(--heat),transparent);opacity:.16;filter:blur(20px);pointer-events:none}
.headline h2{font:600 26px/1.15 var(--read);letter-spacing:-.02em;margin:10px 0 4px;position:relative}
.headline .game{font-size:14px;position:relative}
.numeral{font:700 44px/1 var(--read);letter-spacing:-.025em;margin:18px 0 12px;display:flex;align-items:baseline;justify-content:space-between;position:relative}
.numeral small{font:400 12px/1.3 var(--lang);color:var(--faint);text-align:right;letter-spacing:0}
.then{list-style:none;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--hairline)}
.then li{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted);padding:3px 0}
.then .dot{width:6px;height:6px;border-radius:50%;flex:none}
.then .t{margin-left:auto;font:600 12px var(--read)}
/* meter */
.meter{display:flex;gap:2px;height:10px}
.meter i{flex:1;border-radius:1px;background:var(--hairline)}
.meter i.on{background:var(--tick);box-shadow:0 0 6px -1px var(--tick)}
.meter.unknown{background:repeating-linear-gradient(135deg,var(--hairline) 0 3px,transparent 3px 7px);border-radius:1px}
/* game lines in rail */
.glines{margin-top:10px}
.gline{display:grid;grid-template-columns:3px 1fr auto;gap:0 12px;align-items:center;padding:9px 0;border-top:1px solid color-mix(in srgb,var(--hairline) 60%,transparent)}
.gline:first-child{border-top:0}
.gline .rail3{width:3px;height:70%;border-radius:2px;background:var(--hue);opacity:.7;align-self:center;min-height:18px}
.gline .nm{font-size:14px;font-weight:500;line-height:1.3}
.gline .what{font-size:12px;color:var(--faint);line-height:1.35}
.gline .what.go{color:var(--muted)}
.gline .end{display:flex;align-items:center;gap:6px}
.tick{width:24px;height:24px;border-radius:6px;border:1px solid var(--hairline);display:grid;place-items:center;color:transparent;transition:background .12s,border-color .12s}
.tick svg{width:14px;height:14px}
.tick:hover{border-color:var(--faint)}
.tick.done{background:color-mix(in srgb,var(--near) 18%,transparent);border-color:var(--near);color:var(--near)}
.count{font:600 12px var(--read);color:var(--muted)}
.count.out{color:var(--soon)}
.count.met{color:var(--near)}
.chev{width:16px;height:16px;color:var(--faint);transition:transform .14s}
.open .chev{transform:rotate(90deg)}
.gline.btn{cursor:pointer;width:100%;text-align:left}
.gline.btn:hover .nm{color:var(--ink-strong)}
.expand{grid-column:2 / 4;padding:8px 0 4px;margin-top:6px}
.item{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px;color:var(--muted)}
.item .lbl{flex:1;line-height:1.3}
.item .lbl small{display:block;color:var(--faint);font-size:11.5px}
.item.met .lbl{color:var(--faint)}
.amt{display:flex;align-items:center;gap:6px;font:600 12px var(--read);color:var(--faint)}
.amt input{width:72px;background:var(--ground);border:1px solid var(--hairline);border-radius:6px;color:var(--ink);font:600 12px var(--read);padding:4px 6px;text-align:right}
.mine{font:500 10px/1 var(--lang);padding:3px 5px;border-radius:3px;border:1px solid var(--hairline);color:var(--muted);margin-left:6px;vertical-align:1px}
.addhere{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--faint);padding:6px 0 0}
.addhere:hover{color:var(--ink)}
.addhere svg{width:12px;height:12px}
.empty{font-size:13px;color:var(--muted);margin:10px 0 0;line-height:1.5}
.link{color:var(--near);text-decoration:underline;text-underline-offset:3px}
/* main column */
main{min-width:0}
.banner{margin:16px;border:1px solid var(--hairline);border-left:3px solid var(--near);border-radius:8px;padding:14px 16px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.banner p{margin:0;flex:1;min-width:240px;font-size:13.5px;color:var(--muted)}
.banner p b{color:var(--ink);font-weight:600}
.qbtn{font:500 12px/1 var(--lang);padding:8px 12px;border:1px solid var(--hairline);border-radius:8px;color:var(--muted);transition:color .12s,border-color .12s}
.qbtn:hover{color:var(--ink);border-color:var(--faint)}
.cbtn{font:600 13px/1 var(--lang);padding:10px 16px;border-radius:8px;background:var(--ink);color:var(--ground)}
.cbtn:hover{background:var(--ink-strong)}
.cbtn:disabled{background:var(--raised);color:var(--faint);cursor:default}
.listhead{display:flex;align-items:center;gap:12px;padding:16px 16px 10px;border-bottom:1px solid var(--hairline);flex-wrap:wrap}
.focus{display:flex;gap:2px 14px;flex-wrap:wrap;font-size:12.5px}
.focus button{color:var(--faint);padding:4px 0;border-bottom:2px solid transparent}
.focus button:hover{color:var(--ink)}
.focus button[aria-pressed="true"]{color:var(--ink);border-color:var(--fh,var(--near))}
.focus .n{font:600 11px var(--read);opacity:.7;margin-left:4px}
.erow{display:grid;grid-template-columns:3px 1fr auto;gap:0 14px;padding:14px 16px;border-bottom:1px solid var(--hairline);width:100%;text-align:left;transition:background .12s}
.erow:hover{background:color-mix(in srgb,var(--raised) 62%,transparent)}
.erow .rail3{width:3px;border-radius:2px;background:var(--hue);opacity:.55;height:82%;align-self:center}
.erow:hover .rail3{opacity:1;height:100%;box-shadow:0 0 10px var(--hue)}
.eyebrow{font:600 11px/1 var(--read);letter-spacing:.06em;color:var(--hue)}
.etitle{display:flex;justify-content:space-between;gap:12px;margin:6px 0 8px;font-size:15px;font-weight:500;line-height:1.375}
.etitle .rd{font:600 14px var(--read);white-space:nowrap}
.emeta{font-size:12px;color:var(--faint);margin-top:6px}
.erow .chev{align-self:center}
.mutednote{padding:16px;font-size:13px;color:var(--faint)}
/* settings */
.settings{display:grid;grid-template-columns:20rem 1fr;min-height:calc(100vh - var(--bar) - 70px)}
.slist{border-right:1px solid var(--hairline);padding:12px 0}
.slist h4{font:600 11px/1 var(--read);letter-spacing:.06em;color:var(--faint);margin:14px 16px 6px}
.srow{display:grid;grid-template-columns:3px 1fr auto;gap:0 12px;align-items:center;width:100%;text-align:left;padding:9px 16px;transition:background .12s}
.srow:hover{background:color-mix(in srgb,var(--raised) 50%,transparent)}
.srow[aria-current="true"]{background:var(--raised)}
.srow .rail3{width:3px;height:26px;border-radius:2px;background:var(--hue)}
.srow .nm{font-size:13.5px;font-weight:500;line-height:1.3}
.srow .what{font-size:11.5px;color:var(--faint);line-height:1.3}
.srow.off .nm{color:var(--muted)}
.srow.off .rail3{opacity:.25}
.srow .cnt{font:600 11px var(--read);color:var(--faint)}
.more{font-size:12px;color:var(--faint);padding:8px 16px}
.more:hover{color:var(--ink)}
.profile{padding:24px 32px 64px;max-width:760px}
.ptitle{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.ptitle h2{font:600 26px/1.15 var(--read);letter-spacing:-.02em;margin:0;color:var(--hue)}
.ptitle .studio{font-size:12.5px;color:var(--faint)}
.switch{margin-left:auto;display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.sw{width:34px;height:20px;border-radius:10px;background:var(--hairline);position:relative;transition:background .14s;flex:none}
.sw::after{content:"";position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .14s,background .14s}
.sw[aria-checked="true"]{background:color-mix(in srgb,var(--hue,var(--near)) 70%,var(--ground))}
.sw[aria-checked="true"]::after{transform:translateX(14px);background:var(--ink-strong)}
.pdesc{color:var(--muted);font-size:13.5px;margin:10px 0 0;max-width:60ch}
.ladder{margin:24px 0 8px;display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--hairline);border-radius:10px;overflow:hidden}
.ladder button{padding:12px 12px 11px;text-align:left;border-left:1px solid var(--hairline);transition:background .12s}
.ladder button:first-child{border-left:0}
.ladder button:hover{background:color-mix(in srgb,var(--raised) 50%,transparent)}
.ladder button[aria-pressed="true"]{background:color-mix(in srgb,var(--hue) 14%,transparent);box-shadow:inset 0 -2px 0 var(--hue)}
.ladder .ln{font-size:13px;font-weight:600;display:block}
.ladder .ls{font-size:11.5px;color:var(--faint);display:block;margin-top:2px;line-height:1.3}
.ladnote{font-size:12px;color:var(--faint);min-height:18px}
.grp{margin-top:26px}
.grp h3{display:flex;align-items:baseline;gap:10px;font:600 12px/1 var(--read);letter-spacing:.05em;color:var(--muted);margin:0 0 4px;padding-bottom:8px;border-bottom:1px solid var(--hairline)}
.grp h3 span{font:400 12px var(--lang);letter-spacing:0;color:var(--faint)}
.grp h3 .r{margin-left:auto}
.trow{display:flex;align-items:center;gap:14px;padding:11px 0;border-bottom:1px solid color-mix(in srgb,var(--hairline) 55%,transparent)}
.trow .lbl{flex:1;font-size:14px;line-height:1.35}
.trow .lbl small{display:block;font-size:12px;color:var(--faint)}
.trow .when{font:600 12px var(--read);color:var(--faint);white-space:nowrap}
.trow.sub{padding-left:24px}
.trow.sub .lbl{font-size:13px;color:var(--muted)}
.radio{display:flex;gap:10px;align-items:flex-start;padding:10px 0;width:100%;text-align:left}
.radio .dot{width:16px;height:16px;border-radius:50%;border:1px solid var(--faint);flex:none;margin-top:2px;display:grid;place-items:center}
.radio[aria-checked="true"] .dot{border-color:var(--hue)}
.radio[aria-checked="true"] .dot::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--hue)}
.radio .lbl{font-size:14px}
.radio .lbl small{display:block;font-size:12px;color:var(--faint)}
.form{display:flex;gap:8px;flex-wrap:wrap;padding:12px 0}
.input,select.input{background:var(--ground);border:1px solid var(--hairline);border-radius:8px;color:var(--ink);font:400 13.5px var(--lang);padding:8px 12px}
.input:focus{border-color:var(--faint);outline:none}
.seg{display:inline-flex;border:1px solid var(--hairline);border-radius:8px;padding:2px;gap:2px}
.seg button{font:500 12px/1 var(--lang);padding:7px 11px;border-radius:6px;color:var(--faint)}
.seg button[aria-pressed="true"]{background:var(--raised);color:var(--ink)}
.flash{animation:flash 1.2s ease-out}
@keyframes flash{0%{background:color-mix(in srgb,var(--near) 22%,transparent)}100%{background:transparent}}
/* archive */
.arch{padding:20px 16px 64px;max-width:900px}
.arch h2{font:600 24px/1.2 var(--read);margin:0 0 4px;letter-spacing:-.02em}
.arow{display:grid;grid-template-columns:3px 1fr auto;gap:0 14px;align-items:center;padding:13px 0;border-bottom:1px solid var(--hairline)}
.arow .rail3{width:3px;height:30px;border-radius:2px;background:var(--hue);opacity:.5}
.arow .t{font-size:14.5px;color:var(--muted)}
.arow.done .t{text-decoration:line-through;text-decoration-color:var(--faint)}
.tag{font:500 10px/1 var(--lang);padding:3px 6px;border-radius:3px;margin-right:6px}
.tag.d{background:color-mix(in srgb,var(--near) 16%,transparent);color:var(--near)}
.tag.i{border:1px solid var(--hairline);color:var(--faint)}
/* onboarding */
.ob{max-width:640px;margin:0 auto;padding:40px 16px 80px}
.ob h1{font:600 28px/1.15 var(--read);letter-spacing:-.02em;margin:28px 0 8px}
.ob .lead{color:var(--muted);margin:0 0 24px;max-width:52ch}
.steps{display:flex;gap:6px;margin-top:24px}
.steps i{height:3px;flex:1;border-radius:2px;background:var(--hairline)}
.steps i.on{background:var(--near)}
.pick{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}
.pick button{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--hairline);text-align:left;font-size:14px;color:var(--muted)}
.pick button .box{width:18px;height:18px;border-radius:5px;border:1px solid var(--faint);display:grid;place-items:center;flex:none;color:transparent}
.pick button .box svg{width:12px;height:12px}
.pick button[aria-pressed="true"]{color:var(--ink)}
.pick button[aria-pressed="true"] .box{background:var(--hue);border-color:var(--hue);color:var(--ground)}
.obfoot{display:flex;align-items:center;gap:16px;margin-top:28px;flex-wrap:wrap}
.obfoot .q{font-size:12.5px;color:var(--faint)}
.obfoot .q:hover{color:var(--ink)}
.pre{display:block;width:100%;text-align:left;padding:14px 16px;border:1px solid var(--hairline);border-radius:12px;margin-bottom:8px;transition:border-color .12s,background .12s}
.pre:hover{border-color:var(--faint)}
.pre[aria-pressed="true"]{border-color:var(--hue);background:color-mix(in srgb,var(--hue) 9%,transparent)}
.pre .pn{font-weight:600;font-size:14px}
.pre .pp{font-size:12.5px;color:var(--faint);margin-top:3px;line-height:1.45}
.pre[aria-pressed="true"] .pp{color:var(--muted)}
.map{border:1px solid var(--hairline);border-radius:12px;padding:16px;margin:8px 0 0}
.map .row{display:flex;gap:14px;align-items:flex-start;padding:12px 0;border-top:1px solid var(--hairline)}
.map .row:first-child{border-top:0;padding-top:0}
.map .ic{width:32px;height:32px;border-radius:8px;border:1px solid var(--hairline);display:grid;place-items:center;flex:none;color:var(--muted)}
.map .ic svg{width:16px;height:16px}
.map .row p{margin:0;font-size:13.5px;color:var(--muted)}
.map .row b{color:var(--ink);font-weight:600;display:block}
/* sheets */
.scrim{position:fixed;inset:var(--bar) 0 0 0;background:var(--scrim);z-index:40;display:flex;justify-content:flex-end}
.scrim.center{justify-content:center;align-items:center;padding:16px}
.sheet{background:var(--surface);border-left:1px solid var(--hairline);width:min(440px,100%);height:100%;overflow:auto;padding:20px 20px 40px}
.scrim.center .sheet{height:auto;border:1px solid var(--hairline);border-radius:16px;max-width:480px}
.sheet h3{font:600 20px/1.2 var(--read);margin:4px 0 6px;color:var(--hue)}
.sheet .grp h3{font:600 12px/1 var(--read);letter-spacing:.05em;color:var(--muted);margin:0 0 4px}
.sheet .x{float:right;width:28px;height:28px;border-radius:8px;display:grid;place-items:center;color:var(--faint)}
.sheet .x:hover{color:var(--ink);background:var(--raised)}
.sheet .x svg{width:14px;height:14px}
.sheet p{color:var(--muted);font-size:13.5px;margin:0 0 10px}
.btnrow{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
@media (max-width:960px){
.cols,.settings{grid-template-columns:1fr}
.rail{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--hairline)}
.slist{border-right:0;border-bottom:1px solid var(--hairline)}
.profile{padding:20px 16px 48px}
}
@media (max-width:560px){
header.top{flex-wrap:wrap}
.tabs{width:100%}
.tabs button{flex:1;justify-content:center;padding:8px 6px}
.tabs .tl{display:none}
.ladder{grid-template-columns:1fr 1fr}
.ladder button:nth-child(3){border-left:0}
.ladder button:nth-child(n+3){border-top:1px solid var(--hairline)}
.pick{grid-template-columns:1fr}
.callout{width:min(300px,calc(100vw - 32px))}
.numeral{font-size:38px}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<div class="mock" id="mock"></div>
<aside class="why-panel" id="why" aria-label="Why and foundation"></aside>
<div class="app" id="app"></div>
<script>
/* ---------- real content (from the shipped feed and routines.ts) ---------- */
const NOW = Date.parse("2026-10-16T12:00:00Z");
const GAMES = [
{id:"genshin",name:"Genshin Impact",short:"Genshin",hue:"#4EA8DE",studio:"HoYoverse",chores:"Commissions, resin"},
{id:"hsr",name:"Honkai: Star Rail",short:"Star Rail",hue:"#7B8CFF",studio:"HoYoverse",chores:"Daily training, Trailblaze Power"},
{id:"zzz",name:"Zenless Zone Zero",short:"ZZZ",hue:"#F2A03D",studio:"HoYoverse",chores:"Daily missions, battery"},
{id:"wuwa",name:"Wuthering Waves",short:"Wuwa",hue:"#3DD6A0",studio:"Kuro Games",chores:"Daily activity, waveplate"},
{id:"endfield",name:"Arknights: Endfield",short:"Endfield",hue:"#E8635A",studio:"Hypergryph",chores:"Daily missions"},
{id:"nte",name:"Neverness to Everness",short:"NTE",hue:"#C77DFF",studio:"Hotta Studio",chores:"Daily tasks"},
{id:"arknights",name:"Arknights",short:"Arknights",hue:"#9AA3B8",studio:"Hypergryph",chores:"Daily missions, sanity"},
{id:"ba",name:"Blue Archive",short:"Blue Archive",hue:"#3FCBDD",studio:"Nexon Games",chores:"Daily missions, AP"},
{id:"hi3",name:"Honkai Impact 3rd",short:"Honkai 3rd",hue:"#8B5CF6",studio:"HoYoverse",chores:"Daily missions, stamina"},
{id:"nikki",name:"Infinity Nikki",short:"Nikki",hue:"#F27BB0",studio:"Infold Games",chores:"Daily tasks, Vital Energy"},
{id:"pgr",name:"Punishing: Gray Raven",short:"PGR",hue:"#CC292B",studio:"Kuro Games",chores:"Daily missions, serum"},
{id:"r1999",name:"Reverse: 1999",short:"R1999",hue:"#C9A227",studio:"Bluepoch",chores:"Daily missions"},
];
const G = Object.fromEntries(GAMES.map(g=>[g.id,g]));
// [game, title, unit, cycleDays, kind, target, unit, pulls, daysLeftInCycle]
const R = [
["genshin","Weekly Bosses","week",0,"amount",3,"runs"],["genshin","Battle Pass weekly EXP","week",0,"amount",10000,"EXP"],
["genshin","Reputation Bounties","week",0,"amount",3,"bounties"],["genshin","Reputation Requests","week",0,"amount",3,"requests"],
["genshin","Realm Depot: Realm Treasures","week",0,"days",1,""],["genshin","Paimon's Bargains: Intertwined Fates","month",0,"amount",5,"fates","premium"],
["genshin","Paimon's Bargains: Acquaint Fates","month",0,"amount",5,"fates","standard"],["genshin","Spiral Abyss","month",0,"days",1,""],
["genshin","Imaginarium Theater","month",0,"days",1,""],
["hsr","Echo of War","week",0,"amount",3,"runs"],["hsr","Nameless Honor weekly EXP","week",0,"amount",8000,"EXP"],
["hsr","Embers Exchange: Star Rail Special Pass","month",0,"amount",5,"passes","premium"],["hsr","Embers Exchange: Star Rail Pass","month",0,"amount",5,"passes","standard"],
["hsr","Divergent Universe & Currency Wars points","week",0,"amount",18000,"points"],
["hsr","Pure Fiction","cycle",42,"days",1,"",null,6],["hsr","Memory of Chaos","cycle",42,"days",1,"",null,20],["hsr","Apocalyptic Shadow","cycle",42,"days",1,"",null,34],
["zzz","Ridu Weekly","week",0,"amount",2100,"points"],["zzz","Bounty Commissions","week",0,"amount",8000,"points"],
["zzz","Notorious Hunt","week",0,"amount",3,"runs"],["zzz","New Eridu City Fund weekly EXP","week",0,"amount",10000,"EXP"],
["zzz","Signal Shop: Encrypted Master Tapes","month",0,"amount",5,"tapes","premium"],["zzz","Signal Shop: Master Tapes","month",0,"amount",5,"tapes","standard"],
["zzz","Signal Shop: Boopons","month",0,"amount",5,"Boopons","premium"],
["zzz","Shiyu Defense","cycle",14,"days",1,"",null,9],["zzz","Deadly Assault","cycle",14,"days",1,"",null,2],
["endfield","Weekly Routine","week",0,"amount",10,"points"],["endfield","Etchspace Salvage: Focus Commissions","week",0,"amount",3,"commissions"],
["wuwa","Weekly Challenge","week",0,"amount",3,"claims"],["wuwa","Pioneer Podcast weekly EXP","week",0,"amount",12000,"EXP"],
["wuwa","Weekly Activities","week",0,"amount",6000,"points"],["wuwa","Tower of Adversity","cycle",28,"days",1,"",null,11],
["nte","Season Pass weekly EXP","week",0,"amount",12000,"EXP"],["nte","Anomaly Pilgrimage","week",0,"amount",3,"claims"],
["nte","City Stamina","week",0,"amount",700,"stamina"],["nte","Realm of Greed","week",0,"days",1,""],
["nte","Special City Commission","week",0,"days",1,""],["nte","Beyond the Rails","cycle",14,"days",1,"",null,4],
["nte","Tycoon Incentive Fund","cycle",14,"amount",1000000,"fons",null,4],
];
const slug=s=>s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");
const ROUT = R.map(([game,title,unit,days,kind,target,u,pulls,left])=>({id:`routine:${game}:${slug(title)}`,game,title,unit,days,kind,target,u,pulls:pulls||null,left:left??null}));
const EVENTS = [
["genshin","Anniversary Memories Album","2026-09-28","2026-10-18"],["zzz","Clink, Clank, Pinball Knight!","2026-09-10","2026-10-19"],
["zzz","Diary of an Orbie Parent","2026-09-30","2026-10-19"],["zzz","Chronicles of the Hobbling Crow","2026-10-03","2026-10-19"],
["nte","Stamina Recharge","2026-10-05","2026-10-19"],["zzz","Data Bounty - Combat Simulation","2026-10-14","2026-10-19"],
["zzz","All-New Program","2026-09-09","2026-10-20"],["genshin","Predictive Victory Dynamics","2026-10-12","2026-10-20"],
["wuwa","Bountiful Crescendo","2026-10-15","2026-10-22"],["hsr","4.6 Planar Fissure","2026-10-12","2026-10-25"],
["wuwa","Artisan's Search","2026-10-08","2026-10-26"],["wuwa","Waking Moon Fishing","2026-10-15","2026-11-01"],
["genshin","To Temper Thyself and Journey Far","2026-08-10","2026-11-02"],["genshin","Battle Pass - Moontrace","2026-09-23","2026-11-02"],
["hsr","Astral Imagea","2026-09-28","2026-11-11"],["hsr","Love, Ghosts & Robots","2026-09-28","2026-11-11"],
["hsr","Wishpower UP! Down with Voracity!","2026-09-28","2026-11-11"],["nte","Pukaland Travelogue","2026-09-30","2026-11-11"],
["wuwa","3.7 Moonlit Path","2026-09-30","2026-11-11"],["wuwa","Ascendant Aces","2026-09-30","2026-11-11"],
["nte","Coal Lumps Treasure","2026-10-08","2026-11-11"],["hsr","Free Gilgamesh or Archer","2026-07-24",null],
["endfield","Originium Supply Run","2026-10-09","2026-10-23"],["arknights","Lingering Echoes Rerun","2026-10-13","2026-10-27"],
].map(([game,title,s,e])=>({id:`${game}:${slug(title)}:${s}`,game,title,start:Date.parse(s+"T00:00:00Z"),end:e?Date.parse(e+"T00:00:00Z"):null}));
/* ---------- presets: the four steps every profile offers ---------- */
const PRESETS = [
{id:"events",name:"Just events",sub:"No chores, no tasks"},
{id:"dailies",name:"Dailies",sub:"Today's chores"},
{id:"weeklies",name:"Dailies + weeklies",sub:"Adds the weekly reset"},
{id:"all",name:"Everything",sub:"Monthlies, cycles, pulls"},
];
const routinesOf = g => ROUT.filter(r=>r.game===g);
function applyPreset(p, preset){
p.chores = preset!=="events";
for (const r of routinesOf(p.game)) {
p.routines[r.id] = preset==="all" ? r.pulls!=="standard" : preset==="weeklies" ? (r.unit==="week" && !r.pulls) : false;
}
if (preset!=="all") p.standardPulls=false;
}
function presetOf(p){
for (const pr of PRESETS){
const t={game:p.game,routines:{},chores:false,standardPulls:p.standardPulls};
applyPreset(t,pr.id);
if (t.chores===p.chores && routinesOf(p.game).every(r=> r.pulls==="standard" ? true : !!t.routines[r.id]===!!p.routines[r.id])) return pr.id;
}
return "custom";
}
/* ---------- state: one GameProfile per game is the single source ---------- */
let S;
function fresh(kind){
const followed = ["genshin","hsr","zzz","wuwa","endfield","nte"];
const start = {genshin:"weeklies",hsr:"all",zzz:"dailies",wuwa:"weeklies",endfield:"dailies",nte:"events"};
const profiles = {};
for (const g of GAMES){
const p = {game:g.id,followed:followed.includes(g.id),chores:false,routines:{},standardPulls:false,placement:"rail"};
applyPreset(p, start[g.id]||"events");
profiles[g.id]=p;
}
profiles.nte.placement="focus";
return {
view:"main", sel:"zzz", focus:"all", sheet:null, open:{hsr:true}, returning:kind==="returning", callout:false,
profiles,
chores:{genshin:true,hsr:false},
prog:{"routine:genshin:weekly-bosses":2,"routine:genshin:battle-pass-exp":6400,"routine:genshin:reputation-bounties":3,"routine:hsr:echo-of-war":3,"routine:hsr:nameless-honor-weekly-exp":8000,"routine:hsr:pure-fiction":1,"routine:hsr:divergent-universe-currency-wars-points":11200,"routine:wuwa:weekly-challenge":1},
tasks:[{id:"mytask:a",game:"genshin",title:"Weekly co-op with friends",unit:"week",kind:"days",target:1},{id:"mytask:b",game:"zzz",title:"Hollow Zero run with the group",unit:"week",kind:"days",target:1}],
ownGames:[{name:"Snowbreak: Containment Zone",hue:"#9ad1ff"}],
ownEvents:[{title:"Snowbreak anniversary login",game:"Snowbreak: Containment Zone",end:"Oct 30"}],
archive:{"zzz:diary-of-an-orbie-parent:2026-09-30":{k:"done",at:"Oct 14"},"wuwa:artisan-s-search:2026-10-08":{k:"done",at:"Oct 15"},"hsr:free-gilgamesh-or-archer:2026-07-24":{k:"ignored",at:"Aug 2"},"nte:coal-lumps-treasure:2026-10-08":{k:"ignored",at:"Oct 9"}},
archFilter:"all", archGame:"all",
ob:{step:1,picked:[],idx:0,choice:{}},
addOpen:false,
};
}
S = fresh("main");
/* ---------- helpers ---------- */
const ic = {
gear:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/></svg>',
chev:'<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M6 3.5 10.5 8 6 12.5"/></svg>',
check:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3.5 8.5 6.5 11.5 12.5 4.5"/></svg>',
plus:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M8 3v10M3 8h10"/></svg>',
x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
list:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5.5 4h8M5.5 8h8M5.5 12h8M2.5 4h.5M2.5 8h.5M2.5 12h.5"/></svg>',
bars:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h7M5 8h9M3 12h6"/></svg>',
box:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="3" rx="1"/><path d="M3 6v6.5h10V6M6.5 9h3"/></svg>',
};
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const hue=id=>G[id]?.hue||"#9aa3b8";
function left(ms){const h=ms/36e5; if(h<24) return `${Math.floor(h)}h ${Math.floor((ms%36e5)/6e4)}m`; const d=Math.floor(h/24); return `${d}d ${Math.floor(h%24)}h`;}
function heat(ms){ if(ms==null) return "var(--faint)"; const h=ms/36e5; return h<24?"var(--critical)":h<72?"var(--soon)":h<168?"var(--near)":"var(--calm)"; }
function meter(e,n=24){
if(e.end==null) return `<div class="meter unknown" aria-label="End date unknown"></div>`;
const tot=e.end-e.start, rem=Math.max(0,e.end-NOW); const lit=Math.max(1,Math.round(n*rem/tot));
return `<div class="meter" style="--tick:${heat(rem)}">${Array.from({length:n},(_,i)=>`<i class="${i<lit?"on":""}"></i>`).join("")}</div>`;
}
const fmtDay=t=>new Date(t).toLocaleDateString("en-GB",{day:"numeric",month:"short",timeZone:"UTC"});
const P = id => S.profiles[id];
const followed = () => GAMES.filter(g=>P(g.id).followed);
const visibleHere = id => P(id).followed && (P(id).placement==="rail" || S.focus===id);
const onRoutines = id => routinesOf(id).filter(r=>P(id).routines[r.id] && (r.pulls!=="standard"||P(id).standardPulls));
const tasksOf = id => S.tasks.filter(t=>t.game===id);
const val = id => S.prog[id]||0;
const met = r => val(r.id) >= r.target;
const periodLeft = r => r.unit==="week"?"3d":r.unit==="month"?"16d":`${r.left}d`;
const unitWord = {week:"this week",month:"this month",cycle:"this cycle"};
function profileSummary(id){
const p=P(id); if(!p.followed) return "Not followed";
const pr=presetOf(p); const n=onRoutines(id).length+tasksOf(id).length;
const name = pr==="custom"?"Custom":PRESETS.find(x=>x.id===pr).name;
return `${name}${n?` · ${n} task${n>1?"s":""}`:""}${p.placement==="focus"?" · focus only":""}`;
}
/* ---------- views ---------- */
function mockbar(){
const items=[["main","Main"],["add","Add & choose systems"],["settings","Settings"],["onboard","Onboarding (new user)"],["returning","Returning user"],["archive","Archive"]];
const cur = S.mode==="returning"&&S.view==="main"?"returning":S.sheet?.kind==="add"?"add":S.view;
document.getElementById("mock").innerHTML = `<b>A · Every game has its own setup</b>${items.map(([k,l])=>`<button data-jump="${k}" aria-current="${cur===k}">${l}</button>`).join("")}<span class="sep"></span><button data-jump="reset">Reset demo</button><button class="why" data-jump="why">Why / foundation</button>`;
}
function header(){
const tabs=[["main","Checklist",ic.list],["timeline","Timeline",ic.bars],["archive","Archive",ic.box]];
return `<header class="top"><div><div class="wordmark">EVENT<span>CLOCK</span></div><div class="sub tnum">${liveEvents().length} running · ${followed().length} games</div></div>
<div class="tabs" role="tablist">${tabs.map(([k,l,i])=>`<button role="tab" data-view="${k}" aria-label="${l}" aria-selected="${S.view===k}">${i}<span class="tl">${l}</span></button>`).join("")}<span class="divider"></span>
<button role="tab" data-view="settings" aria-label="Settings" aria-selected="${S.view==="settings"}" class="${S.callout?"hl":""}">${ic.gear}<span class="tl">Settings</span></button>
${S.callout?`<div class="callout" role="dialog"><b>Your games are set up here now.</b> Each game has one page for its dailies, weeklies, monthlies, cycles and pulls, plus your own tasks for it. The gear next to any heading in the left column opens the right game's page directly.<br><button class="qbtn" data-act="callout-off">Got it</button></div>`:""}</div></header>`;
}
function liveEvents(){ return EVENTS.filter(e=>visibleHere(e.game) && !S.archive[e.id] && (S.focus==="all"||S.focus===e.game)).sort((a,b)=>(a.end??9e15)-(b.end??9e15)); }
function railToday(){
const gs=followed().filter(g=>visibleHere(g.id)&&P(g.id).chores&&(S.focus==="all"||S.focus===g.id));
const done=gs.filter(g=>S.chores[g.id]).length;
return `<section><div class="legend">TODAY <span class="tnum">${gs.length?`${done}/${gs.length}`:""}</span><span class="r"><span class="tnum">resets in 3h 38m</span><button class="gear" data-act="gear" data-game="${gs[0]?.id||S.sel}" aria-label="Set up dailies">${ic.gear}</button></span></div>
${gs.length?`<div class="glines">${gs.map(g=>`<div class="gline" style="--hue:${g.hue}"><span class="rail3"></span><div><div class="nm">${g.short}</div><div class="what">${g.chores}</div></div><div class="end"><button class="tick ${S.chores[g.id]?"done":""}" data-act="chore" data-game="${g.id}" aria-label="${g.short} dailies done" aria-pressed="${!!S.chores[g.id]}">${ic.check}</button></div></div>`).join("")}</div>`
:`<p class="empty">No dailies yet. Turn them on for a game in its <button class="link" data-act="gear" data-game="${S.sel}">setup</button>.</p>`}</section>`;
}
function railPeriod(){
const gs=followed().filter(g=>visibleHere(g.id)&&(onRoutines(g.id).length||tasksOf(g.id).length)&&(S.focus==="all"||S.focus===g.id));
return `<section><div class="legend">THIS WEEK, MONTH AND CYCLE<span class="r"><button class="gear" data-act="gear" data-game="${gs[0]?.id||S.sel}" aria-label="Set up weekly tasks">${ic.gear}</button></span></div>
${gs.length?`<div class="glines">${gs.map(g=>{
const rs=onRoutines(g.id), ts=tasksOf(g.id);
const wk=rs.filter(r=>r.unit==="week"), mo=rs.filter(r=>r.unit==="month"), cy=rs.filter(r=>r.unit==="cycle");
const wkDone=wk.filter(met).length+ts.filter(t=>val(t.id)>=t.target).length, wkAll=wk.length+ts.length;
const bits=[]; if(wkAll) bits.push(`${wkDone} of ${wkAll} this week`); if(mo.length) bits.push(`${mo.filter(met).length}/${mo.length} month`);
if(cy.length){const soon=cy.filter(r=>!met(r)).sort((a,b)=>a.left-b.left)[0]; bits.push(soon?`cycle ${soon.left}d`:"cycles done");}
const open=S.open[g.id];
return `<div class="gline ${open?"open":""}" style="--hue:${g.hue}"><span class="rail3"></span>
<button class="btn" style="text-align:left" data-act="expand" data-game="${g.id}" aria-expanded="${!!open}"><div class="nm">${g.short}</div><div class="what go tnum">${bits.join(" · ")}</div></button>
<div class="end"><button data-act="expand" data-game="${g.id}" aria-label="Show ${g.short} tasks">${ic.chev}</button></div>
${open?`<div class="expand">${[...rs,...ts.map(t=>({...t,mine:true}))].map(item).join("")}
<button class="addhere" data-act="quickadd" data-game="${g.id}">${ic.plus} Add to ${g.short}</button></div>`:""}
</div>`;}).join("")}</div>`
:`<p class="empty">Nothing on a weekly or longer reset yet. Each game's setup has its weeklies, monthlies and cycles: <button class="link" data-act="gear" data-game="${S.sel}">choose some</button>.</p>`}</section>`;
}
function item(r){
const v=val(r.id), done=v>=r.target;
const when = r.unit==="cycle"?`${r.left}d left`:r.unit==="month"?"month":"week";
const ctl = r.kind==="days"
? `<button class="tick ${done?"done":""}" data-act="tickr" data-id="${r.id}" aria-pressed="${done}" aria-label="${esc(r.title)} done">${ic.check}</button>`
: `<span class="amt tnum"><input inputmode="numeric" data-act="amt" data-id="${r.id}" value="${v}" aria-label="${esc(r.title)} amount">/ ${r.target.toLocaleString("en")}</span>`;
return `<div class="item ${done?"met":""}"><span class="lbl">${esc(r.title)}${r.mine?'<span class="mine">yours</span>':""}<small class="tnum">${when}${r.u?` · ${r.u}`:""}</small></span>${ctl}</div>`;
}
function headline(){
const ev=liveEvents(); const e=ev.find(x=>x.end!=null);
if(!e) return `<section><div class="legend">NEXT TO EXPIRE</div><p class="empty">Nothing running for the games on show.</p></section>`;
const rem=e.end-NOW;
return `<section class="headline" style="--heat:${heat(rem)}"><div class="wash"></div><div class="legend">NEXT TO EXPIRE</div>
<h2>${esc(e.title)}</h2><div class="game" style="color:${hue(e.game)}">${G[e.game].name}</div>
<div class="numeral tnum" style="color:${heat(rem)}">${left(rem)}<small>left<br>ends ${fmtDay(e.end)}</small></div>${meter(e,22)}
<ul class="then">${ev.filter(x=>x!==e).slice(0,2).map(x=>`<li><span class="dot" style="background:${hue(x.game)}"></span>${esc(x.title)}<span class="t tnum" style="color:${heat(x.end&&x.end-NOW)}">${x.end?left(x.end-NOW):"end unknown"}</span></li>`).join("")}</ul></section>`;
}
function mainView(){
const fs=followed().filter(g=>P(g.id).followed);
const ev=liveEvents();
const all=EVENTS.filter(e=>P(e.game).followed&&P(e.game).placement==="rail"&&!S.archive[e.id]).length;
return `<div class="cols"><aside class="rail">${headline()}${railToday()}${railPeriod()}</aside>
<main>${S.returning?`<div class="banner"><p><b>Dailies, weeklies, monthlies and cycles are now set up one game at a time.</b> We kept everything you had switched on. Games and the old "What you see" options are in Settings now.</p><button class="cbtn" data-act="showme">Show me</button><button class="qbtn" data-act="dismiss">Dismiss</button></div>`:""}
<div class="listhead"><div class="legend" style="min-height:0">RUNNING NOW</div><div class="focus" role="group" aria-label="Focus on a game">
<button data-act="focus" data-game="all" aria-pressed="${S.focus==="all"}">All<span class="n">${all}</span></button>
${fs.map(g=>`<button data-act="focus" data-game="${g.id}" aria-pressed="${S.focus===g.id}" style="--fh:${g.hue}">${g.short}<span class="n">${EVENTS.filter(e=>e.game===g.id&&!S.archive[e.id]).length}</span></button>`).join("")}</div></div>
${ev.map(e=>{const rem=e.end&&e.end-NOW; return `<button class="erow" data-act="event" data-id="${e.id}" style="--hue:${hue(e.game)}"><span class="rail3"></span><div><div class="eyebrow">${G[e.game].short.toUpperCase()}</div><div class="etitle"><span>${esc(e.title)}</span><span class="rd tnum" style="color:${heat(rem)}">${e.end?left(rem):"end date unknown"}</span></div>${meter(e,30)}<div class="emeta tnum">${fmtDay(e.start)} – ${e.end?fmtDay(e.end):"?"}</div></div>${ic.chev}</button>`}).join("")}
${followed().some(g=>P(g.id).placement==="focus")&&S.focus==="all"?`<p class="mutednote">${followed().filter(g=>P(g.id).placement==="focus").map(g=>g.short).join(", ")} only show${followed().filter(g=>P(g.id).placement==="focus").length>1?"":"s"} when you focus on ${followed().filter(g=>P(g.id).placement==="focus").length>1?"them":"it"}. Change that in its setup.</p>`:""}
</main></div>`;
}
function settingsView(){
const fl=GAMES.filter(g=>P(g.id).followed), nf=GAMES.filter(g=>!P(g.id).followed);
const sel=S.sel;
const row=g=>`<button class="srow ${P(g.id).followed?"":"off"}" data-act="sel" data-game="${g.id}" aria-current="${sel===g.id}" style="--hue:${g.hue}"><span class="rail3"></span><span><span class="nm">${g.name}</span><br><span class="what">${profileSummary(g.id)}</span></span>${ic.chev}</button>`;
const gen=[["general","Region and appearance"],["lists","Lists"],["own","Your own events and games"],["backup","Backup"]];
return `<div class="settings"><nav class="slist" aria-label="Settings">
<h4>YOUR GAMES</h4>${fl.map(row).join("")}
<h4>OTHER GAMES</h4>${nf.slice(0,S.allGames?99:3).map(row).join("")}${!S.allGames&&nf.length>3?`<button class="more" data-act="allgames">Show ${nf.length-3} more games</button>`:""}
<h4>GENERAL</h4>${gen.map(([k,l])=>`<button class="srow" data-act="sel" data-game="${k}" aria-current="${sel===k}" style="--hue:transparent"><span class="rail3"></span><span class="nm">${l}</span>${ic.chev}</button>`).join("")}
</nav><div class="profile" id="profile">${G[sel]?profile(sel):generalPane(sel)}</div></div>`;
}
function profile(id){
const g=G[id], p=P(id), pr=presetOf(p), rs=routinesOf(id);
const groups=[["Weekly","resets Monday",rs.filter(r=>r.unit==="week"&&!r.pulls)],["Monthly","resets on the 1st",rs.filter(r=>r.unit==="month"&&!r.pulls)],["Cycles","endgame modes on their own clock",rs.filter(r=>r.unit==="cycle")]];
const pulls=rs.filter(r=>r.pulls==="premium"), std=rs.filter(r=>r.pulls==="standard");
const t=r=>`<div class="trow"><span class="lbl">${esc(r.title)}<small class="tnum">${r.kind==="amount"?`${r.target.toLocaleString("en")} ${r.u}`:"Once"}${r.unit==="cycle"?` · every ${r.days} days · ${r.left}d left now`:""}</small></span><button class="sw" role="switch" data-act="rt" data-id="${r.id}" aria-checked="${!!p.routines[r.id]}" aria-label="${esc(r.title)}"></button></div>`;
return `<div style="--hue:${g.hue}">
<div class="ptitle"><h2>${g.name}</h2><span class="studio">${g.studio}</span><label class="switch">Following<button class="sw" role="switch" data-act="follow" data-game="${id}" aria-checked="${p.followed}" aria-label="Follow ${g.name}"></button></label></div>
${!p.followed?`<p class="pdesc">You're not following ${g.name}, so none of its events, chores or tasks show anywhere. Follow it to set it up.</p>`:`
<p class="pdesc">Everything ${g.short} adds to your page is chosen here. Start from a level, then switch single items on or off.</p>
<div class="ladder" role="group" aria-label="How much to track">${PRESETS.map(x=>`<button data-act="preset" data-p="${x.id}" aria-pressed="${pr===x.id}"><span class="ln">${x.name}</span><span class="ls">${x.sub}</span></button>`).join("")}</div>
<div class="ladnote">${pr==="custom"?"Custom: you've changed single items. Pick a level to start over.":""}</div>
<div class="grp"><h3>DAILY<span>resets 04:00 server time</span></h3><div class="trow"><span class="lbl">Daily chores<small>${g.chores}</small></span><button class="sw" role="switch" data-act="chores" aria-checked="${p.chores}" aria-label="Daily chores"></button></div></div>
${groups.filter(x=>x[2].length).map(([n,s,list])=>`<div class="grp"><h3>${n.toUpperCase()}<span>${s}</span><span class="r tnum">${list.filter(r=>p.routines[r.id]).length} of ${list.length}</span></h3>${list.map(t).join("")}</div>`).join("")}
${pulls.length?`<div class="grp"><h3>PULLS<span>shop items that buy gacha pulls</span></h3>${pulls.map(t).join("")}
${std.length?`<div class="trow sub"><span class="lbl">Also standard-banner pulls<small>${std.map(r=>esc(r.title)).join(", ")}</small></span><button class="sw" role="switch" data-act="std" aria-checked="${p.standardPulls}" aria-label="Standard pulls"></button></div>`:""}</div>`:""}
<div class="grp" id="yourtasks"><h3>YOUR OWN TASKS FOR ${g.short.toUpperCase()}<span>things the game doesn't track for you</span></h3>
${tasksOf(id).map(t=>`<div class="trow"><span class="lbl">${esc(t.title)}<small>Once ${unitWord[t.unit]}</small></span><button class="qbtn" data-act="deltask" data-id="${t.id}">Remove</button></div>`).join("")||`<p class="empty" style="margin:8px 0">None yet.</p>`}
${S.addOpen?taskForm(id):`<button class="addhere" data-act="addtask">${ic.plus} Add a task</button>`}</div>
<div class="grp"><h3>WHERE ${g.short.toUpperCase()} SHOWS</h3>
<button class="radio" role="radio" data-act="place" data-v="rail" aria-checked="${p.placement==="rail"}"><span class="dot"></span><span class="lbl">Everywhere<small>Its events in the list, its chores and tasks in the left column.</small></span></button>
<button class="radio" role="radio" data-act="place" data-v="focus" aria-checked="${p.placement==="focus"}"><span class="dot"></span><span class="lbl">Only when I focus on it<small>Out of the way until you pick ${g.short} in the focus row.</small></span></button></div>`}
</div>`;
}
function taskForm(id){
return `<div class="form"><input class="input" id="tt" placeholder="What do you do?" style="flex:1;min-width:180px" aria-label="Task name"><select class="input" id="tu" aria-label="How often"><option value="week">Every week</option><option value="month">Every month</option></select><button class="cbtn" data-act="savetask" data-game="${id}">Add task</button><button class="qbtn" data-act="canceltask">Cancel</button></div>`;
}
function generalPane(k){
if(k==="general") return `<div style="--hue:var(--near)"><div class="ptitle"><h2 style="color:var(--ink)">Region and appearance</h2></div>
<div class="grp"><h3>SERVER REGION</h3><p class="pdesc" style="margin:8px 0 12px">Daily resets and event ends follow this server.</p><div class="seg">${["Asia","America","Europe"].map(r=>`<button aria-pressed="${r==="Europe"}">${r}</button>`).join("")}</div></div>
<div class="grp"><h3>THEME</h3><div class="seg" style="margin-top:12px">${["Dark","Light","System"].map(r=>`<button aria-pressed="${r==="Dark"}">${r}</button>`).join("")}</div></div></div>`;
if(k==="lists") return `<div style="--hue:var(--near)"><div class="ptitle"><h2 style="color:var(--ink)">Lists</h2></div><p class="pdesc">The few choices that are about the lists, not about any one game.</p>
<div class="grp"><div class="trow"><span class="lbl">Show events that haven't started<small>As their own section under what's running.</small></span><button class="sw" role="switch" aria-checked="false"></button></div>
<div class="trow"><span class="lbl">Guess which events repeat daily<small>Reads the source's wording, and is sometimes wrong.</small></span><button class="sw" role="switch" aria-checked="false"></button></div>
<div class="trow"><span class="lbl">Finished and ignored events<small>Leave the lists and wait in the Archive tab.</small></span><button class="qbtn" data-view="archive">Open Archive</button></div></div></div>`;
if(k==="own") return `<div style="--hue:var(--near)"><div class="ptitle"><h2 style="color:var(--ink)">Your own events and games</h2></div><p class="pdesc">Only what you typed in yourself. Built-in chores and tasks never show up here; they live on each game's page.</p>
<div class="grp"><h3>YOUR GAMES</h3>${S.ownGames.map(o=>`<div class="trow" style="--hue:${o.hue}"><span class="lbl">${esc(o.name)}<small>Added by you</small></span><button class="qbtn">Edit</button></div>`).join("")}<button class="addhere">${ic.plus} Add a game</button></div>
<div class="grp"><h3>YOUR EVENTS</h3>${S.ownEvents.map(o=>`<div class="trow"><span class="lbl">${esc(o.title)}<small>${esc(o.game)} · ends ${o.end}</small></span><button class="qbtn">Edit</button></div>`).join("")}<button class="addhere">${ic.plus} Add an event</button></div>
<div class="grp"><h3>YOUR TASKS</h3>${S.tasks.map(t=>`<div class="trow"><span class="lbl">${esc(t.title)}<small>${G[t.game].short} · once ${unitWord[t.unit]}</small></span><button class="qbtn" data-act="sel" data-game="${t.game}">Open ${G[t.game].short}</button></div>`).join("")}</div></div>`;
return `<div style="--hue:var(--near)"><div class="ptitle"><h2 style="color:var(--ink)">Backup</h2></div><p class="pdesc">Everything lives in this browser. Save it to a file to move it to another device.</p><div class="btnrow"><button class="cbtn">Save to a file</button><button class="qbtn">Load a file</button></div></div>`;
}
function archiveView(){
const rows=EVENTS.filter(e=>S.archive[e.id]).filter(e=>S.archFilter==="all"||S.archive[e.id].k===S.archFilter).filter(e=>S.archGame==="all"||e.game===S.archGame);
const games=[...new Set(EVENTS.filter(e=>S.archive[e.id]).map(e=>e.game))];
return `<div class="arch"><h2>Archive</h2><p class="pdesc" style="margin:0 0 18px">Events you finished or chose to ignore. They stay out of your lists until you restore them.</p>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center"><div class="seg">${[["all","All"],["done","Finished"],["ignored","Ignored"]].map(([k,l])=>`<button data-act="af" data-v="${k}" aria-pressed="${S.archFilter===k}">${l}</button>`).join("")}</div>
<div class="focus"><button data-act="ag" data-game="all" aria-pressed="${S.archGame==="all"}">Every game</button>${games.map(g=>`<button data-act="ag" data-game="${g}" aria-pressed="${S.archGame===g}" style="--fh:${hue(g)}">${G[g].short}</button>`).join("")}</div></div>
<div style="margin-top:12px">${rows.map(e=>{const a=S.archive[e.id];return `<div class="arow ${a.k}" style="--hue:${hue(e.game)}"><span class="rail3"></span><div><div class="eyebrow">${G[e.game].short.toUpperCase()}</div><div class="t">${esc(e.title)}</div><div class="emeta"><span class="tag ${a.k==="done"?"d":"i"}">${a.k==="done"?"Finished":"Ignored"}</span>${a.at}${e.end&&e.end<NOW?" · event over":e.end?` · still running, ${left(e.end-NOW)} left`:""}</div></div><button class="qbtn" data-act="restore" data-id="${e.id}">Restore</button></div>`}).join("")||`<p class="empty">Nothing here. Finish or ignore an event from its details and it moves here.</p>`}</div></div>`;
}
function onboardView(){
const o=S.ob;
const steps=`<div class="steps">${[1,2,3].map(i=>`<i class="${i<=o.step?"on":""}"></i>`).join("")}</div>`;
if(o.step===1) return `<div class="ob"><div class="wordmark">EVENT<span>CLOCK</span></div>${steps}<h1>Which games do you play?</h1><p class="lead">You'll see events only for these. Next, you'll choose how much of each game to track.</p>
<div class="pick">${GAMES.map(g=>`<button data-act="obpick" data-game="${g.id}" aria-pressed="${o.picked.includes(g.id)}" style="--hue:${g.hue}"><span class="box">${ic.check}</span>${g.name}</button>`).join("")}</div>
<div class="obfoot"><button class="cbtn" data-act="ob1" ${o.picked.length?"":"disabled"}>${o.picked.length?`Continue with ${o.picked.length}`:"Pick at least one game"}</button></div></div>`;
if(o.step===2){
const id=o.picked[o.idx], g=G[id], ch=o.choice[id]||"events", rs=routinesOf(id);
const peek=pid=>{const t={game:id,routines:{},chores:false,standardPulls:false};applyPreset(t,pid);const on=rs.filter(r=>t.routines[r.id]);
const bits=[]; if(t.chores) bits.push(g.chores.toLowerCase()+" each day"); if(on.length) bits.push(`${on.length} task${on.length>1?"s":""}: ${on.slice(0,3).map(r=>r.title).join(", ")}${on.length>3?", and more":""}`);
return pid==="events"?"Only its events, with countdowns. Nothing to tick.": (pid!=="events"&&!rs.length&&pid!=="dailies")?`Same as Dailies: ${g.short} has no built-in weekly tasks yet.`:`Adds ${bits.join("; ")}.`;};
return `<div class="ob" style="--hue:${g.hue}"><div class="wordmark">EVENT<span>CLOCK</span></div>${steps}
<div class="legend" style="margin-top:28px">GAME ${o.idx+1} OF ${o.picked.length}</div><h1 style="margin-top:8px;color:${g.hue}">How much of ${g.name} do you want to track?</h1><p class="lead">You can change this later in Settings, item by item.</p>
${PRESETS.map(x=>`<button class="pre" data-act="obpre" data-p="${x.id}" aria-pressed="${ch===x.id}"><div class="pn">${x.name}</div><div class="pp">${peek(x.id)}</div></button>`).join("")}
<div class="obfoot"><button class="cbtn" data-act="ob2">${o.idx<o.picked.length-1?`Next: ${G[o.picked[o.idx+1]].short}`:"Continue"}</button>${o.picked.length-o.idx>1?`<button class="q" data-act="obsame">Use this for the remaining ${o.picked.length-o.idx-1}</button>`:""}<button class="q" data-act="obback">Back</button></div></div>`;
}
return `<div class="ob"><div class="wordmark">EVENT<span>CLOCK</span></div>${steps}<h1>Where things live</h1><p class="lead">Three places, so you always know where to look.</p>
<div class="map"><div class="row"><span class="ic">${ic.list}</span><p><b>Checklist</b>What's ending soon on the right. Today's chores and your weekly tasks on the left, one line per game.</p></div>
<div class="row"><span class="ic">${ic.gear}</span><p><b>Settings, and the gears</b>Each game has its own page for what it tracks. The gear next to a heading in the left column takes you straight there.</p></div>
<div class="row"><span class="ic">${ic.box}</span><p><b>Archive</b>Events you finish or ignore wait here, and you can put any of them back.</p></div></div>
<div class="obfoot"><button class="cbtn" data-act="ob3">Show my events</button><button class="q" data-act="obback">Back</button></div></div>`;
}
function sheet(){
if(!S.sheet) return "";
if(S.sheet.kind==="event"){
const e=EVENTS.find(x=>x.id===S.sheet.id);
return `<div class="scrim center" data-act="close"><div class="sheet" style="--hue:${hue(e.game)}" data-stop><button class="x" data-act="close" aria-label="Close">${ic.x}</button><div class="eyebrow">${G[e.game].short.toUpperCase()}</div><h3 style="color:var(--ink)">${esc(e.title)}</h3><p class="tnum">${fmtDay(e.start)} – ${e.end?fmtDay(e.end):"end date unknown"}</p>${meter(e,24)}<div class="btnrow"><button class="cbtn" data-act="arch" data-k="done" data-id="${e.id}">Mark done</button><button class="qbtn" data-act="arch" data-k="ignored" data-id="${e.id}">Ignore</button></div><p style="margin-top:14px;font-size:12px;color:var(--faint)">Either one moves it to the Archive.</p></div></div>`;
}
const id=S.sheet.game, g=G[id], p=P(id);
const off=routinesOf(id).filter(r=>!p.routines[r.id]&&(r.pulls!=="standard"||p.standardPulls));
return `<div class="scrim" data-act="close"><div class="sheet" style="--hue:${g.hue}" data-stop><button class="x" data-act="close" aria-label="Close">${ic.x}</button>
<div class="legend">ADD TO</div><h3>${g.name}</h3><p>Built-in tasks you're not tracking yet. Switching one on puts it in the left column straight away.</p>
${off.length?off.map(r=>`<div class="trow"><span class="lbl">${esc(r.title)}<small class="tnum">${r.unit==="cycle"?`Every ${r.days} days`:r.unit==="week"?"Weekly":"Monthly"}${r.kind==="amount"?` · ${r.target.toLocaleString("en")} ${r.u}`:""}</small></span><button class="sw" role="switch" data-act="rt" data-id="${r.id}" aria-checked="false" aria-label="${esc(r.title)}"></button></div>`).join(""):`<p class="empty">You're tracking every built-in task ${g.short} has.</p>`}
<div class="grp"><h3>OR YOUR OWN</h3>${taskForm(id).replace('data-act="canceltask"','data-act="close"')}</div>
<div class="btnrow"><button class="qbtn" data-act="gear" data-game="${id}">Open ${g.short}'s full setup</button></div></div></div>`;
}
function why(){
document.getElementById("why").innerHTML = `
<h3>The idea</h3><p>The game is the unit you customise. Every game you follow has one page that holds everything it adds to your screen: its events, its daily chores, each weekly, monthly and cycle task, its pulls, your own tasks for it, and where it shows up. A four-step level ("Just events" → "Everything") gets you most of the way in one click, and single switches handle the rest. New readers start on "Just events" for every game, so the page starts quiet.</p>
<h3>The foundation</h3><p>Today about fifteen preferences each answer part of "what do I see for this game": <code>hiddenGames</code>, <code>knownGames</code>, <code>showChores</code> / <code>choresOn</code> / <code>choresOff</code>, <code>showRoutines</code> / <code>routineGames</code> / <code>routinesOff</code> / <code>routinesAsked</code> / <code>routinesHidden</code>, <code>showWeeklyTasks</code> / <code>showMonthlyTasks</code> / <code>showCycleTasks</code>, and <code>standardPulls</code>. They're spread across four settings panels, which is why turning one thing back on is hard to find. In this model they collapse into one record per game:</p>
<pre>GameProfile {
game: LaneId
followed: boolean
chores: boolean
routines: { [routineId]: boolean } // explicit, opt-in
standardPulls: boolean
placement: "everywhere" | "focus-only"
}
prefs.profiles: Record&lt;LaneId, GameProfile&gt;</pre>
<p>Every view reads from <code>profiles</code> only: the rail, the lists, the timeline, the counts. A preset is just a function that writes a profile. It's never stored, and "Custom" means the profile doesn't match any preset. Your own tasks keep their own key space and attach to a game by <code>game</code>, so they show on that game's page without being mixed in with the built-in ones.</p>
<h3>How it scales</h3><ul><li><b>A new game</b> gets a profile on "Just events", unfollowed, the same thing <code>knownGames</code> does today, but in one place.</li><li><b>A new built-in routine</b> starts off; for readers on a preset that includes its kind, a one-line "New for ZZZ: …" offer appears on that game's page instead of it turning on silently.</li><li><b>A new kind of system</b> (say, a seasonal pass) is one more group on the game page and one more rule in the presets. No new global switch.</li><li>Migration is one pass: work out each game's profile from the old fifteen fields, then drop them.</li></ul>
<h3>Tradeoffs</h3><ul><li>Changing things across many games means visiting each one. There's no "turn off monthlies everywhere" here. A bulk row could be added, but it reintroduces a global switch.</li><li>Settings becomes a real page, which is one more place to learn. The gears and the onboarding map are there to soften that.</li><li>The rail stays quieter by collapsing tasks to one line per game. Ticking a task takes one extra tap to expand the game.</li><li>"Only when I focus on it" is a new idea readers have to learn.</li></ul>`;
}
function render(){
mockbar();
const app=document.getElementById("app");
if(S.view==="onboard"){ app.innerHTML=`<div class="shell">${onboardView()}</div>`; return; }
const body = S.view==="settings"?settingsView():S.view==="archive"?archiveView():S.view==="timeline"?`<p class="mutednote" style="padding:40px 16px">The timeline isn't part of this mockup. It reads the same game profiles as the checklist, so a game set to "Just events" shows only its event bars.</p>`:mainView();
app.innerHTML = `<div class="shell">${header()}${body}</div>${sheet()}`;
}
/* ---------- interaction ---------- */
document.addEventListener("click",ev=>{
const j=ev.target.closest("[data-jump]");
if(j){ const k=j.dataset.jump;
if(k==="why"){document.getElementById("why").classList.toggle("open");return;}
if(k==="reset"){S=fresh("main");}
else if(k==="main"){S.view="main";S.returning=false;S.sheet=null;S.callout=false;}
else if(k==="returning"){S=fresh("returning");}
else if(k==="settings"){S.view="settings";S.sheet=null;}
else if(k==="archive"){S.view="archive";S.sheet=null;}
else if(k==="add"){S.view="main";S.sheet={kind:"add",game:"zzz"};}
else if(k==="onboard"){S=fresh("main");S.view="onboard";}
S.mode=k; render();return; }
const v=ev.target.closest("[data-view]");
if(v){S.view=v.dataset.view;S.sheet=null;S.callout=false;S.addOpen=false;render();window.scrollTo(0,0);return;}
const a=ev.target.closest("[data-act]"); if(!a) return;
if(a.dataset.act==="close" && ev.target.closest("[data-stop]") && a.classList.contains("scrim")) return;
const act=a.dataset.act, g=a.dataset.game, id=a.dataset.id;
const p=S.profiles[S.sheet?.kind==="add"?S.sheet.game:S.sel];
switch(act){
case "gear": S.view="settings";S.sel=g;S.sheet=null;S.addOpen=false;break;
case "chore": S.chores[g]=!S.chores[g];break;
case "expand": S.open[g]=!S.open[g];break;
case "tickr": S.prog[id]=val(id)>=1?0:1;break;
case "focus": S.focus=g;break;
case "event": S.sheet={kind:"event",id};break;
case "arch": S.archive[id]={k:a.dataset.k,at:"Oct 16"};S.sheet=null;break;
case "close": S.sheet=null;break;
case "quickadd": S.sheet={kind:"add",game:g};break;
case "sel": S.sel=g;S.addOpen=false;if(G[g])S.view="settings";break;
case "allgames": S.allGames=true;break;
case "follow": P(g).followed=!P(g).followed; if(!P(g).followed&&S.focus===g)S.focus="all"; break;
case "preset": applyPreset(p,a.dataset.p);break;
case "chores": p.chores=!p.chores;break;
case "rt": p.routines[id]=!p.routines[id]; if(S.sheet?.kind==="add") S.open[p.game]=true; break;
case "std": p.standardPulls=!p.standardPulls; for(const r of routinesOf(p.game)) if(r.pulls==="standard") p.routines[r.id]=p.standardPulls; break;
case "place": p.placement=a.dataset.v;break;
case "addtask": S.addOpen=true;break;
case "canceltask": S.addOpen=false;break;
case "savetask": { const t=document.getElementById("tt").value.trim(); if(!t) return; S.tasks.push({id:"mytask:"+Math.random().toString(36).slice(2,8),game:g,title:t,unit:document.getElementById("tu").value,kind:"days",target:1}); S.addOpen=false; S.open[g]=true; if(S.sheet) S.sheet=null; break; }
case "deltask": S.tasks=S.tasks.filter(t=>t.id!==id);break;
case "restore": delete S.archive[id];break;
case "af": S.archFilter=a.dataset.v;break;
case "ag": S.archGame=g;break;
case "showme": S.returning=false;S.callout=true;break;
case "dismiss": S.returning=false;break;
case "callout-off": S.callout=false;S.view="settings";break;
case "obpick": { const l=S.ob.picked; S.ob.picked=l.includes(g)?l.filter(x=>x!==g):[...l,g]; break; }
case "ob1": S.ob.step=2;S.ob.idx=0;break;
case "obpre": S.ob.choice[S.ob.picked[S.ob.idx]]=a.dataset.p;break;
case "ob2": if(S.ob.idx<S.ob.picked.length-1)S.ob.idx++;else S.ob.step=3;break;
case "obsame": { const c=S.ob.choice[S.ob.picked[S.ob.idx]]||"events"; S.ob.picked.slice(S.ob.idx).forEach(x=>S.ob.choice[x]=c); S.ob.step=3; break; }
case "obback": if(S.ob.step===3){S.ob.step=2;S.ob.idx=S.ob.picked.length-1;} else if(S.ob.idx>0)S.ob.idx--; else S.ob.step=1; break;
case "ob3": { for(const gm of GAMES){ const pp=S.profiles[gm.id]; pp.followed=S.ob.picked.includes(gm.id); pp.placement="rail"; applyPreset(pp,S.ob.choice[gm.id]||"events"); }
S.tasks=[];S.prog={};S.chores={};S.archive={};S.open={};S.view="main";S.focus="all";S.sel=S.ob.picked[0]; break; }
default: return;
}
render();
if(act==="gear"){ const pf=document.getElementById("profile"); pf&&pf.classList.add("flash"); }
if(act==="addtask"){ document.getElementById("tt")?.focus(); }
});
document.addEventListener("change",ev=>{
const t=ev.target; if(t.dataset.act==="amt"){ S.prog[t.dataset.id]=Math.max(0,parseInt(t.value.replace(/\D/g,""))||0); render(); }
});
document.addEventListener("keydown",ev=>{ if(ev.key==="Escape"&&S.sheet){S.sheet=null;render();} });
why(); render();
</script>
</body>
</html>
@@ -0,0 +1,616 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>B · Routines get their own home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root{
--ground:#0e1117; --panel:#141923; --raised:#1c2230; --rule:#232a38; --rule-strong:#323b4e;
--ink:#e4e7ef; --ink-strong:#fff; --muted:#8b93a7; --faint:#5d6579;
--calm:#55607f; --near:#5b8def; --soon:#e8b33c; --critical:#ff4d6a;
--fill:#c9cedb;
--chrome-bg:#e9e4d4; --chrome-ink:#2a2a2a;
}
:root[data-theme="light"]{
--ground:#eef0f5; --panel:#ffffff; --raised:#e2e6ef; --rule:#d3d8e3; --rule-strong:#b9c0cf;
--ink:#151824; --ink-strong:#000; --muted:#525a70; --faint:#757d92;
--calm:#8a92a8; --near:#2b62c6; --soon:#8a6008; --critical:#c81e3c; --fill:#3a4152;
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--ground);color:var(--ink);font:400 14px/1.5 Barlow,system-ui,sans-serif;font-variant-numeric:tabular-nums;padding-top:40px;min-height:100vh}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
input,select{font:inherit;color:var(--ink);background:var(--ground);border:1px solid var(--rule-strong);border-radius:3px;padding:6px 8px}
:focus-visible{outline:2px solid var(--near);outline-offset:2px}
.cond{font-family:"Barlow Condensed",Barlow,sans-serif}
/* ---------- prototype chrome ---------- */
#chrome{position:fixed;inset:0 0 auto 0;height:40px;z-index:50;background:var(--chrome-bg);color:var(--chrome-ink);display:flex;align-items:center;gap:6px;padding:0 12px;font:500 12px/1 system-ui,sans-serif;border-bottom:2px dashed #b9b29a;overflow-x:auto;white-space:nowrap}
#chrome b{font-weight:700;margin-right:8px}
#chrome button{padding:5px 8px;border-radius:3px;border:1px solid #c4bda4;background:#f6f2e6}
#chrome button[aria-pressed="true"]{background:#2a2a2a;color:#f6f2e6;border-color:#2a2a2a}
#chrome .sp{flex:1}
/* ---------- app shell ---------- */
.shell{max-width:1320px;margin:0 auto;border-inline:1px solid var(--rule);min-height:calc(100vh - 40px)}
header.top{display:flex;align-items:flex-end;gap:28px;padding:18px 24px 0;border-bottom:1px solid var(--rule)}
.mark{font:700 20px/1 "Barlow Condensed";letter-spacing:.04em;padding-bottom:14px}
.mark span{color:var(--near)}
nav.tabs{display:flex;gap:2px;flex:1}
nav.tabs button{padding:10px 14px 12px;font:600 15px/1 "Barlow Condensed";letter-spacing:.03em;color:var(--faint);border-bottom:2px solid transparent;margin-bottom:-1px;display:flex;gap:6px;align-items:baseline}
nav.tabs button:hover{color:var(--muted)}
nav.tabs button[aria-current="page"]{color:var(--ink);border-color:var(--ink)}
nav.tabs .n{font:500 12px Barlow;color:var(--faint)}
nav.tabs .gear{margin-left:auto}
nav.tabs svg{width:15px;height:15px;align-self:center}
/* ---------- events screen ---------- */
.ev{display:grid;grid-template-columns:340px 1fr;min-height:calc(100vh - 100px)}
.rail{border-right:1px solid var(--rule)}
.head{padding:22px 24px 20px;border-bottom:1px solid var(--rule)}
.legend{font-size:12px;color:var(--faint);margin:0 0 6px}
.head h2{font:600 30px/1.05 "Barlow Condensed";margin:0 0 6px;letter-spacing:.005em}
.head .game{font-weight:500;font-size:14px}
.big{font:700 64px/1 "Barlow Condensed";letter-spacing:-.01em;margin:16px 0 4px}
.big small{font-size:18px;font-weight:600;color:var(--muted);margin-left:6px;letter-spacing:0}
.exact{font-size:12px;color:var(--faint);margin-bottom:12px}
.meter{display:flex;gap:2px;height:9px}
.meter i{flex:1;background:var(--rule);border-radius:1px}
.meter i.on{background:var(--h)}
.meter.unknown{background:repeating-linear-gradient(135deg,var(--rule) 0 3px,transparent 3px 6px);}
.meter.unknown i{background:transparent}
.then{list-style:none;margin:16px 0 0;padding:12px 0 0;border-top:1px solid var(--rule)}
.then li{display:flex;gap:8px;align-items:baseline;font-size:13px;padding:3px 0;color:var(--muted)}
.then .dot{width:7px;height:7px;border-radius:50%;background:var(--g);flex:none;transform:translateY(-1px)}
.then .t{flex:1}
.then .r{font:600 14px "Barlow Condensed"}
.rstrip{display:block;width:100%;text-align:left;padding:18px 24px;border-bottom:1px solid var(--rule)}
.rstrip:hover{background:var(--panel)}
.rstrip .row1{display:flex;justify-content:space-between;align-items:baseline}
.rstrip .lbl{font:600 16px "Barlow Condensed";letter-spacing:.02em}
.rstrip .go{font-size:12px;color:var(--near)}
.rstrip .nums{display:flex;gap:18px;margin-top:8px}
.rstrip .nums div{font-size:12px;color:var(--faint)}
.rstrip .nums b{display:block;font:600 22px/1.1 "Barlow Condensed";color:var(--ink)}
.rstrip .nums b.due{color:var(--soon)}
.rstrip.empty .lbl{color:var(--muted)}
.rstrip p{margin:6px 0 0;font-size:13px;color:var(--muted)}
.mine{padding:18px 24px}
.mine h3{font:600 16px "Barlow Condensed";letter-spacing:.02em;margin:0 0 8px;display:flex;justify-content:space-between}
.mine h3 button{font:500 12px Barlow;color:var(--near)}
.mine p{font-size:13px;color:var(--muted);margin:0}
.main{min-width:0}
.notice{margin:0;padding:14px 24px;border-bottom:1px solid var(--rule);background:color-mix(in srgb,var(--near) 9%,transparent);display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.notice p{margin:0;flex:1;min-width:220px}
.notice strong{font-weight:600}
.btn{padding:7px 12px;border:1px solid var(--rule-strong);border-radius:3px;font-weight:500;font-size:13px;color:var(--muted)}
.btn:hover{color:var(--ink);border-color:var(--muted)}
.btn.solid{background:var(--ink);color:var(--ground);border-color:var(--ink)}
.btn.solid:hover{background:var(--ink-strong)}
.btn.link{border:0;padding:7px 4px;color:var(--near)}
.ledger-h{display:flex;justify-content:space-between;align-items:baseline;padding:20px 24px 10px;gap:12px;flex-wrap:wrap}
.ledger-h h2{font:600 22px "Barlow Condensed";margin:0;letter-spacing:.02em}
.ledger-h .sub{font-size:12px;color:var(--faint)}
.seg{display:inline-flex;border:1px solid var(--rule-strong);border-radius:3px}
.seg button{padding:5px 10px;font-size:12px;color:var(--faint)}
.seg button[aria-pressed="true"]{background:var(--raised);color:var(--ink)}
table.led{width:100%;border-collapse:collapse}
table.led th{font:500 12px Barlow;color:var(--faint);text-align:left;padding:6px 12px;border-bottom:1px solid var(--rule-strong);white-space:nowrap}
table.led td{padding:12px 12px;border-bottom:1px solid var(--rule);vertical-align:middle}
table.led th:first-child,table.led td:first-child{padding-left:24px}
table.led th:last-child,table.led td:last-child{padding-right:24px}
table.led tbody tr{cursor:pointer}
table.led tbody tr:hover{background:var(--panel)}
.gcell{display:flex;align-items:center;gap:10px;white-space:nowrap;font-weight:600;font-size:13px;color:var(--g)}
.gcell::before{content:"";width:3px;height:26px;border-radius:2px;background:var(--g)}
.tcell{font-weight:500;font-size:15px}
.tcell .tag{font-size:11px;color:var(--faint);border:1px solid var(--rule-strong);border-radius:2px;padding:0 5px;margin-left:8px;font-weight:500;vertical-align:2px}
.tcell .tag.own{color:var(--g);border-color:color-mix(in srgb,var(--g) 45%,transparent)}
.mcell{width:34%}
.cd{font:600 20px "Barlow Condensed";text-align:right;white-space:nowrap}
.cd.calm{color:var(--calm)}.cd.near{color:var(--near)}.cd.soon{color:var(--soon)}.cd.critical{color:var(--critical)}.cd.unk{color:var(--faint);font-size:14px}
tr.done{opacity:.4}
tr.done .tcell{text-decoration:line-through}
.more{padding:12px 24px;font-size:13px;color:var(--muted)}
/* ---------- routines board ---------- */
.rt-top{display:flex;align-items:flex-end;gap:16px;padding:22px 24px 16px;border-bottom:1px solid var(--rule);flex-wrap:wrap}
.rt-top h1{font:700 34px/1 "Barlow Condensed";margin:0;letter-spacing:.01em}
.rt-top .sum{font-size:13px;color:var(--muted);flex:1;min-width:200px}
.band{border-bottom:1px solid var(--rule)}
.band-h{display:grid;grid-template-columns:1fr auto;align-items:end;padding:22px 24px 10px;gap:12px;border-bottom:1px solid var(--rule-strong)}
.band-h h2{font:700 26px/1 "Barlow Condensed";margin:0;letter-spacing:.02em}
.band-h .meta{font-size:12px;color:var(--faint);margin-top:4px}
.band-h .reset{text-align:right}
.band-h .reset .k{font-size:12px;color:var(--faint)}
.band-h .reset .v{font:700 34px/1 "Barlow Condensed"}
table.rt{width:100%;border-collapse:collapse;table-layout:fixed}
table.rt td:nth-child(4){width:150px;text-align:right}
table.rt td{padding:10px 12px;border-bottom:1px solid var(--rule);vertical-align:middle}
table.rt td:first-child{padding-left:24px;width:150px}
table.rt td:last-child{padding-right:24px;text-align:right;width:44px}
table.rt tr:last-child td{border-bottom:0}
.rtitle{font-weight:500;font-size:15px}
.rtitle .src{display:block;font-size:12px;color:var(--faint);font-weight:400}
.prog{width:34%}
.amt{display:flex;align-items:center;gap:12px;width:100%;text-align:left}
.amt .bar{flex:1;height:6px;background:var(--rule);position:relative;border-radius:1px;overflow:hidden}
.amt .bar i{position:absolute;inset:0 auto 0 0;background:var(--fill)}
.amt .bar.full i{background:var(--g)}
.amt .num{font:600 15px "Barlow Condensed";min-width:120px;text-align:right;color:var(--muted)}
.amt .num b{color:var(--ink);font-weight:600}
.amt input{width:90px;padding:3px 6px}
.tick{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}
.tick .box{width:18px;height:18px;border:1px solid var(--rule-strong);border-radius:3px;display:grid;place-items:center}
.tick[aria-pressed="true"]{color:var(--ink)}
.tick[aria-pressed="true"] .box{background:var(--g);border-color:var(--g)}
.tick svg{width:12px;height:12px;stroke:var(--ground);stroke-width:3;fill:none;opacity:0}
.tick[aria-pressed="true"] svg{opacity:1}
.cyc{font:600 14px "Barlow Condensed";color:var(--muted);white-space:nowrap}
.kebab{width:28px;height:28px;border-radius:3px;display:inline-grid;place-items:center;color:var(--faint);position:relative}
.kebab:hover{background:var(--raised);color:var(--ink)}
.menu{position:absolute;right:0;top:30px;z-index:20;background:var(--panel);border:1px solid var(--rule-strong);border-radius:4px;min-width:180px;text-align:left;padding:4px;box-shadow:0 8px 24px rgb(0 0 0 / .35)}
.menu button{display:block;width:100%;text-align:left;padding:7px 10px;font-size:13px;color:var(--muted);border-radius:3px}
.menu button:hover{background:var(--raised);color:var(--ink)}
.menu button.danger:hover{color:var(--critical)}
tr.met .rtitle{color:var(--muted)}
.empty-band{padding:14px 24px;font-size:13px;color:var(--faint)}
.empty-state{padding:72px 24px;max-width:560px}
.empty-state h2{font:700 30px/1.1 "Barlow Condensed";margin:0 0 10px}
.empty-state p{color:var(--muted);margin:0 0 20px;max-width:46ch}
/* ---------- library drawer ---------- */
.scrim{position:fixed;inset:40px 0 0 0;background:rgb(8 10 14 / .6);z-index:30}
:root[data-theme="light"] .scrim{background:rgb(26 31 48 / .35)}
.drawer{position:fixed;top:40px;right:0;bottom:0;width:min(560px,100vw);background:var(--panel);border-left:1px solid var(--rule-strong);z-index:31;display:flex;flex-direction:column}
.drawer-h{padding:20px 24px 14px;border-bottom:1px solid var(--rule)}
.drawer-h .r1{display:flex;justify-content:space-between;align-items:center}
.drawer-h h2{font:700 28px/1 "Barlow Condensed";margin:0}
.drawer-h p{margin:6px 0 12px;font-size:13px;color:var(--muted)}
.drawer-h input{width:100%}
.close{width:32px;height:32px;display:grid;place-items:center;border-radius:3px;color:var(--muted)}
.close:hover{background:var(--raised);color:var(--ink)}
.drawer-b{overflow:auto;flex:1}
.lg{border-bottom:1px solid var(--rule)}
.lg-h{display:flex;align-items:center;gap:10px;padding:16px 24px 8px;flex-wrap:wrap}
.lg-h .nm{font:700 18px "Barlow Condensed";color:var(--g);letter-spacing:.02em;flex:1}
.lg-h .cnt{font-size:12px;color:var(--faint)}
.bulk{display:flex;gap:6px;padding:0 24px 10px;flex-wrap:wrap}
.bulk button{font-size:12px;color:var(--muted);border:1px solid var(--rule-strong);border-radius:2px;padding:3px 8px}
.bulk button:hover{color:var(--ink);border-color:var(--muted)}
.li{display:grid;grid-template-columns:1fr auto;gap:12px;padding:9px 24px;border-top:1px solid var(--rule);align-items:center}
.li .t{font-weight:500}
.li .d{font-size:12px;color:var(--faint)}
.fol{font-size:12px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px solid var(--rule-strong);color:var(--muted);min-width:92px}
.fol:hover{color:var(--ink);border-color:var(--muted)}
.fol[aria-pressed="true"]{background:color-mix(in srgb,var(--g) 16%,transparent);border-color:color-mix(in srgb,var(--g) 60%,transparent);color:var(--ink)}
.others summary{padding:14px 24px;cursor:pointer;color:var(--muted);font-size:13px}
.create{padding:18px 24px 28px;border-top:1px solid var(--rule-strong);background:var(--ground)}
.create h3{font:700 20px "Barlow Condensed";margin:0 0 4px}
.create p{font-size:13px;color:var(--muted);margin:0 0 12px}
.form{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--faint)}
.form .full{grid-column:1/-1}
.pills{display:flex;gap:4px;flex-wrap:wrap}
.pills button{font-size:12px;padding:5px 9px;border:1px solid var(--rule-strong);border-radius:2px;color:var(--muted)}
.pills button[aria-pressed="true"]{border-color:var(--near);color:var(--ink);background:color-mix(in srgb,var(--near) 14%,transparent)}
/* ---------- settings ---------- */
.set{max-width:820px;padding:8px 24px 60px}
.set h1{font:700 34px/1 "Barlow Condensed";margin:22px 0 6px}
.set .intro{color:var(--muted);margin:0 0 18px;max-width:60ch}
.sec{display:grid;grid-template-columns:200px 1fr;gap:24px;padding:22px 0;border-top:1px solid var(--rule)}
.sec>*{align-self:start}
.sec .seg{justify-self:start}
.sec h2{font:700 20px "Barlow Condensed";margin:0;letter-spacing:.02em}
.sec .hint{font-size:12px;color:var(--faint);margin-top:4px}
.grow{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--rule)}
.grow:last-child{border-bottom:0}
.grow .sw{width:10px;height:10px;border-radius:2px;background:var(--g)}
.grow .nm{flex:1}
.grow.off .nm{color:var(--faint)}
.grow .ord{width:26px;height:26px;border-radius:3px;color:var(--faint);display:grid;place-items:center}
.grow .ord:hover{background:var(--raised);color:var(--ink)}
.chk{display:flex;gap:10px;align-items:flex-start;padding:6px 0;cursor:pointer}
.chk input{margin-top:3px;accent-color:var(--near)}
.chk small{display:block;color:var(--faint);font-size:12px}
.pointer{font-size:13px;color:var(--muted);border-left:2px solid var(--rule-strong);padding:4px 0 4px 12px;margin-top:4px}
/* ---------- archive ---------- */
.arc-sum{display:flex;gap:32px;padding:20px 24px;border-bottom:1px solid var(--rule);flex-wrap:wrap}
.arc-sum div{font-size:12px;color:var(--faint)}
.arc-sum b{display:block;font:700 30px/1.1 "Barlow Condensed";color:var(--ink)}
.filters{display:flex;gap:12px;padding:14px 24px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--rule-strong)}
.st{font-size:12px;font-weight:500;padding:1px 6px;border-radius:2px}
.st.c{color:var(--near);background:color-mix(in srgb,var(--near) 15%,transparent)}
.st.i{color:var(--muted);border:1px solid var(--rule-strong)}
/* ---------- timeline (stub) ---------- */
.tl{padding:20px 24px}
.tl-row{display:grid;grid-template-columns:120px 1fr;align-items:center;border-bottom:1px solid var(--rule);height:40px}
.tl-row .n{font-weight:600;font-size:13px;color:var(--g)}
.tl-track{position:relative;height:100%}
.tl-bar{position:absolute;top:8px;height:24px;border-radius:3px;background:color-mix(in srgb,var(--g) 24%,var(--panel));border-left:3px solid var(--g);font-size:12px;padding:3px 8px;white-space:nowrap;overflow:hidden}
.tl-now{position:absolute;top:0;bottom:0;width:1px;background:var(--critical)}
/* ---------- onboarding ---------- */
.ob{max-width:760px;margin:0 auto;padding:40px 24px 80px}
.ob .step{font-size:12px;color:var(--faint);margin-bottom:8px}
.ob h1{font:700 44px/1.02 "Barlow Condensed";margin:0 0 10px}
.ob .lede{color:var(--muted);max-width:54ch;margin:0 0 24px}
.gpick{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--rule)}
.gpick button{display:flex;align-items:center;gap:10px;padding:11px 12px;border-bottom:1px solid var(--rule);text-align:left;color:var(--muted)}
.gpick button:nth-child(odd){border-right:1px solid var(--rule)}
.gpick button .sw{width:12px;height:12px;border-radius:2px;border:1px solid var(--g)}
.gpick button[aria-pressed="true"]{color:var(--ink);background:color-mix(in srgb,var(--g) 9%,transparent)}
.gpick button[aria-pressed="true"] .sw{background:var(--g)}
.gpick button:hover{color:var(--ink)}
.ob-foot{display:flex;gap:12px;align-items:center;margin-top:28px;flex-wrap:wrap}
.ob-foot .cnt{color:var(--faint);font-size:13px;flex:1}
.ob-g{border-top:1px solid var(--rule-strong);margin-top:6px}
.ob-g .lg-h{padding-left:0;padding-right:0}
.ob-g .li{padding-left:0;padding-right:0}
/* ---------- detail sheet ---------- */
.sheet{position:fixed;z-index:31;left:50%;top:50%;transform:translate(-50%,-50%);width:min(480px,calc(100vw - 32px));background:var(--panel);border:1px solid var(--rule-strong);border-radius:6px;padding:22px 24px}
.sheet h2{font:700 26px/1.05 "Barlow Condensed";margin:4px 0 8px}
.sheet .acts{display:flex;gap:8px;margin-top:18px;flex-wrap:wrap}
/* ---------- why panel ---------- */
.why{position:fixed;top:40px;left:0;bottom:0;width:min(460px,100vw);z-index:40;background:#f6f2e6;color:#232323;border-right:2px dashed #b9b29a;overflow:auto;padding:22px 24px 40px;font:400 14px/1.55 system-ui,sans-serif}
.why h2{font:700 18px system-ui;margin:0 0 8px}
.why h3{font:700 14px system-ui;margin:20px 0 6px}
.why code,.why pre{font:12px/1.5 ui-monospace,Menlo,monospace;background:#ebe5d3;border-radius:3px}
.why code{padding:1px 4px}
.why pre{padding:10px;overflow:auto;white-space:pre}
.why ul{padding-left:18px;margin:6px 0}
.why .x{float:right;border:1px solid #c4bda4;border-radius:3px;padding:3px 8px;background:#fff}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--raised);border:1px solid var(--rule-strong);border-radius:4px;padding:10px 14px;z-index:45;font-size:13px;display:flex;gap:12px;box-shadow:0 8px 24px rgb(0 0 0 / .35)}
@media (max-width:860px){
.ev{grid-template-columns:1fr}
.rail{border-right:0}
.mcell,table.led .h-meter{display:none}
table.led .h-game,table.led td.gc{display:none}
.tcell .gsm{display:block;font-size:12px;font-weight:600;color:var(--g)}
table.rt{table-layout:auto}
table.rt td:first-child,table.rt td:nth-child(4){display:none}
nav.tabs button{white-space:nowrap}
.rt-top h1{width:100%}
table.rt td:nth-child(2){padding-left:24px}
.rtitle .gsm{display:block;font-size:12px;font-weight:600;color:var(--g)}
.prog{width:auto}
.amt .bar{display:none}
.amt .num{min-width:0}
.sec{grid-template-columns:1fr;gap:10px}
.gpick{grid-template-columns:1fr}
.gpick button:nth-child(odd){border-right:0}
header.top{gap:12px;padding:12px 16px 0;flex-wrap:wrap}
.mark{padding-bottom:4px}
nav.tabs{overflow-x:auto;width:100%}
nav.tabs button{padding:10px 10px 12px}
.big{font-size:52px}
.band-h .reset .v{font-size:26px}
.ob h1{font-size:34px}
}
@media (min-width:861px){ .gsm{display:none} }
@media (prefers-reduced-motion:no-preference){
.drawer{animation:slide .18s ease-out}
@keyframes slide{from{transform:translateX(24px);opacity:.6}}
}
</style>
</head>
<body>
<div id="chrome"></div>
<div id="app"></div>
<div id="overlay"></div>
<script>
// ---------- real content (from the feed and src/shared/routines.ts) ----------
const NOW = Date.parse("2026-10-16T12:00:00Z");
const GAMES = [{"id":"genshin","name":"Genshin Impact","short":"Genshin","hue":"#4EA8DE","chores":"Commissions, resin"},{"id":"hsr","name":"Honkai: Star Rail","short":"Star Rail","hue":"#7B8CFF","chores":"Daily training, Trailblaze Power"},{"id":"zzz","name":"Zenless Zone Zero","short":"ZZZ","hue":"#F2A03D","chores":"Daily missions, battery"},{"id":"wuwa","name":"Wuthering Waves","short":"Wuwa","hue":"#3DD6A0","chores":"Daily activity, waveplate"},{"id":"arknights","name":"Arknights","short":"Arknights","hue":"#9AA3B8","chores":"Daily missions, sanity"},{"id":"endfield","name":"Arknights: Endfield","short":"Endfield","hue":"#E8635A","chores":"Daily missions"},{"id":"nte","name":"Neverness to Everness","short":"NTE","hue":"#C77DFF","chores":"Daily tasks"},{"id":"nikki","name":"Infinity Nikki","short":"Nikki","hue":"#F27BB0","chores":"Daily tasks, Vital Energy"},{"id":"p5x","name":"Persona 5: The Phantom X","short":"P5X","hue":"#D62246","chores":"Daily missions, stamina"},{"id":"r1999","name":"Reverse: 1999","short":"R1999","hue":"#C9A227","chores":"Daily missions"},{"id":"ba","name":"Blue Archive","short":"Blue Archive","hue":"#3FCBDD","chores":"Daily missions, AP"},{"id":"fgo","name":"Fate/Grand Order","short":"FGO","hue":"#6F8BE0","chores":"Daily Quests, AP"},{"id":"gfl2","name":"Girls' Frontline 2: Exilium","short":"GFL2","hue":"#A9C23F","chores":"Daily missions"},{"id":"stellasora","name":"Stella Sora","short":"Stella Sora","hue":"#2E9E9E","chores":"Daily missions"},{"id":"czn","name":"Chaos Zero Nightmare","short":"CZN","hue":"#B84A9C","chores":"Daily missions"},{"id":"nikke","name":"Goddess of Victory: Nikke","short":"Nikke","hue":"#E4572E","chores":"Daily missions, outpost"},{"id":"uma","name":"Umamusume: Pretty Derby","short":"Umamusume","hue":"#6FBF44","chores":"Daily races, missions"},{"id":"hi3","name":"Honkai Impact 3rd","short":"Honkai 3rd","hue":"#8B5CF6","chores":"Daily missions, stamina"},{"id":"pgr","name":"Punishing: Gray Raven","short":"PGR","hue":"#CC292B","chores":"Daily missions, serum"}];
const G = Object.fromEntries(GAMES.map(g=>[g.id,g]));
const RAW_ROUTINES = [["routine:genshin:weekly-bosses","genshin","Weekly Bosses",{"unit":"week"},{"kind":"amount","target":3,"unit":"runs"}],["routine:genshin:battle-pass-exp","genshin","Battle Pass weekly EXP",{"unit":"week"},{"kind":"amount","target":10000,"unit":"EXP"}],["routine:genshin:bounties","genshin","Reputation Bounties",{"unit":"week"},{"kind":"amount","target":3,"unit":"bounties"}],["routine:genshin:requests","genshin","Reputation Requests",{"unit":"week"},{"kind":"amount","target":3,"unit":"requests"}],["routine:genshin:realm-depot","genshin","Realm Depot: Realm Treasures",{"unit":"week"},{"kind":"days","required":1}],["routine:genshin:paimons-bargains","genshin","Paimon's Bargains: Intertwined Fates",{"unit":"month"},{"kind":"amount","target":5,"unit":"fates"},"premium"],["routine:genshin:paimons-bargains-acquaint","genshin","Paimon's Bargains: Acquaint Fates",{"unit":"month"},{"kind":"amount","target":5,"unit":"fates"},"standard"],["routine:genshin:spiral-abyss","genshin","Spiral Abyss",{"unit":"month","startsOn":16},{"kind":"days","required":1}],["routine:genshin:imaginarium-theater","genshin","Imaginarium Theater",{"unit":"month"},{"kind":"days","required":1}],["routine:hsr:echo-of-war","hsr","Echo of War",{"unit":"week"},{"kind":"amount","target":3,"unit":"runs"}],["routine:hsr:nameless-honor-exp","hsr","Nameless Honor weekly EXP",{"unit":"week"},{"kind":"amount","target":8000,"unit":"EXP"}],["routine:hsr:embers-exchange","hsr","Embers Exchange: Star Rail Special Pass",{"unit":"month"},{"kind":"amount","target":5,"unit":"passes"},"premium"],["routine:hsr:embers-exchange-standard","hsr","Embers Exchange: Star Rail Pass",{"unit":"month"},{"kind":"amount","target":5,"unit":"passes"},"standard"],["routine:hsr:points","hsr","Divergent Universe & Currency Wars points",{"unit":"week"},{"kind":"amount","target":18000,"unit":"points"}],["routine:hsr:pure-fiction","hsr","Pure Fiction",{"unit":"cycle","days":42,"from":"2026-10-19"},{"kind":"days","required":1}],["routine:hsr:memory-of-chaos","hsr","Memory of Chaos",{"unit":"cycle","days":42,"from":"2026-11-02"},{"kind":"days","required":1}],["routine:hsr:apocalyptic-shadow","hsr","Apocalyptic Shadow",{"unit":"cycle","days":42,"from":"2026-11-16"},{"kind":"days","required":1}],["routine:zzz:ridu-weekly","zzz","Ridu Weekly",{"unit":"week"},{"kind":"amount","target":2100,"unit":"points"}],["routine:zzz:bounty-commissions","zzz","Bounty Commissions",{"unit":"week"},{"kind":"amount","target":8000,"unit":"points"}],["routine:zzz:notorious-hunt","zzz","Notorious Hunt",{"unit":"week"},{"kind":"amount","target":3,"unit":"runs"}],["routine:zzz:city-fund-exp","zzz","New Eridu City Fund weekly EXP",{"unit":"week"},{"kind":"amount","target":10000,"unit":"EXP"}],["routine:zzz:signal-shop-tapes","zzz","Signal Shop: Encrypted Master Tapes",{"unit":"month"},{"kind":"amount","target":5,"unit":"tapes"},"premium"],["routine:zzz:signal-shop-master-tapes","zzz","Signal Shop: Master Tapes",{"unit":"month"},{"kind":"amount","target":5,"unit":"tapes"},"standard"],["routine:zzz:signal-shop-boopons","zzz","Signal Shop: Boopons",{"unit":"month"},{"kind":"amount","target":5,"unit":"Boopons"},"premium"],["routine:zzz:shiyu-defense","zzz","Shiyu Defense",{"unit":"cycle","days":14,"from":"2026-10-16"},{"kind":"days","required":1}],["routine:zzz:deadly-assault","zzz","Deadly Assault",{"unit":"cycle","days":14,"from":"2026-10-23"},{"kind":"days","required":1}],["routine:endfield:weekly-routine","endfield","Weekly Routine",{"unit":"week"},{"kind":"amount","target":10,"unit":"points"}],["routine:endfield:focus-commissions","endfield","Etchspace Salvage: Focus Commissions",{"unit":"week"},{"kind":"amount","target":3,"unit":"commissions"}],["routine:wuwa:weekly-challenge","wuwa","Weekly Challenge",{"unit":"week"},{"kind":"amount","target":3,"unit":"claims"}],["routine:wuwa:pioneer-podcast-exp","wuwa","Pioneer Podcast weekly EXP",{"unit":"week"},{"kind":"amount","target":12000,"unit":"EXP"}],["routine:wuwa:weekly-activities","wuwa","Weekly Activities",{"unit":"week"},{"kind":"amount","target":6000,"unit":"points"}],["routine:wuwa:tower-of-adversity","wuwa","Tower of Adversity",{"unit":"cycle","days":28,"from":"2026-11-09"},{"kind":"days","required":1}],["routine:nikki:mira-journey-exp","nikki","Mira Journey weekly EXP",{"unit":"week"},{"kind":"amount","target":8000,"unit":"Journey EXP"}],["routine:nikki:realm-of-breakthrough","nikki","Realm of Breakthrough",{"unit":"week"},{"kind":"amount","target":2,"unit":"trials"}],["routine:nikki:resonance-revelation","nikki","Resonance Store: Revelation Crystals",{"unit":"month"},{"kind":"amount","target":5,"unit":"crystals"},"premium"],["routine:nikki:resonance-resonite","nikki","Resonance Store: Resonite Crystals",{"unit":"month"},{"kind":"amount","target":5,"unit":"crystals"},"standard"],["routine:nte:season-pass-exp","nte","Season Pass weekly EXP",{"unit":"week"},{"kind":"amount","target":12000,"unit":"EXP"}],["routine:nte:anomaly-pilgrimage","Line truncated
// Daily chores become routines too: one per game, from games.ts `dailyTasks`.
const BUILTINS = [
...GAMES.map(g=>({id:`routine:${g.id}:daily`,game:g.id,title:`Dailies: ${g.chores}`,period:{unit:"day"},measure:{kind:"days",required:1},src:"games.ts, the game's standing chores",builtin:true})),
...RAW_ROUTINES.map(([id,game,title,period,measure,pulls])=>({id,game,title,period,measure,pulls,src:"Community wiki, checked 14 Oct 2026",builtin:true})),
];
const EVENTS = [["genshin","Anniversary Memories Album","2026-09-28","2026-10-18"],["zzz","Clink, Clank, Pinball Knight!","2026-09-10","2026-10-19"],["zzz","Diary of an Orbie Parent","2026-09-30","2026-10-19"],["nte","Stamina Recharge","2026-10-05","2026-10-19"],["zzz","Data Bounty - Combat Simulation","2026-10-14","2026-10-19"],["zzz","All-New Program","2026-09-09","2026-10-20"],["genshin","Predictive Victory Dynamics","2026-10-12","2026-10-20"],["wuwa","Bountiful Crescendo","2026-10-15","2026-10-22"],["hsr","4.6 Planar Fissure","2026-10-12","2026-10-25"],["wuwa","Artisan's Search","2026-10-08","2026-10-26"],["wuwa","Waking Moon Fishing","2026-10-15","2026-11-01"],["genshin","Battle Pass - Moontrace","2026-09-23","2026-11-02"],["genshin","To Temper Thyself and Journey Far","2026-08-10","2026-11-02"],["hsr","Love, Ghosts & Robots","2026-09-28","2026-11-11"],["hsr","Astral Imagea","2026-09-28","2026-11-11"],["nte","Pukaland Travelogue","2026-09-30","2026-11-11"],["wuwa","3.7 Moonlit Path","2026-09-30","2026-11-11"],["nte","Coal Lumps Treasure","2026-10-08","2026-11-11"],["hsr","Free Gilgamesh or Archer","2026-07-24",null]].map(([game,title,s,e],i)=>({id:"e"+i,game,title,start:Date.parse(s+"T03:00:00Z"),end:e?Date.parse(e+"T03:00:00Z"):null}));
const OWN_EVENTS_SEED = [{id:"own1",game:"genshin",title:"Photo contest with the guild",start:Date.parse("2026-10-10T00:00:00Z"),end:Date.parse("2026-10-24T20:00:00Z"),own:true}];
const ARCHIVE_SEED = [
{game:"zzz",title:"Hollow Zero: Lost Void – Season 3",end:"12 Oct",status:"done"},
{game:"genshin",title:"Overflowing Mastery",end:"9 Oct",status:"done"},
{game:"wuwa",title:"Ascendant Aces",end:"running",status:"ignored"},
{game:"hsr",title:"Garden of Plenty",end:"6 Oct",status:"done"},
{game:"nte",title:"Neon Nights Photo Hunt",end:"4 Oct",status:"ignored"},
{game:"genshin",title:"Moontrace",end:"running",status:"ignored"},
{game:"zzz",title:"Hollow Zero: Lost Void – Season 2",end:"28 Sep",status:"done"},
];
// ---------- clock ----------
const DAY=864e5, H=36e5;
const RESET_UTC_HOUR=3; // Europe server, 04:00 local
function nextDaily(){let d=new Date(NOW);d.setUTCHours(RESET_UTC_HOUR,0,0,0);if(+d<=NOW)d=new Date(+d+DAY);return +d}
function nextWeekly(){let t=nextDaily();while(new Date(t).getUTCDay()!==1)t+=DAY;return t}
function nextMonthly(startsOn=1){const n=new Date(NOW);let d=Date.UTC(n.getUTCFullYear(),n.getUTCMonth(),startsOn,RESET_UTC_HOUR);if(d<=NOW)d=Date.UTC(n.getUTCFullYear(),n.getUTCMonth()+1,startsOn,RESET_UTC_HOUR);return d}
function nextCycle(p){const from=Date.parse(p.from+"T03:00:00Z"),len=p.days*DAY;let k=Math.floor((NOW-from)/len)+1;return from+k*len}
function resetOf(r){const p=r.period;return p.unit==="day"?nextDaily():p.unit==="week"?nextWeekly():p.unit==="month"?nextMonthly(p.startsOn||1):nextCycle(p)}
function dur(ms){if(ms<H)return Math.max(1,Math.round(ms/6e4))+"m";const d=Math.floor(ms/DAY),h=Math.floor(ms%DAY/H),m=Math.floor(ms%H/6e4);return d?`${d}d ${h}h`:`${h}h ${String(m).padStart(2,"0")}m`}
function heat(ms){return ms==null?"unk":ms<DAY?"critical":ms<3*DAY?"soon":ms<7*DAY?"near":"calm"}
const HEAT={calm:"var(--calm)",near:"var(--near)",soon:"var(--soon)",critical:"var(--critical)"};
const fmt=n=>n.toLocaleString("en-GB");
const dateStr=t=>new Date(t).toLocaleString("en-GB",{weekday:"short",day:"numeric",month:"short",hour:"2-digit",minute:"2-digit",timeZone:"UTC"})+" UTC";
const PERIOD_LABEL={day:"Daily",week:"Weekly",month:"Monthly",cycle:"Cycle"};
function periodWords(p){return p.unit==="day"?"Every day":p.unit==="week"?"Every Monday":p.unit==="month"?(p.startsOn&&p.startsOn!==1?`Every month on the ${p.startsOn}th`:"Every month on the 1st"):`Every ${p.days} days`}
// ---------- state ----------
const DEFAULT_GAMES=["genshin","hsr","zzz","wuwa","endfield","nte"];
const DEFAULT_FOLLOWED=["routine:genshin:daily","routine:hsr:daily","routine:zzz:daily","routine:wuwa:daily","routine:genshin:weekly-bosses","routine:genshin:battle-pass-exp","routine:genshin:spiral-abyss","routine:hsr:echo-of-war","routine:hsr:points","routine:hsr:pure-fiction","routine:zzz:ridu-weekly","routine:zzz:notorious-hunt","routine:zzz:shiyu-defense","routine:wuwa:weekly-activities","routine:wuwa:tower-of-adversity","routine:endfield:weekly-routine","routine:nte:beyond-the-rails","mytask:a1"];
const OWN_ROUTINES_SEED=[{id:"mytask:a1",game:"endfield",title:"Protocol Space weekly clear",period:{unit:"week"},measure:{kind:"days",required:1},src:"Yours",own:true}];
const PROGRESS_SEED={"routine:genshin:daily":1,"routine:hsr:daily":1,"routine:genshin:weekly-bosses":2,"routine:genshin:battle-pass-exp":10000,"routine:hsr:echo-of-war":1,"routine:hsr:points":11200,"routine:zzz:ridu-weekly":1900,"routine:zzz:notorious-hunt":3,"routine:wuwa:weekly-activities":2400,"routine:endfield:weekly-routine":6};
function fresh(){return{
tab:"events", group:"horizon", drawer:false, ob:null, returning:false, why:false,
games:[...DEFAULT_GAMES], followed:new Set(DEFAULT_FOLLOWED), own:[...OWN_ROUTINES_SEED],
ownEvents:[...OWN_EVENTS_SEED], progress:{...PROGRESS_SEED}, status:{}, archive:[...ARCHIVE_SEED],
q:"", editing:null, menu:null, sheet:null, region:"europe", theme:"dark", showUpcoming:false, showCompleted:true,
arcGame:"all", arcStatus:"all", toast:null, standardPulls:false, firstVisitRoutines:false,
form:{title:"",game:"genshin",unit:"week",days:14,kind:"days",target:1}
}}
let S=fresh();
const all=()=>[...BUILTINS,...S.own];
const byId=id=>all().find(r=>r.id===id);
const followedList=()=>[...S.followed].map(byId).filter(r=>r&&S.games.includes(r.game));
function isMet(r){const v=S.progress[r.id]||0;return r.measure.kind==="days"?v>=1:v>=r.measure.target}
function counts(){const f=followedList();const today=f.filter(r=>r.period.unit==="day");return{total:f.length,left:f.filter(r=>!isMet(r)).length,todayLeft:today.filter(r=>!isMet(r)).length,today:today.length,weekLeft:f.filter(r=>r.period.unit==="week"&&!isMet(r)).length}}
// ---------- render helpers ----------
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const gv=g=>`--g:${G[g]?.hue||"#9aa3b8"}`;
const ICON={
gear:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>',
dots:'<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>',
x:'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>',
check:'<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7"/></svg>',
up:'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 15l6-6 6 6"/></svg>',
down:'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>',
};
function meter(ev,n=24){if(ev.end==null)return `<div class="meter unknown">${"<i></i>".repeat(n)}</div>`;const frac=Math.max(0,(ev.end-NOW)/(ev.end-ev.start));const lit=Math.max(1,Math.round(frac*n));const h=HEAT[heat(ev.end-NOW)];return `<div class="meter" style="--h:${h}">${Array.from({length:n},(_,i)=>`<i class="${i<lit?"on":""}"></i>`).join("")}</div>`}
// ---------- chrome ----------
const JUMPS=[["main","Main"],["add","Add & choose systems"],["settings","Settings"],["onboard","Onboarding (new user)"],["returning","Returning user"],["archive","Archive"]];
function currentJump(){if(S.ob)return"onboard";if(S.drawer)return"add";if(S.returning&&S.tab==="events")return"returning";if(S.tab==="settings")return"settings";if(S.tab==="archive")return"archive";if(S.tab==="events")return"main";return""}
function renderChrome(){const c=currentJump();document.getElementById("chrome").innerHTML=`<b>B · Routines get their own home</b>${JUMPS.map(([k,l])=>`<button data-jump="${k}" aria-pressed="${c===k}">${l}</button>`).join("")}<span class="sp"></span><button data-act="why" aria-pressed="${S.why}">Why / foundation</button><button data-act="reset">Reset demo</button>`}
function jump(k){S.drawer=false;S.ob=null;S.returning=false;S.sheet=null;S.menu=null;
if(k==="main")S.tab="events";
if(k==="add"){S.tab="routines";S.drawer=true}
if(k==="settings")S.tab="settings";
if(k==="archive")S.tab="archive";
if(k==="returning"){S.tab="events";S.returning=true}
if(k==="onboard"){S.ob={step:1,games:[],picks:new Set()}}
}
// ---------- screens ----------
function tabs(){const c=counts();const t=[["events","Events",[...EVENTS,...S.ownEvents].filter(e=>S.games.includes(e.game)&&!S.status[e.id]).length],["timeline","Timeline",""],["routines","Routines",c.total?`${c.left} left`:""],["archive","Archive",S.archive.length]];
return `<header class="top"><div class="mark">EVENT<span>CLOCK</span></div><nav class="tabs" aria-label="Sections">${t.map(([k,l,n])=>`<button data-tab="${k}" ${S.tab===k?'aria-current="page"':""}>${l}${n!==""?` <span class="n">${n}</span>`:""}</button>`).join("")}<button class="gear" data-tab="settings" ${S.tab==="settings"?'aria-current="page"':""}>${ICON.gear}Settings</button></nav></header>`}
function eventsScreen(){
const evs=[...EVENTS,...S.ownEvents].filter(e=>S.games.includes(e.game)).sort((a,b)=>(a.end??9e15)-(b.end??9e15));
const live=evs.filter(e=>S.status[e.id]!=="ignored"&&(S.showCompleted||S.status[e.id]!=="done"));
const next=live.find(e=>S.status[e.id]!=="done"&&e.end);const then=live.filter(e=>e!==next&&S.status[e.id]!=="done").slice(0,3);
const c=counts();
const strip=c.total?`<button class="rstrip" data-tab="routines"><div class="row1"><span class="lbl">Routines</span><span class="go">Open Routines</span></div>
<div class="nums"><div><b class="${c.todayLeft?"due":""}">${c.todayLeft}/${c.today}</b>left today</div><div><b>${c.weekLeft}</b>left this week</div><div><b>${dur(nextDaily()-NOW)}</b>to daily reset</div></div></button>`
:`<button class="rstrip empty" data-tab="routines"><div class="row1"><span class="lbl">Routines</span><span class="go">Browse the library</span></div><p>Follow dailies, weeklies and endgame cycles. They live on their own tab and stay out of this list.</p></button>`;
const rail=`<aside class="rail">${next?`<section class="head"><p class="legend">Next to expire</p><h2>${esc(next.title)}</h2><div class="game" style="color:${G[next.game].hue}">${G[next.game].name}${next.own?" · yours":""}</div>
<div class="big cond" style="color:${HEAT[heat(next.end-NOW)]}">${dur(next.end-NOW)}<small>left</small></div><div class="exact">Ends ${dateStr(next.end)}</div>${meter(next)}
<ul class="then">${then.map(e=>`<li style="${gv(e.game)}"><span class="dot"></span><span class="t">${esc(e.title)}</span><span class="r" style="color:${e.end?HEAT[heat(e.end-NOW)]:"var(--faint)"}">${e.end?dur(e.end-NOW):"no end"}</span></li>`).join("")}</ul></section>`:""}
${strip}
<section class="mine"><h3>Your own events<button data-act="newevent">Add an event</button></h3><p>${S.ownEvents.length?`${S.ownEvents.length} of yours, mixed into the list and tagged “yours”.`:"None yet."} Built-in routines never show up here.</p></section></aside>`;
const notice=S.returning?`<div class="notice"><p><strong>Routines moved to their own tab.</strong> The ${c.total} you followed came with you, and nothing you ticked was lost. This list now holds events only.</p><button class="btn solid" data-act="takeme">Take me there</button><button class="btn link" data-act="dismiss">Got it</button></div>`:"";
const rows=live.map(e=>{const ms=e.end?e.end-NOW:null;const h=heat(ms);const st=S.status[e.id];return `<tr data-ev="${e.id}" class="${st==="done"?"done":""}" style="${gv(e.game)}"><td class="gc"><span class="gcell">${G[e.game].short}</span></td><td class="tcell"><span class="gsm">${G[e.game].short}</span>${esc(e.title)}${e.own?'<span class="tag own">yours</span>':""}</td><td class="mcell">${meter(e,30)}</td><td class="cd ${h}">${ms==null?"End unknown":dur(ms)}</td></tr>`}).join("");
return `<div class="ev">${rail}<section class="main">${notice}<div class="ledger-h"><div><h2>Running now</h2><div class="sub">${live.length} events in ${S.games.length} games · sorted by what ends first</div></div><div class="seg"><button aria-pressed="true">Ending soonest</button><button aria-pressed="false">Doing first</button></div></div>
<table class="led"><thead><tr><th class="h-game">Game</th><th>Event</th><th class="h-meter">Time left</th><th style="text-align:right">Ends in</th></tr></thead><tbody>${rows}</tbody></table>
<div class="more">Completed and ignored events move to the Archive tab.</div></section></div>`;
}
function routineRow(r){
const met=isMet(r);const v=S.progress[r.id]||0;let prog;
if(r.measure.kind==="days"){prog=`<button class="tick" data-tick="${r.id}" aria-pressed="${met}"><span class="box">${ICON.check}</span>${met?"Done":"To do"}</button>`}
else if(S.editing===r.id){prog=`<form class="amt" data-amtform="${r.id}"><input type="number" min="0" name="v" value="${v}" autofocus aria-label="Amount"><span class="num">/ ${fmt(r.measure.target)} ${esc(r.measure.unit||"")}</span><button class="btn" type="submit">Save</button></form>`}
else{const f=Math.min(1,v/r.measure.target);prog=`<button class="amt" data-amt="${r.id}" title="Enter an amount"><span class="bar ${f>=1?"full":""}"><i style="width:${f*100}%"></i></span><span class="num"><b>${fmt(v)}</b> / ${fmt(r.measure.target)} ${esc(r.measure.unit||"")}</span></button>`}
const cyc=r.period.unit==="cycle"?`<span class="cyc">${dur(resetOf(r)-NOW)} left in cycle</span>`:"";
const menu=S.menu===r.id?`<div class="menu" role="menu">${r.measure.kind==="amount"?`<button data-amt="${r.id}">Edit amount</button>`:""}<button data-act="target" data-id="${r.id}">Change target</button>${r.builtin?`<button data-act="mine" data-id="${r.id}">Make it mine</button>`:`<button data-act="editown" data-id="${r.id}">Edit</button>`}<button class="danger" data-unfollow="${r.id}">${r.own?"Delete":"Unfollow"}</button></div>`:"";
return `<tr class="${met?"met":""}" style="${gv(r.game)}"><td><span class="gcell">${G[r.game].short}</span></td><td class="rtitle"><span class="gsm">${G[r.game].short}</span>${esc(r.title)}${r.own?'<span class="tag own" style="font-size:11px;margin-left:8px;border:1px solid;border-radius:2px;padding:0 5px">yours</span>':""}${S.group==="game"?`<span class="src">${PERIOD_LABEL[r.period.unit]} · resets in ${dur(resetOf(r)-NOW)}</span>`:""}</td><td class="prog">${prog}</td><td>${cyc}</td><td><button class="kebab" data-menu="${r.id}" aria-label="More for ${esc(r.title)}" aria-expanded="${S.menu===r.id}">${ICON.dots}${menu}</button></td></tr>`;
}
function routinesScreen(){
const f=followedList();const c=counts();
const top=`<div class="rt-top"><h1>Routines</h1><div class="sum">${f.length?`${c.left} of ${c.total} still to do across ${new Set(f.map(r=>r.game)).size} games. Each one rolls on its own game's reset.`:"Things that reset: dailies, weeklies, monthlies and endgame cycles."}</div>${f.length?`<div class="seg"><button data-group="horizon" aria-pressed="${S.group==="horizon"}">By reset</button><button data-group="game" aria-pressed="${S.group==="game"}">By game</button></div>`:""}<button class="btn solid" data-act="library">Browse the library</button></div>`;
if(!f.length)return top+`<div class="empty-state"><h2>You're not following any routines yet</h2><p>Pick the dailies, weekly bosses, shop restocks and endgame modes you actually do. Nothing is on until you choose it, and your events list stays clean either way.</p><button class="btn solid" data-act="library">Browse the library</button> <button class="btn link" data-act="library-create">Create your own</button></div>`;
const order=r=>S.games.indexOf(r.game);
if(S.group==="game"){
return top+S.games.filter(g=>f.some(r=>r.game===g)).map(g=>{const rs=f.filter(r=>r.game===g);const left=rs.filter(r=>!isMet(r)).length;return `<section class="band"><div class="band-h" style="${gv(g)}"><div><h2 style="color:var(--g)">${G[g].name}</h2><div class="meta">${left} of ${rs.length} to do</div></div><div class="reset"><div class="k">Daily reset</div><div class="v cond">${dur(nextDaily()-NOW)}</div></div></div><table class="rt"><tbody>${rs.sort((a,b)=>resetOf(a)-resetOf(b)).map(routineRow).join("")}</tbody></table></section>`}).join("");
}
const bands=[["day","Today","Daily chores"],["week","This week","Rolls every Monday"],["month","This month","Shop restocks and monthly modes"],["cycle","Cycles","Endgame modes on their own clocks"]];
return top+bands.map(([u,name,meta])=>{const rs=f.filter(r=>r.period.unit===u).sort((a,b)=>u==="cycle"?resetOf(a)-resetOf(b):order(a)-order(b));const left=rs.filter(r=>!isMet(r)).length;
const nxt=u==="day"?nextDaily():u==="week"?nextWeekly():u==="month"?nextMonthly():rs.length?Math.min(...rs.map(resetOf)):null;
const left_ms=nxt?nxt-NOW:0;const bandCol=!rs.length||!left?"var(--faint)":left_ms<3*H?"var(--critical)":left_ms<DAY&&u!=="day"?"var(--soon)":"var(--ink)";
return `<section class="band"><div class="band-h"><div><h2>${name}</h2><div class="meta">${meta} · ${rs.length?`${left} of ${rs.length} to do`:"none followed"}</div></div><div class="reset"><div class="k">${u==="cycle"?"Next cycle ends":"Resets in"}</div><div class="v cond" style="color:${bandCol}">${nxt?dur(nxt-NOW):"—"}</div></div></div>${rs.length?`<table class="rt"><tbody>${rs.map(routineRow).join("")}</tbody></table>`:`<div class="empty-band">Nothing here. <button class="btn link" data-act="library">Add from the library</button></div>`}</section>`}).join("");
}
function libraryDrawer(){
const q=S.q.trim().toLowerCase();
const match=r=>!q||r.title.toLowerCase().includes(q)||G[r.game].name.toLowerCase().includes(q);
const vis=r=>S.standardPulls||r.pulls!=="standard";
const group=g=>{const rs=BUILTINS.filter(r=>r.game===g&&match(r)&&vis(r));if(!rs.length)return"";const n=rs.filter(r=>S.followed.has(r.id)).length;
const weekly=rs.filter(r=>r.period.unit==="week"),cyc=rs.filter(r=>r.period.unit==="cycle");
return `<section class="lg" style="${gv(g)}"><div class="lg-h"><span class="nm">${G[g].name}</span><span class="cnt">${n} of ${rs.length} followed</span></div>
<div class="bulk">${weekly.length?`<button data-bulk="${g}:week">${weekly.every(r=>S.followed.has(r.id))?"Unfollow":"Follow"} all weeklies</button>`:""}${cyc.length?`<button data-bulk="${g}:cycle">${cyc.every(r=>S.followed.has(r.id))?"Unfollow":"Follow"} all cycles</button>`:""}<button data-bulk="${g}:all">${n===rs.length?"Unfollow everything":"Follow everything"}</button></div>
${rs.map(r=>{const on=S.followed.has(r.id);return `<div class="li"><div><div class="t">${esc(r.title)}</div><div class="d">${periodWords(r.period)} · ${r.measure.kind==="amount"?`${fmt(r.measure.target)} ${esc(r.measure.unit||"")}`:"tick when done"}${r.pulls==="standard"?" · standard banner":""} · ${esc(r.src)}</div></div><button class="fol" data-follow="${r.id}" aria-pressed="${on}">${on?"Following":"Follow"}</button></div>`}).join("")}</section>`};
const others=GAMES.map(g=>g.id).filter(g=>!S.games.includes(g));
const F=S.form;
return `<div class="scrim" data-act="closedrawer"></div><aside class="drawer" role="dialog" aria-label="Routine library"><div class="drawer-h"><div class="r1"><h2>Library</h2><button class="close" data-act="closedrawer" aria-label="Close">${ICON.x}</button></div><p>Built-in routines for your games, each from a dated source. Follow what you do; it appears on the Routines tab straight away.</p><input type="search" id="libq" placeholder="Search routines or games" value="${esc(S.q)}" aria-label="Search the library"></div>
<div class="drawer-b">${S.games.map(group).join("")}
${others.length?`<details class="others"><summary>${others.length} games you don't follow</summary>${others.map(group).join("")}</details>`:""}
<section class="create" id="create"><h3>Create your own routine</h3><p>For anything the library doesn't have. It sits beside the built-ins and is marked as yours.</p>
<form class="form" id="createform"><label class="full">What is it<input name="title" required placeholder="Protocol Space weekly clear" value="${esc(F.title)}"></label>
<label>Game<select name="game">${S.games.map(g=>`<option value="${g}" ${F.game===g?"selected":""}>${G[g].name}</option>`).join("")}</select></label>
<label>Repeats<span class="pills">${[["day","Daily"],["week","Weekly"],["month","Monthly"],["cycle","Every N days"]].map(([u,l])=>`<button type="button" data-fu="${u}" aria-pressed="${F.unit===u}">${l}</button>`).join("")}</span></label>
${F.unit==="cycle"?`<label>Days in a cycle<input name="days" type="number" min="2" max="366" value="${F.days}"></label>`:""}
<label>Done when<span class="pills"><button type="button" data-fk="days" aria-pressed="${F.kind==="days"}">I tick it</button><button type="button" data-fk="amount" aria-pressed="${F.kind==="amount"}">I reach a number</button></span></label>
${F.kind==="amount"?`<label>Target<input name="target" type="number" min="1" value="${F.target}"></label>`:""}
<div class="full"><button class="btn solid" type="submit">Create and follow</button></div></form></section></div></aside>`;
}
function settingsScreen(){
const rows=[...S.games,...GAMES.map(g=>g.id).filter(g=>!S.games.includes(g))];
return `<div class="set"><h1>Settings</h1><p class="intro">Everything here is stored in this browser only. Routines aren't set here: you follow and unfollow them on the Routines tab, where they live.</p>
<section class="sec"><div><h2>Games</h2><div class="hint">${S.games.length} of ${GAMES.length} on. The order is used on every tab.</div></div><div>${rows.map((g,i)=>{const on=S.games.includes(g);return `<div class="grow ${on?"":"off"}" style="${gv(g)}"><input type="checkbox" data-game="${g}" ${on?"checked":""} aria-label="Follow ${G[g].name}" style="accent-color:${G[g].hue}"><span class="sw"></span><span class="nm">${G[g].name}</span>${on?`<button class="ord" data-ord="${g}:-1" aria-label="Move up">${ICON.up}</button><button class="ord" data-ord="${g}:1" aria-label="Move down">${ICON.down}</button>`:""}</div>`}).join("")}</div></section>
<section class="sec"><div><h2>Server region</h2><div class="hint">Sets every game's daily reset.</div></div><div class="seg">${["asia","america","europe"].map(r=>`<button data-region="${r}" aria-pressed="${S.region===r}">${r[0].toUpperCase()+r.slice(1)}</button>`).join("")}</div></section>
<section class="sec"><div><h2>Appearance</h2></div><div class="seg">${["dark","light"].map(t=>`<button data-theme="${t}" aria-pressed="${S.theme===t}">${t[0].toUpperCase()+t.slice(1)}</button>`).join("")}</div></section>
<section class="sec"><div><h2>Events list</h2><div class="hint">What the Events tab shows.</div></div><div><label class="chk"><input type="checkbox" data-pref="showUpcoming" ${S.showUpcoming?"checked":""}><span>Show events that haven't started<small>Off keeps the list to what you can play now.</small></span></label><label class="chk"><input type="checkbox" data-pref="showCompleted" ${S.showCompleted?"checked":""}><span>Keep completed events in the list, struck through<small>Off moves them straight to the Archive.</small></span></label><label class="chk"><input type="checkbox" data-pref="standardPulls" ${S.standardPulls?"checked":""}><span>Offer standard-banner pulls in the library<small>Premium pulls are always offered.</small></span></label></div></section>
<section class="sec"><div><h2>Backup</h2><div class="hint">Moves your ticks, routines and settings to another browser.</div></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn">Export to a file</button><button class="btn">Import a file</button></div></section>
<section class="sec"><div><h2>Routines</h2></div><div class="pointer">You follow ${counts().total}. Manage them on the Routines tab. <button class="btn link" data-tab="routines">Open Routines</button></div></section></div>`;
}
function archiveScreen(){
const list=S.archive.filter(a=>(S.arcGame==="all"||a.game===S.arcGame)&&(S.arcStatus==="all"||a.status===S.arcStatus));
const done=S.archive.filter(a=>a.status==="done").length,ign=S.archive.length-done;
const games=[...new Set(S.archive.map(a=>a.game))];
return `<div class="rt-top"><h1>Archive</h1><div class="sum">Events you finished or chose to ignore. Restoring one puts it back in the Events list if it's still running.</div></div>
<div class="arc-sum"><div><b>${done}</b>completed</div><div><b>${ign}</b>ignored</div><div><b>${games.length}</b>games</div></div>
<div class="filters"><select id="arcgame" aria-label="Game"><option value="all">All games</option>${games.map(g=>`<option value="${g}" ${S.arcGame===g?"selected":""}>${G[g].name}</option>`).join("")}</select><div class="seg">${[["all","All"],["done","Completed"],["ignored","Ignored"]].map(([k,l])=>`<button data-arcst="${k}" aria-pressed="${S.arcStatus===k}">${l}</button>`).join("")}</div></div>
<table class="led"><thead><tr><th class="h-game">Game</th><th>Event</th><th>Ended</th><th>Status</th><th></th></tr></thead><tbody>${list.map(a=>`<tr style="${gv(a.game)};cursor:default"><td class="gc"><span class="gcell">${G[a.game].short}</span></td><td class="tcell"><span class="gsm">${G[a.game].short}</span>${esc(a.title)}</td><td style="color:var(--muted);white-space:nowrap">${a.end==="running"?"Still running":a.end}</td><td><span class="st ${a.status==="done"?"c":"i"}">${a.status==="done"?"Completed":"Ignored"}</span></td><td style="text-align:right"><button class="btn" data-restore="${esc(a.title)}">Restore</button></td></tr>`).join("")||`<tr><td colspan="5" style="color:var(--faint)">Nothing matches.</td></tr>`}</tbody></table>`;
}
function timelineScreen(){
const span=30*DAY,start=NOW-7*DAY;const x=t=>Math.max(0,Math.min(100,(t-start)/span*100));
return `<div class="rt-top"><h1>Timeline</h1><div class="sum">Unchanged in this mockup. Events only; routines stay on their own tab.</div></div><div class="tl">${S.games.map(g=>{const es=EVENTS.filter(e=>e.game===g).slice(0,1);return `<div class="tl-row" style="${gv(g)}"><span class="n">${G[g].short}</span><div class="tl-track">${es.map(e=>`<div class="tl-bar" style="left:${x(e.start)}%;width:${x(e.end??start+span)-x(e.start)}%">${esc(e.title)}</div>`).join("")}<div class="tl-now" style="left:${x(NOW)}%"></div></div></div>`}).join("")}</div>`;
}
function onboarding(){
const o=S.ob;
if(o.step===1)return `<div class="ob"><div class="step">Step 1 of 2</div><h1>Which games do you play?</h1><p class="lede">You'll only see events for these. You can change it any time in Settings.</p>
<div class="gpick">${GAMES.map(g=>`<button data-obg="${g.id}" aria-pressed="${o.games.includes(g.id)}" style="${gv(g.id)}"><span class="sw"></span>${g.name}</button>`).join("")}</div>
<div class="ob-foot"><span class="cnt">${o.games.length?`${o.games.length} chosen`:"Pick at least one"}</span><button class="btn link" data-act="obskip">Show everything instead</button><button class="btn solid" data-act="obnext" ${o.games.length?"":"disabled style='opacity:.4'"}>Continue</button></div></div>`;
const usual=()=>BUILTINS.filter(r=>o.games.includes(r.game)&&(r.period.unit==="week"||r.period.unit==="day")&&r.pulls!=="standard");
return `<div class="ob"><div class="step">Step 2 of 2 · optional</div><h1>Do you also want to track resets?</h1><p class="lede">Dailies, weekly bosses, shop restocks and endgame cycles get their own Routines tab, so your events list stays short. Nothing is followed unless you tick it here or later.</p>
<div class="ob-foot" style="margin:0 0 8px"><span class="cnt">${o.picks.size} picked</span><button class="btn" data-act="obusual">Add the usual dailies and weeklies</button>${o.picks.size?`<button class="btn link" data-act="obclear">Clear</button>`:""}</div>
${o.games.map(g=>`<section class="ob-g" style="${gv(g)}"><div class="lg-h"><span class="nm">${G[g].name}</span></div>${BUILTINS.filter(r=>r.game===g&&r.pulls!=="standard").map(r=>`<div class="li"><div><div class="t">${esc(r.title)}</div><div class="d">${periodWords(r.period)}</div></div><button class="fol" data-obpick="${r.id}" aria-pressed="${o.picks.has(r.id)}">${o.picks.has(r.id)?"Following":"Follow"}</button></div>`).join("")}</section>`).join("")}
<div class="ob-foot"><button class="btn link" data-act="obback">Back</button><span class="cnt"></span><button class="btn solid" data-act="obdone">${o.picks.size?`Start with ${o.picks.size} routines`:"Skip, events only"}</button></div></div>`;
}
function sheet(){const e=[...EVENTS,...S.ownEvents].find(x=>x.id===S.sheet);if(!e)return"";const st=S.status[e.id];
return `<div class="scrim" data-act="closesheet"></div><div class="sheet" role="dialog" style="${gv(e.game)}"><p class="legend" style="color:var(--g)">${G[e.game].name}</p><h2>${esc(e.title)}</h2><div class="exact">${e.end?"Ends "+dateStr(e.end):"End date not announced"}</div>${meter(e)}
<div class="acts"><button class="btn solid" data-evst="${e.id}:done">${st==="done"?"Mark not done":"Mark done"}</button><button class="btn" data-evst="${e.id}:ignored">Ignore</button><button class="btn link" data-act="closesheet">Close</button></div></div>`}
function whyPanel(){return `<aside class="why"><button class="x" data-act="why">Close</button><h2>B · Routines get their own home</h2>
<p>Events and routines are different jobs. An event has an end date you're racing; a routine resets and comes back. Today both share the left rail, so a few followed weeklies crowd out the deadlines. B gives routines a tab of their own, ordered by when they reset, and makes every routine opt-in from one library. The Events list goes back to holding events only.</p>
<h3>The foundation</h3>
<p>One idea replaces about fifteen prefs: <b>a routine is anything that resets, and you follow it or you don't.</b> Daily chores, built-in weeklies, monthly shops, endgame cycles and your own tasks are all the same shape.</p>
<pre>prefs = {
games: LaneId[] // followed games, in order
followed: RoutineId[] // one subscription list
...region, theme, display
}
Routine = { id, game, title, period, measure,
source: builtin | yours }</pre>
<p>It retires <code>showChores</code>, <code>choresOn/Off</code>, <code>showRoutines</code>, <code>routineGames</code>, <code>routinesOff</code>, <code>routinesAsked</code>, <code>routinesHidden</code>, <code>showWeekly/Monthly/CycleTasks</code> and <code>hiddenGames</code>/<code>knownGames</code> (now just <code>games</code>). <code>standardPulls</code> stays, as a library filter only. The board, the rail counts and the tab badge are each a pure query: <code>followed ∩ games</code>, grouped by period. Ticks keep their existing <code>routine:</code>/<code>mytask:</code> keys, so a migration only has to compute the first <code>followed</code> list from the old switches.</p>
<h3>How it scales</h3>
<ul><li>A new game adds its rows to the library and changes nothing for anyone until they follow them. Nobody's screen fills up on its own.</li>
<li>A new kind of system, like a pass window or a rotating shop, is a new period unit or band. The follow model doesn't change.</li>
<li>“Make it mine” copies a built-in into your own routines and swaps the id in <code>followed</code>.</li></ul>
<h3>Trade-offs</h3>
<ul><li>A fourth tab is one more place to look. Someone who only does dailies has to leave the events list to tick them, though the rail strip softens that.</li>
<li>Opt-in means a new reader sees nothing until they choose, so step 2 of onboarding carries a lot of weight.</li>
<li>Per-game nuance, like “all of Genshin but none of NTE”, is only expressible as a list of followed items. That's fine for the library's bulk buttons, but there's no single per-game switch to flip.</li>
<li>The ledger look is denser. It's harder to scan on a phone, where the game column collapses into the title.</li></ul></aside>`}
function render(){
document.documentElement.dataset.theme=S.theme;
renderChrome();
const app=document.getElementById("app");
if(S.ob){app.innerHTML=`<div class="shell"><header class="top" style="border:0"><div class="mark">EVENT<span>CLOCK</span></div></header>${onboarding()}</div>`}
else{const body=S.tab==="events"?eventsScreen():S.tab==="routines"?routinesScreen():S.tab==="settings"?settingsScreen():S.tab==="archive"?archiveScreen():timelineScreen();app.innerHTML=`<div class="shell">${tabs()}${body}</div>`}
document.getElementById("overlay").innerHTML=(S.drawer?libraryDrawer():"")+(S.sheet?sheet():"")+(S.why?whyPanel():"")+(S.toast?`<div class="toast" role="status">${S.toast}</div>`:"");
const a=document.querySelector("[autofocus]");if(a){a.focus();a.select?.()}
}
let toastT;function toast(m){S.toast=m;clearTimeout(toastT);toastT=setTimeout(()=>{S.toast=null;render()},2600)}
// ---------- events ----------
document.addEventListener("click",ev=>{
const t=ev.target.closest("button,[data-ev],[data-act]");if(!t)return;
const d=t.dataset;
if(d.jump)jump(d.jump);
else if(d.tab){S.tab=d.tab;S.drawer=false;S.menu=null;if(S.tab!=="events")S.returning=false}
else if(d.group)S.group=d.group;
else if(d.tick){S.progress[d.tick]=isMet(byId(d.tick))?0:1}
else if(d.amt){S.editing=d.amt;S.menu=null}
else if(d.menu!==undefined&&t.classList.contains("kebab")&&!ev.target.closest(".menu")){S.menu=S.menu===d.menu?null:d.menu}
else if(d.unfollow){const r=byId(d.unfollow);S.followed.delete(d.unfollow);if(r.own)S.own=S.own.filter(x=>x.id!==r.id);S.menu=null;toast(`${r.own?"Deleted":"Unfollowed"} ${esc(r.title)}. Find it again in the library.`)}
else if(d.follow){S.followed.has(d.follow)?S.followed.delete(d.follow):S.followed.add(d.follow)}
else if(d.bulk){const[g,u]=d.bulk.split(":");const rs=BUILTINS.filter(r=>r.game===g&&(u==="all"||r.period.unit===u)&&(S.standardPulls||r.pulls!=="standard"));const allOn=rs.every(r=>S.followed.has(r.id));rs.forEach(r=>allOn?S.followed.delete(r.id):S.followed.add(r.id));toast(`${allOn?"Unfollowed":"Followed"} ${rs.length} for ${G[g].short}`)}
else if(d.fu){S.form.unit=d.fu}
else if(d.fk){S.form.kind=d.fk}
else if(d.ev){S.sheet=d.ev}
else if(d.evst){const[id,st]=d.evst.split(":");const e=[...EVENTS,...S.ownEvents].find(x=>x.id===id);if(S.status[id]===st)delete S.status[id];else{S.status[id]=st;if(st==="ignored"||!S.showCompleted)S.archive.unshift({game:e.game,title:e.title,end:"running",status:st==="done"?"done":"ignored",ref:id})}S.sheet=null;if(st==="ignored")toast("Ignored. It's in the Archive if you want it back.")}
else if(d.restore){const a=S.archive.find(x=>x.title===d.restore);S.archive=S.archive.filter(x=>x!==a);if(a.ref)delete S.status[a.ref];toast(`Restored ${esc(a.title)}`)}
else if(d.arcst)S.arcStatus=d.arcst;
else if(d.region)S.region=d.region;
else if(d.theme)S.theme=d.theme;
else if(d.ord){const[g,dir]=d.ord.split(":");const i=S.games.indexOf(g),j=i+ +dir;if(j>=0&&j<S.games.length){[S.games[i],S.games[j]]=[S.games[j],S.games[i]]}}
else if(d.obg){const o=S.ob;o.games=o.games.includes(d.obg)?o.games.filter(x=>x!==d.obg):[...o.games,d.obg]}
else if(d.obpick){const p=S.ob.picks;p.has(d.obpick)?p.delete(d.obpick):p.add(d.obpick)}
else if(d.act){switch(d.act){
case"why":S.why=!S.why;break;
case"reset":S=fresh();break;
case"library":S.drawer=true;S.tab="routines";break;
case"library-create":S.drawer=true;setTimeout(()=>document.getElementById("create")?.scrollIntoView(),0);break;
case"closedrawer":S.drawer=false;break;
case"closesheet":S.sheet=null;break;
case"takeme":S.tab="routines";S.returning=false;break;
case"dismiss":S.returning=false;break;
case"mine":{const r=byId(d.id);const id="mytask:"+Math.random().toString(36).slice(2,8);S.own.push({...r,id,builtin:false,own:true,src:"Yours, copied from the built-in"});S.followed.delete(r.id);S.followed.add(id);S.progress[id]=S.progress[r.id];S.menu=null;toast(`${esc(r.title)} is now yours to edit`);break}
case"target":case"editown":S.menu=null;toast("Mockup: opens the routine editor");break;
case"newevent":toast("Mockup: opens Add an event (your own events stay under Events)");break;
case"obnext":if(S.ob.games.length)S.ob.step=2;break;
case"obback":S.ob.step=1;break;
case"obskip":S.ob.games=GAMES.map(g=>g.id);S.ob.step=2;break;
case"obusual":BUILTINS.filter(r=>S.ob.games.includes(r.game)&&(r.period.unit==="week"||r.period.unit==="day")&&r.pulls!=="standard").forEach(r=>S.ob.picks.add(r.id));break;
case"obclear":S.ob.picks.clear();break;
case"obdone":S.games=[...S.ob.games];S.followed=new Set(S.ob.picks);S.own=[];S.progress={};S.ownEvents=[];S.ob=null;S.tab="events";toast(S.followed.size?`Following ${S.followed.size} routines. They're on the Routines tab.`:"Events only. Add routines from the Routines tab any time.");break;
}}
else return;
render();
});
document.addEventListener("submit",ev=>{ev.preventDefault();const f=ev.target;
if(f.dataset.amtform){S.progress[f.dataset.amtform]=Math.max(0,+f.v.value||0);S.editing=null;render()}
if(f.id==="createform"){const fd=new FormData(f);const F=S.form;const title=String(fd.get("title")||"").trim();if(!title)return;const id="mytask:"+Math.random().toString(36).slice(2,8);
const period=F.unit==="cycle"?{unit:"cycle",days:+fd.get("days")||14,from:"2026-10-16"}:{unit:F.unit};
S.own.push({id,game:String(fd.get("game")),title,period,measure:F.kind==="amount"?{kind:"amount",target:+fd.get("target")||1}:{kind:"days",required:1},src:"Yours",own:true});S.followed.add(id);S.form={title:"",game:F.game,unit:"week",days:14,kind:"days",target:1};toast(`Created and following ${esc(title)}`);render()}
});
document.addEventListener("input",ev=>{
if(ev.target.id==="libq"){S.q=ev.target.value;const pos=ev.target.selectionStart;render();const i=document.getElementById("libq");i.focus();i.setSelectionRange(pos,pos)}
if(ev.target.closest("#createform")&&ev.target.name==="title")S.form.title=ev.target.value;
});
document.addEventListener("change",ev=>{const t=ev.target;
if(t.dataset.game){const g=t.dataset.game;S.games=t.checked?[...S.games,g]:S.games.filter(x=>x!==g);render()}
if(t.dataset.pref){S[t.dataset.pref]=t.checked;render()}
if(t.id==="arcgame"){S.arcGame=t.value;render()}
if(t.closest("#createform")&&t.name==="game")S.form.game=t.value;
});
document.addEventListener("keydown",ev=>{if(ev.key==="Escape"){S.drawer=false;S.sheet=null;S.menu=null;S.editing=null;render()}});
render();
</script>
</body>
</html>
@@ -0,0 +1,703 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>C · One due list</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Figtree:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root{
--ground:#1a1c24; --surface:#21242e; --soft:#292c38; --softer:#323543;
--ink:#eeeae3; --strong:#fffdf8; --muted:#aaa6b0; --faint:#77747f; --line:#2f3240;
--calm:#7d8499; --near:#7ba5f3; --soon:#e9b95b; --critical:#f76b7d;
--r-sm:8px; --r:14px; --r-lg:20px;
--display:"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
--body:"Figtree", ui-sans-serif, system-ui, sans-serif;
--bar:40px;
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--ground);color:var(--ink);font:400 15px/1.6 var(--body);-webkit-font-smoothing:antialiased;padding-top:var(--bar);min-height:100vh}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
input,select{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--near);outline-offset:2px;border-radius:6px}
.tnum{font-variant-numeric:tabular-nums}
.hidden{display:none!important}
/* ---------- prototype chrome (deliberately not the app) ---------- */
.mock{position:fixed;inset:0 0 auto 0;height:var(--bar);z-index:100;background:#d8d5cc;color:#22232a;display:flex;align-items:center;gap:6px;padding:0 12px;font:500 12.5px/1 ui-sans-serif,system-ui,sans-serif;overflow-x:auto;white-space:nowrap;border-bottom:1px solid #bdb9ae}
.mock b{font-weight:700;margin-right:8px}
.mock button{padding:6px 9px;border-radius:6px;color:#22232a}
.mock button:hover{background:#c6c2b7}
.mock button.on{background:#22232a;color:#d8d5cc}
.mock .sp{flex:1}
.why{position:fixed;top:var(--bar);right:0;bottom:0;width:min(440px,100vw);background:#e7e4dc;color:#1e1f25;z-index:99;padding:22px 24px 40px;overflow:auto;font:400 14px/1.6 ui-sans-serif,system-ui,sans-serif;box-shadow:-12px 0 30px rgb(0 0 0/.35)}
.why h3{font-size:15px;margin:20px 0 6px}
.why code{background:#d5d1c6;padding:1px 5px;border-radius:4px;font-size:12.5px}
.why pre{background:#d5d1c6;padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.5;overflow:auto}
.why ul{padding-left:18px;margin:6px 0}
/* ---------- app shell ---------- */
.shell{max-width:1360px;margin:0 auto;padding:0 28px 80px}
header.top{display:flex;align-items:center;gap:20px;padding:22px 0 18px}
.word{font:700 21px/1 var(--display);letter-spacing:-.01em}
.word span{color:var(--near)}
.tabs{display:flex;gap:2px;background:var(--surface);padding:4px;border-radius:12px}
.tabs button{padding:7px 14px;border-radius:9px;color:var(--muted);font-weight:500;font-size:14px}
.tabs button:hover{color:var(--ink)}
.tabs button[aria-selected=true]{background:var(--softer);color:var(--ink)}
.grow{flex:1}
.link{color:var(--muted);font-size:14px}
.link:hover{color:var(--ink)}
.addbtn{display:flex;align-items:center;gap:8px;background:var(--ink);color:var(--ground);padding:9px 14px 9px 12px;border-radius:12px;font-weight:600;font-size:14px}
.addbtn:hover{background:var(--strong)}
.addbtn kbd{font:600 11px/1 var(--body);background:rgb(26 28 36/.12);padding:3px 6px;border-radius:5px}
.layout{display:grid;grid-template-columns:400px 1fr;gap:28px;align-items:start}
.rail{display:flex;flex-direction:column;gap:16px;position:sticky;top:calc(var(--bar) + 16px)}
.sec{background:var(--surface);border-radius:var(--r-lg);padding:18px 20px 16px}
.sec-h{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
.sec-h h2{font:600 17px/1.3 var(--display);margin:0;letter-spacing:-.005em}
.sec-h .meta{color:var(--faint);font-size:13px}
.cust{margin-left:auto;display:flex;align-items:center;gap:6px;color:var(--faint);font-size:13px;padding:4px 8px;border-radius:8px}
.cust:hover,.cust[aria-expanded=true]{color:var(--ink);background:var(--soft)}
.cust svg{width:15px;height:15px}
/* due list */
.bucket{color:var(--faint);font-size:13px;margin:14px 0 4px}
.bucket:first-of-type{margin-top:4px}
.due{display:grid;grid-template-columns:10px 1fr auto;gap:2px 12px;align-items:center;padding:9px 10px;margin:0 -10px;border-radius:12px;width:calc(100% + 20px);text-align:left}
.due:hover{background:var(--soft)}
.dot{width:9px;height:9px;border-radius:50%}
.due .t{font-weight:500;line-height:1.35;min-width:0}
.due .t .sub{display:block;font-weight:400;color:var(--muted);font-size:13px;margin-top:1px}
.due .left{font-weight:600;font-size:14px;text-align:right}
.kind{display:inline-block;font-size:11.5px;font-weight:500;color:var(--muted);background:var(--soft);padding:1px 7px;border-radius:999px;margin-left:6px;vertical-align:1px}
.due:hover .kind{background:var(--softer)}
.kind.yours{color:var(--soon);background:rgb(233 185 91/.13)}
.due.done .t{color:var(--faint);text-decoration:line-through;text-decoration-color:var(--faint)}
.due.done .left{color:var(--faint)}
.fill{grid-column:2/4;height:3px;border-radius:2px;background:var(--softer);overflow:hidden;margin-top:5px}
.fill i{display:block;height:100%;border-radius:2px}
.more{color:var(--muted);font-size:14px;margin-top:8px;padding:4px 0}
.more:hover{color:var(--ink)}
.empty{color:var(--muted);font-size:14px;padding:6px 0 4px}
.empty button{color:var(--near);text-decoration:underline;text-underline-offset:3px}
.editor{grid-column:1/4;display:flex;gap:8px;align-items:center;padding:8px 0 4px 22px;font-size:13.5px;color:var(--muted);flex-wrap:wrap}
.editor input{width:96px;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
.pill{padding:6px 12px;border-radius:999px;background:var(--softer);font-size:13px;font-weight:500}
.pill:hover{background:#3c3f4f}
.pill.primary{background:var(--near);color:#0f1420}
/* chips */
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{display:inline-flex;align-items:center;gap:7px;padding:6px 12px 6px 9px;border-radius:999px;background:var(--soft);font-size:13.5px;font-weight:500;color:var(--muted)}
.chip:hover{color:var(--ink)}
.chip .ck{width:16px;height:16px;border-radius:50%;border:1.5px solid var(--h);display:grid;place-items:center}
.chip[aria-pressed=true]{background:color-mix(in srgb,var(--h) 16%,var(--surface));color:var(--ink)}
.chip[aria-pressed=true] .ck{background:var(--h)}
.chip[aria-pressed=true] .ck svg{opacity:1}
.chip .ck svg{width:10px;height:10px;opacity:0;color:var(--ground)}
.addsec{border:1.5px dashed var(--line);border-radius:var(--r-lg);padding:13px 18px;color:var(--muted);font-size:14px;text-align:left;display:flex;gap:8px;align-items:center}
.addsec:hover{color:var(--ink);border-color:var(--faint)}
/* headline */
.head{background:var(--surface);border-radius:var(--r-lg);padding:26px 30px 24px;position:relative;overflow:hidden;margin-bottom:16px}
.head .lbl{color:var(--muted);font-size:14px}
.head h1{font:600 34px/1.12 var(--display);letter-spacing:-.02em;margin:6px 0 4px;max-width:22ch}
.head .game{font-weight:600;font-size:15px}
.head .row{display:flex;align-items:flex-end;gap:18px;margin:18px 0 14px;flex-wrap:wrap}
.big{font:700 54px/1 var(--body);letter-spacing:-.03em}
.head .when{color:var(--muted);font-size:14px;padding-bottom:6px}
.meter{display:flex;gap:3px}
.meter i{flex:1;height:10px;border-radius:3px;background:var(--softer)}
.meter i.on{background:var(--c)}
.meter.sm i{height:7px;border-radius:2px}
/* running list */
.runrow{display:grid;grid-template-columns:4px 1fr auto;gap:4px 16px;padding:16px 14px;margin:0 -14px;border-radius:14px;width:calc(100% + 28px);text-align:left}
.runrow:hover{background:var(--soft)}
.runrow .rail3{grid-row:1/4;border-radius:3px;background:var(--h);opacity:.7}
.runrow .gname{font-size:13px;font-weight:600}
.runrow .title{font-size:16px;font-weight:500;line-height:1.35}
.runrow .left{font-weight:600;font-size:15px;align-self:start;text-align:right}
.runrow .range{color:var(--faint);font-size:13px;margin-top:6px}
.runrow .meter{grid-column:2/4;margin-top:8px}
.rowacts{grid-column:2/4;display:flex;gap:8px;margin-top:10px}
/* popover */
.pop{position:absolute;z-index:60;width:340px;background:var(--softer);border-radius:16px;padding:16px 18px 14px;box-shadow:0 18px 40px rgb(0 0 0/.45)}
.pop h4{font:600 13px/1.3 var(--body);color:var(--muted);margin:14px 0 8px}
.pop h4:first-child{margin-top:0}
.opts{display:flex;flex-wrap:wrap;gap:6px}
.opt{padding:5px 11px;border-radius:999px;background:var(--soft);font-size:13px;color:var(--muted)}
.opt:hover{color:var(--ink)}
.opt[aria-pressed=true]{background:rgb(123 165 243/.18);color:var(--ink);box-shadow:inset 0 0 0 1px rgb(123 165 243/.5)}
.opt.g[aria-pressed=true]{background:color-mix(in srgb,var(--h) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--h) 60%,transparent)}
.popfoot{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:16px;padding-top:12px;border-top:1px solid #3a3d4c;font-size:13.5px}
.popfoot button{color:var(--muted)}
.popfoot button:hover{color:var(--ink)}
.popfoot .addb{color:var(--near)}
/* palette */
.scrim{position:fixed;inset:var(--bar) 0 0 0;background:rgb(14 15 20/.66);z-index:70;display:flex;justify-content:center;align-items:flex-start;padding:10vh 16px 16px}
.pal{width:min(620px,100%);background:var(--surface);border-radius:20px;box-shadow:0 30px 60px rgb(0 0 0/.5);overflow:hidden}
.pal .q{display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--line)}
.pal .q svg{width:18px;height:18px;color:var(--faint)}
.pal .q input{flex:1;background:none;border:0;outline:0;font-size:17px}
.pal .res{max-height:56vh;overflow:auto;padding:8px 8px 12px}
.pal .grp{color:var(--faint);font-size:13px;padding:10px 12px 4px}
.pr{display:grid;grid-template-columns:10px 1fr auto;gap:2px 12px;align-items:center;padding:9px 12px;border-radius:12px;width:100%;text-align:left}
.pr:hover,.pr.sel{background:var(--soft)}
.pr .sub{grid-column:2;color:var(--muted);font-size:13px}
.pr .act{font-size:13px;font-weight:600;color:var(--near);grid-row:1/3;grid-column:3}
.pr .act.on{color:var(--muted);font-weight:500}
.pal .hint{padding:10px 18px;border-top:1px solid var(--line);color:var(--faint);font-size:12.5px}
/* toast */
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--softer);border-radius:14px;padding:11px 12px 11px 18px;display:flex;gap:16px;align-items:center;box-shadow:0 14px 34px rgb(0 0 0/.45);z-index:90;font-size:14px;max-width:calc(100vw - 32px)}
.toast button{color:var(--near);font-weight:600;padding:4px 8px}
/* settings & archive */
.page{max-width:820px}
.page h1{font:600 30px/1.2 var(--display);letter-spacing:-.02em;margin:8px 0 6px}
.page .lead{color:var(--muted);margin:0 0 22px;max-width:60ch}
.set{background:var(--surface);border-radius:var(--r-lg);padding:20px 22px;margin-bottom:14px}
.set h2{font:600 18px/1.3 var(--display);margin:0 0 4px}
.set p{color:var(--muted);margin:0 0 14px;font-size:14px;max-width:62ch}
.lrow{display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--line);font-size:14.5px}
.lrow:first-of-type{border-top:0}
.lrow .s{color:var(--muted);font-size:13px}
.lrow .r{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
.seg{display:inline-flex;background:var(--ground);border-radius:10px;padding:3px}
.seg button{padding:5px 12px;border-radius:8px;font-size:13px;color:var(--muted)}
.seg button[aria-pressed=true]{background:var(--softer);color:var(--ink)}
/* timeline (light sketch) */
.tl{background:var(--surface);border-radius:var(--r-lg);padding:18px 20px;overflow:auto}
.tlrow{display:grid;grid-template-columns:120px 1fr;gap:12px;align-items:center;min-height:34px}
.tltrack{position:relative;height:26px}
.tlbar{position:absolute;top:2px;height:22px;border-radius:6px;font-size:12px;padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.now{position:absolute;top:0;bottom:0;width:1px;background:var(--critical)}
/* onboarding */
.ob{position:fixed;inset:var(--bar) 0 0 0;background:var(--ground);z-index:80;overflow:auto}
.obin{max-width:720px;margin:0 auto;padding:56px 24px 60px}
.step{color:var(--faint);font-size:14px}
.ob h1{font:600 38px/1.12 var(--display);letter-spacing:-.025em;margin:8px 0 10px}
.ob .lead{color:var(--muted);font-size:16px;margin:0 0 28px;max-width:52ch}
.ggrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
.gopt{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:14px;background:var(--surface);text-align:left;font-weight:500;color:var(--muted)}
.gopt:hover{color:var(--ink)}
.gopt[aria-pressed=true]{background:color-mix(in srgb,var(--h) 16%,var(--surface));color:var(--ink);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--h) 70%,transparent)}
.choice{display:block;width:100%;text-align:left;background:var(--surface);border-radius:18px;padding:20px 22px;margin-bottom:10px}
.choice:hover{background:var(--soft)}
.choice[aria-pressed=true]{box-shadow:inset 0 0 0 2px var(--near);background:var(--soft)}
.choice strong{font:600 19px/1.3 var(--display);display:block}
.choice span{color:var(--muted);font-size:14.5px;display:block;margin-top:4px}
.choice .ex{color:var(--faint);font-size:13px;margin-top:10px}
.obfoot{display:flex;align-items:center;gap:16px;margin-top:28px}
.commit{background:var(--ink);color:var(--ground);font-weight:600;padding:12px 22px;border-radius:12px}
.commit:hover{background:var(--strong)}
.commit:disabled{background:var(--soft);color:var(--faint);cursor:not-allowed}
/* coach marks */
.spot{position:fixed;z-index:85;border-radius:14px;box-shadow:0 0 0 9999px rgb(10 11 16/.72);pointer-events:none;transition:all .22s ease-out}
.tip{position:fixed;z-index:86;width:320px;background:var(--ink);color:var(--ground);border-radius:16px;padding:16px 18px 14px;box-shadow:0 18px 40px rgb(0 0 0/.5)}
.tip .n{font-size:12.5px;color:#5d5a63}
.tip h3{font:600 17px/1.3 var(--display);margin:4px 0 6px}
.tip p{margin:0;font-size:14px;line-height:1.5;color:#3a3840}
.tip .f{display:flex;gap:10px;align-items:center;margin-top:14px}
.tip .f .skip{color:#5d5a63;font-size:13.5px}
.tip .f .nx{margin-left:auto;background:var(--ground);color:var(--ink);padding:7px 14px;border-radius:10px;font-weight:600;font-size:13.5px}
@media (max-width:960px){
.layout{grid-template-columns:1fr}
.rail{position:static}
}
@media (max-width:640px){
.shell{padding:0 16px 80px}
header.top{flex-wrap:wrap;gap:12px;padding:16px 0 14px}
header.top .tabs{order:3;width:100%}
header.top .tabs button{flex:1}
.head{padding:20px 18px}
.head h1{font-size:26px}
.big{font-size:42px}
.sec{padding:16px}
.pop{position:fixed!important;left:0!important;right:0;bottom:0;top:auto!important;width:auto;border-radius:20px 20px 0 0;max-height:80vh;overflow:auto}
.ob h1{font-size:30px}
.tip{left:16px!important;right:16px;width:auto}
.addbtn kbd{display:none}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="mock" id="mock"></div>
<div id="app"></div>
<div id="layer"></div>
<script>
/* ---------------- real content (from the feed and src/shared) ---------------- */
const GAMES=[{"id":"genshin","name":"Genshin Impact","short":"Genshin","hue":"#4EA8DE","chores":"Commissions, resin"},{"id":"hsr","name":"Honkai: Star Rail","short":"Star Rail","hue":"#7B8CFF","chores":"Daily training, Trailblaze Power"},{"id":"zzz","name":"Zenless Zone Zero","short":"ZZZ","hue":"#F2A03D","chores":"Daily missions, battery"},{"id":"wuwa","name":"Wuthering Waves","short":"Wuwa","hue":"#3DD6A0","chores":"Daily activity, waveplate"},{"id":"endfield","name":"Arknights: Endfield","short":"Endfield","hue":"#E8635A","chores":"Daily missions"},{"id":"nte","name":"Neverness to Everness","short":"NTE","hue":"#C77DFF","chores":"Daily tasks"},{"id":"arknights","name":"Arknights","short":"Arknights","hue":"#9AA3B8","chores":"Daily missions, sanity"},{"id":"nikki","name":"Infinity Nikki","short":"Nikki","hue":"#F27BB0","chores":"Daily tasks, Vital Energy"},{"id":"p5x","name":"Persona 5: The Phantom X","short":"P5X","hue":"#D62246","chores":"Daily missions, stamina"},{"id":"r1999","name":"Reverse: 1999","short":"R1999","hue":"#C9A227","chores":"Daily missions"},{"id":"ba","name":"Blue Archive","short":"Blue Archive","hue":"#3FCBDD","chores":"Daily missions, AP"},{"id":"fgo","name":"Fate/Grand Order","short":"FGO","hue":"#5d7fe0","chores":"Daily Quests, AP"},{"id":"gfl2","name":"Girls' Frontline 2: Exilium","short":"GFL2","hue":"#A9C23F","chores":"Daily missions"},{"id":"stellasora","name":"Stella Sora","short":"Stella Sora","hue":"#2E9E9E","chores":"Daily missions"},{"id":"czn","name":"Chaos Zero Nightmare","short":"CZN","hue":"#B84A9C","chores":"Daily missions"},{"id":"nikke","name":"Goddess of Victory: Nikke","short":"Nikke","hue":"#E4572E","chores":"Daily missions, outpost"},{"id":"uma","name":"Umamusume: Pretty Derby","short":"Umamusume","hue":"#6FBF44","chores":"Daily races, missions"},{"id":"hi3","name":"Honkai Impact 3rd","short":"Honkai 3rd","hue":"#8B5CF6","chores":"Daily missions, stamina"},{"id":"pgr","name":"Punishing: Gray Raven","short":"PGR","hue":"#CC292B","chores":"Daily missions, serum"}];
const ROUTINES=[{"id":"routine:genshin:weekly-bosses","game":"genshin","title":"Weekly Bosses","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"runs"}},{"id":"routine:genshin:battle-pass-exp","game":"genshin","title":"Battle Pass weekly EXP","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":10000,"unit":"EXP"}},{"id":"routine:genshin:bounties","game":"genshin","title":"Reputation Bounties","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"bounties"}},{"id":"routine:genshin:requests","game":"genshin","title":"Reputation Requests","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"requests"}},{"id":"routine:genshin:realm-depot","game":"genshin","title":"Realm Depot: Realm Treasures","period":{"unit":"week","startsOn":1},"measure":{"kind":"days","required":1}},{"id":"routine:genshin:paimons-bargains","game":"genshin","title":"Paimon's Bargains: Intertwined Fates","period":{"unit":"month","startsOn":1},"measure":{"kind":"amount","target":5,"unit":"fates"}},{"id":"routine:genshin:spiral-abyss","game":"genshin","title":"Spiral Abyss","period":{"unit":"month","startsOn":16},"measure":{"kind":"days","required":1}},{"id":"routine:genshin:imaginarium-theater","game":"genshin","title":"Imaginarium Theater","period":{"unit":"month","startsOn":1},"measure":{"kind":"days","required":1}},{"id":"routine:hsr:echo-of-war","game":"hsr","title":"Echo of War","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"runs"}},{"id":"routine:hsr:nameless-honor-exp","game":"hsr","title":"Nameless Honor weekly EXP","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":8000,"unit":"EXP"}},{"id":"routine:hsr:embers-exchange","game":"hsr","title":"Embers Exchange: Star Rail Special Pass","period":{"unit":"month","startsOn":1},"measure":{"kind":"amount","target":5,"unit":"passes"}},{"id":"routine:hsr:points","game":"hsr","title":"Divergent Universe & Currency Wars points","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":18000,"unit":"points"}},{"id":"routine:hsr:pure-fiction","game":"hsr","title":"Pure Fiction","period":{"unit":"cycle","days":42,"from":"2026-10-19"},"measure":{"kind":"days","required":1}},{"id":"routine:hsr:memory-of-chaos","game":"hsr","title":"Memory of Chaos","period":{"unit":"cycle","days":42,"from":"2026-11-02"},"measure":{"kind":"days","required":1}},{"id":"routine:hsr:apocalyptic-shadow","game":"hsr","title":"Apocalyptic Shadow","period":{"unit":"cycle","days":42,"from":"2026-11-16"},"measure":{"kind":"days","required":1}},{"id":"routine:zzz:ridu-weekly","game":"zzz","title":"Ridu Weekly","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":2100,"unit":"points"}},{"id":"routine:zzz:bounty-commissions","game":"zzz","title":"Bounty Commissions","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":8000,"unit":"points"}},{"id":"routine:zzz:notorious-hunt","game":"zzz","title":"Notorious Hunt","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"runs"}},{"id":"routine:zzz:city-fund-exp","game":"zzz","title":"New Eridu City Fund weekly EXP","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":10000,"unit":"EXP"}},{"id":"routine:zzz:signal-shop-tapes","game":"zzz","title":"Signal Shop: Encrypted Master Tapes","period":{"unit":"month","startsOn":1},"measure":{"kind":"amount","target":5,"unit":"tapes"}},{"id":"routine:zzz:signal-shop-boopons","game":"zzz","title":"Signal Shop: Boopons","period":{"unit":"month","startsOn":1},"measure":{"kind":"amount","target":5,"unit":"Boopons"}},{"id":"routine:zzz:shiyu-defense","game":"zzz","title":"Shiyu Defense","period":{"unit":"cycle","days":14,"from":"2026-10-16"},"measure":{"kind":"days","required":1}},{"id":"routine:zzz:deadly-assault","game":"zzz","title":"Deadly Assault","period":{"unit":"cycle","days":14,"from":"2026-10-23"},"measure":{"kind":"days","required":1}},{"id":"routine:endfield:weekly-routine","game":"endfield","title":"Weekly Routine","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":10,"unit":"points"}},{"id":"routine:endfield:focus-commissions","game":"endfield","title":"Etchspace Salvage: Focus Commissions","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"commissions"}},{"id":"routine:wuwa:weekly-challenge","game":"wuwa","title":"Weekly Challenge","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":3,"unit":"claims"}},{"id":"routine:wuwa:pioneer-podcast-exp","game":"wuwa","title":"Pioneer Podcast weekly EXP","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":12000,"unit":"EXP"}},{"id":"routine:wuwa:weekly-activities","game":"wuwa","title":"Weekly Activities","period":{"unit":"week","startsOn":1},"measure":{"kind":"amount","target":6000,"unit":"points"}},{"id":"routine:wuwa:tLine truncated
const EVENTS=[{"game":"genshin","title":"Anniversary Memories Album","startsAt":"2026-09-28T00:00:00Z","endsAt":"2026-10-18T00:00:00Z"},{"game":"zzz","title":"Clink, Clank, Pinball Knight!","startsAt":"2026-09-10T00:00:00Z","endsAt":"2026-10-19T00:00:00Z"},{"game":"zzz","title":"Diary of an Orbie Parent","startsAt":"2026-09-30T00:00:00Z","endsAt":"2026-10-19T00:00:00Z"},{"game":"zzz","title":"Chronicles of the Hobbling Crow","startsAt":"2026-10-03T00:00:00Z","endsAt":"2026-10-19T00:00:00Z"},{"game":"nte","title":"Stamina Recharge","startsAt":"2026-10-05T00:00:00Z","endsAt":"2026-10-19T00:00:00Z"},{"game":"zzz","title":"Data Bounty - Combat Simulation","startsAt":"2026-10-14T00:00:00Z","endsAt":"2026-10-19T00:00:00Z"},{"game":"zzz","title":"All-New Program","startsAt":"2026-09-09T00:00:00Z","endsAt":"2026-10-20T00:00:00Z"},{"game":"genshin","title":"Predictive Victory Dynamics","startsAt":"2026-10-12T00:00:00Z","endsAt":"2026-10-20T00:00:00Z"},{"game":"wuwa","title":"Bountiful Crescendo","startsAt":"2026-10-15T00:00:00Z","endsAt":"2026-10-22T00:00:00Z"},{"game":"hsr","title":"4.6 Planar Fissure","startsAt":"2026-10-12T00:00:00Z","endsAt":"2026-10-25T00:00:00Z"},{"game":"wuwa","title":"Artisan's Search","startsAt":"2026-10-08T00:00:00Z","endsAt":"2026-10-26T00:00:00Z"},{"game":"wuwa","title":"Waking Moon Fishing","startsAt":"2026-10-15T00:00:00Z","endsAt":"2026-11-01T00:00:00Z"},{"game":"genshin","title":"To Temper Thyself and Journey Far","startsAt":"2026-08-10T00:00:00Z","endsAt":"2026-11-02T00:00:00Z"},{"game":"genshin","title":"Battle Pass - Moontrace","startsAt":"2026-09-23T00:00:00Z","endsAt":"2026-11-02T00:00:00Z"},{"game":"hsr","title":"Astral Imagea","startsAt":"2026-09-28T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"hsr","title":"Love, Ghosts & Robots","startsAt":"2026-09-28T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"hsr","title":"Wishpower UP! Down with Voracity!","startsAt":"2026-09-28T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"nte","title":"Pukaland Travelogue","startsAt":"2026-09-30T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"wuwa","title":"3.7 Moonlit Path","startsAt":"2026-09-30T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"wuwa","title":"Ascendant Aces","startsAt":"2026-09-30T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"nte","title":"Coal Lumps Treasure","startsAt":"2026-10-08T00:00:00Z","endsAt":"2026-11-11T00:00:00Z"},{"game":"hsr","title":"Free Gilgamesh or Archer","startsAt":"2026-07-24T00:00:00Z","endsAt":null}].map((e,i)=>({...e,id:"ev"+i}));
/* ---------------- clock (fake now; one reset hour for the sketch) ---------------- */
const NOW=Date.parse("2026-10-16T12:00:00Z"), H=3600e3, D=24*H, RESET_H=3;
const at=(ymd)=>Date.parse(ymd+"T0"+RESET_H+":00:00Z");
const NEXT_DAILY=at("2026-10-17");
function periodSpan(p){
if(p.unit==="week"){const end=at("2026-10-19");return [end-7*D,end];}
if(p.unit==="month"){
const d=new Date(NOW);let y=d.getUTCFullYear(),m=d.getUTCMonth();
let end=Date.UTC(y,m,p.startsOn,RESET_H); if(end<=NOW) end=Date.UTC(y,m+1,p.startsOn,RESET_H);
const e=new Date(end); return [Date.UTC(e.getUTCFullYear(),e.getUTCMonth()-1,p.startsOn,RESET_H),end];
}
const len=p.days*D, from=at(p.from); let k=Math.ceil((NOW-from)/len); let end=from+k*len; if(end<=NOW) end+=len; return [end-len,end];
}
function left(ms){ if(ms==null) return "no end date"; const t=ms-NOW; const d=Math.floor(t/D), h=Math.floor((t%D)/H);
return d>0?`${d}d ${h}h`:`${h}h ${Math.floor((t%H)/6e4)}m`; }
function heat(ms){ if(ms==null) return "var(--faint)"; const h=(ms-NOW)/H; return h<24?"var(--critical)":h<72?"var(--soon)":h<168?"var(--near)":"var(--calm)"; }
const fmtD=(ms)=>new Date(ms).toLocaleDateString("en-GB",{day:"numeric",month:"short",timeZone:"UTC"});
const num=(n)=>n.toLocaleString("en-GB");
const G=Object.fromEntries(GAMES.map(g=>[g.id,g]));
const esc=(s)=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const ICON={
tune:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h7M12 4.5h2M2 11.5h2M7 11.5h7"/><circle cx="10.5" cy="4.5" r="1.5"/><circle cx="5.5" cy="11.5" r="1.5"/></svg>',
plus:'<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
check:'<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6.2 5 8.5l4.5-5"/></svg>',
search:'<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="9" cy="9" r="5.5"/><path d="m13.2 13.2 3.8 3.8"/></svg>',
};
/* ---------------- the foundation, in miniature ----------------
Every source projects to one Item shape; every visible list is a Section (a saved query). */
const KINDS=[["event","Events"],["daily","Dailies"],["weekly","Weekly"],["monthly","Monthly"],["cycle","Cycles"],["own","Your own"]];
const KIND_TAG={event:"event",daily:"dailies",weekly:"weekly",monthly:"monthly",cycle:"cycle"};
const DEMO_GAMES=["genshin","hsr","zzz","wuwa","endfield","nte"];
const ALL_KINDS=KINDS.map(k=>k[0]);
const sectionPresets={
due:{id:"due",title:"Due next",where:"rail",kinds:["event","daily","weekly","cycle","own"],games:"all",limit:8,sort:"due",within:7},
dailies:{id:"dailies",title:"Today's dailies",where:"rail",kinds:["daily"],games:"all",limit:0,sort:"game"},
running:{id:"running",title:"Running now",where:"main",kinds:["event","own"],games:"all",limit:6,sort:"due"},
};
function demoState(){
return {
tab:"checklist", view:"main",
games:[...DEMO_GAMES],
chores:["genshin","hsr","zzz","wuwa"],
follow:["routine:zzz:ridu-weekly","routine:zzz:shiyu-defense","routine:genshin:weekly-bosses","routine:hsr:pure-fiction","routine:nte:beyond-the-rails","routine:wuwa:weekly-challenge"],
own:[
{id:"own:1",game:"nte",title:"Collect every Pukaland postcard",kind:"event",startsAt:Date.parse("2026-10-01T00:00:00Z"),dueAt:Date.parse("2026-11-05T03:00:00Z"),measure:{kind:"days",required:1}},
{id:"own:2",game:"zzz",title:"Lost Void runs",kind:"weekly",period:{unit:"week",startsOn:1},measure:{kind:"amount",target:4,unit:"runs"}},
],
progress:{"routine:zzz:ridu-weekly":1400,"routine:genshin:weekly-bosses":1,"routine:wuwa:weekly-challenge":3,"own:2":1},
choresDone:["hsr"],
status:{ev3:"done",ev6:"ignored",ev13:"done"},
layout:[{...sectionPresets.due},{...sectionPresets.dailies},{...sectionPresets.running}],
expanded:{}, editing:null, rowOpen:null,
archiveGame:"all", archiveStatus:"all",
};
}
function blankState(){
const s=demoState(); s.games=[]; s.chores=[]; s.follow=[]; s.own=[]; s.progress={}; s.choresDone=[]; s.status={};
s.layout=[{...sectionPresets.due,kinds:["event","own"]},{...sectionPresets.dailies,hidden:true},{...sectionPresets.running}]; return s;
}
let S=demoState();
let UI={pop:null, pal:null, toast:null, why:false, ob:null, tour:null, addMenu:false, screen:"main"};
function items(){
const out=[];
for(const e of EVENTS){ if(!S.games.includes(e.game)) continue;
out.push({id:e.id,src:"event",game:e.game,title:e.title,kind:"event",startAt:Date.parse(e.startsAt),dueAt:e.endsAt?Date.parse(e.endsAt):null,status:S.status[e.id]||"open"}); }
for(const g of S.chores){ if(!S.games.includes(g)) continue;
out.push({id:"dailies:"+g,src:"chore",game:g,title:G[g].chores,kind:"daily",startAt:NEXT_DAILY-D,dueAt:NEXT_DAILY,status:S.choresDone.includes(g)?"done":"open"}); }
for(const id of S.follow){ const r=ROUTINES.find(x=>x.id===id); if(!r||!S.games.includes(r.game)) continue;
const [a,b]=periodSpan(r.period); out.push(routineItem(r,"routine",a,b)); }
for(const o of S.own){
if(o.kind==="event") out.push({...o,src:"own",startAt:o.startsAt,status:S.status[o.id]||"open",own:true});
else { const [a,b]=periodSpan(o.period); out.push({...routineItem(o,"own",a,b),own:true}); }
}
return out;
}
function routineItem(r,src,a,b){
const kind=r.period.unit==="week"?"weekly":r.period.unit==="month"?"monthly":"cycle";
const v=S.progress[r.id]||0, m=r.measure;
const done=m.kind==="amount"?v>=m.target:v>=1;
return {id:r.id,src,game:r.game,title:r.title,kind,startAt:a,dueAt:b,measure:m,value:v,status:done?"done":"open"};
}
function query(sec){
let xs=items().filter(i=>{
const k=i.own?"own":i.kind;
if(!sec.kinds.includes(k)) return false;
if(sec.games!=="all" && !sec.games.includes(i.game)) return false;
if(sec.where==="main" && i.kind!=="event") return false;
if(sec.within && i.dueAt!=null && i.dueAt-NOW>sec.within*D && i.status!=="done") return false;
if(i.src==="event"||(i.own&&i.kind==="event")) return i.status==="open";
return true;
});
xs.sort((a,b)=>sec.sort==="game"?(S.games.indexOf(a.game)-S.games.indexOf(b.game))||((a.dueAt??9e15)-(b.dueAt??9e15)):((a.status==="done")-(b.status==="done"))||((a.dueAt??9e15)-(b.dueAt??9e15)));
return xs;
}
/* ---------------- rendering ---------------- */
function render(){
renderMock();
const app=document.getElementById("app");
app.innerHTML=`<div class="shell">${header()}${S.tab==="settings"?settings():S.tab==="timeline"?timeline():S.view==="archive"?archive():main()}</div>`;
renderLayer();
}
function renderMock(){
const b=(k,l)=>`<button data-act="screen" data-v="${k}" class="${UI.screen===k?"on":""}">${l}</button>`;
document.getElementById("mock").innerHTML=`<b>C · One due list, customise where you see it</b>${b("main","Main")}${b("add","Add &amp; choose systems")}${b("settings","Settings")}${b("onboard","Onboarding (new user)")}${b("return","Returning user")}${b("archive","Archive")}<span class="sp"></span><button data-act="reset">Reset demo</button><button data-act="why" class="${UI.why?"on":""}">Why / foundation</button>`;
}
function header(){
const t=(k,l)=>`<button role="tab" aria-selected="${S.tab===k}" data-act="tab" data-v="${k}" ${k==="settings"?'id="tab-settings"':""}>${l}</button>`;
return `<header class="top"><div class="word">Event <span>Clock</span></div>
<div class="tabs" role="tablist">${t("checklist","Checklist")}${t("timeline","Timeline")}${t("settings","Settings")}</div>
<span class="grow"></span>
<button class="link" data-act="archive">Done &amp; ignored</button>
<button class="addbtn" id="addbtn" data-act="pal">${ICON.plus} Add <kbd>/</kbd></button></header>`;
}
function main(){
const rail=S.layout.filter(s=>s.where==="rail"&&!s.hidden), mainS=S.layout.filter(s=>s.where==="main"&&!s.hidden);
return `<div class="layout"><aside class="rail">${rail.map(sectionHtml).join("")}
<div style="position:relative"><button class="addsec" data-act="addmenu" id="addsec">${ICON.plus} Add a section</button>${UI.addMenu?addMenu():""}</div></aside>
<main>${headline()}${mainS.map(sectionHtml).join("")}</main></div>`;
}
function headline(){
const ev=items().filter(i=>i.kind==="event"&&i.status==="open"&&i.dueAt).sort((a,b)=>a.dueAt-b.dueAt)[0];
if(!ev) return `<section class="head"><div class="lbl">Nothing is running for your games.</div></section>`;
const g=G[ev.game];
return `<section class="head" style="background:radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, ${heat(ev.dueAt)} 14%, transparent), transparent 55%), var(--surface)">
<div class="lbl">Ends first</div><h1>${esc(ev.title)}</h1><div class="game" style="color:${g.hue}">${g.name}</div>
<div class="row"><div class="big tnum" style="color:${heat(ev.dueAt)}">${left(ev.dueAt)}</div><div class="when tnum">left, ends ${fmtD(ev.dueAt)} at the reset</div></div>
${meter(ev,28)}</section>`;
}
function meter(i,n=24,sm){
if(!i.dueAt) return `<div class="meter ${sm?"sm":""}" style="--c:var(--faint);opacity:.6">${'<i class="on" style="background:repeating-linear-gradient(135deg,var(--softer) 0 3px,transparent 3px 6px)"></i>'.repeat(n)}</div>`;
const f=Math.max(0,Math.min(1,(i.dueAt-NOW)/(i.dueAt-i.startAt))); const on=Math.max(1,Math.round(f*n));
return `<div class="meter ${sm?"sm":""}" style="--c:${heat(i.dueAt)}">${Array.from({length:n},(_,k)=>`<i class="${k<on?"on":""}"></i>`).join("")}</div>`;
}
function sectionHtml(sec){
const xs=query(sec);
const meta=sec.id==="dailies"?`${xs.filter(x=>x.status!=="done").length} of ${xs.length} left`:sec.id==="running"?`${xs.length}`:"";
const head=`<div class="sec-h"><h2>${esc(sec.title)}</h2><span class="meta tnum">${meta}</span>
<button class="cust" data-act="pop" data-v="${sec.id}" id="cust-${sec.id}" aria-expanded="${UI.pop===sec.id}">${ICON.tune}<span>Customise</span></button></div>`;
let body="";
if(sec.id==="dailies") body=dailiesBody(xs);
else if(sec.where==="main") body=runningBody(sec,xs);
else body=dueBody(sec,xs);
return `<section class="sec" id="sec-${sec.id}">${head}${body}</section>`;
}
function dailiesBody(xs){
if(!xs.length) return `<div class="empty">No daily chores yet. <button data-act="pal" data-q="dailies">Add a game's dailies</button></div>`;
return `<div class="chips" style="margin-top:6px">${xs.map(x=>{const g=G[x.game];return `<button class="chip" style="--h:${g.hue}" aria-pressed="${x.status==="done"}" data-act="chore" data-v="${x.game}" title="${esc(x.title)}"><span class="ck">${ICON.check}</span>${g.short}</button>`}).join("")}</div>
<div class="empty" style="font-size:13px;color:var(--faint);padding-top:10px">Next reset in <span class="tnum">${left(NEXT_DAILY)}</span></div>`;
}
function bucketOf(t){ if(t==null) return "No end date"; if(t<=NEXT_DAILY) return "Before tonight's reset"; if(t<=at("2026-10-19")) return "By Monday's reset"; if(t<=at("2026-10-26")) return "Next week"; return "Later"; }
function dueBody(sec,xs){
if(!xs.length) return `<div class="empty">Nothing here yet. <button data-act="pal">Add something to follow</button></div>`;
// the chores fold into one line per reset: a list of six "Daily missions" teaches nothing
const chores=xs.filter(x=>x.kind==="daily"); let rows=xs.filter(x=>x.kind!=="daily");
if(chores.length){ const lft=chores.filter(c=>c.status!=="done");
rows.push({id:"__chores",kind:"daily",game:null,title:"Daily chores",dueAt:NEXT_DAILY,status:lft.length?"open":"done",sub:lft.length?lft.map(c=>G[c.game].short).join(", ")+" still to do":"All done today",count:lft.length?`${lft.length} left`:"done"});
rows.sort((a,b)=>((a.status==="done")-(b.status==="done"))||((a.dueAt??9e15)-(b.dueAt??9e15))); }
const full=S.expanded[sec.id]; const cap=sec.limit||rows.length; const shown=full?rows:rows.slice(0,cap);
let html="",last=null;
for(const r of shown){ const b=r.status==="done"?"Done for now":bucketOf(r.dueAt); if(b!==last&&sec.sort==="due"){html+=`<div class="bucket">${b}</div>`;last=b;} html+=dueRow(r); }
if(rows.length>cap) html+=`<button class="more" data-act="more" data-v="${sec.id}">${full?"Show fewer":`Show ${rows.length-cap} more`}</button>`;
return html;
}
function dueRow(r){
const hue=r.game?G[r.game].hue:"var(--muted)";
const tag=r.own?`<span class="kind yours">your ${r.kind==="event"?"event":r.kind}</span>`:`<span class="kind">${KIND_TAG[r.kind]}</span>`;
let sub="", fill="";
if(r.id==="__chores"){ sub=r.sub; }
else if(r.measure){ const m=r.measure;
sub=m.kind==="amount"?`<span class="tnum">${num(r.value)} / ${num(m.target)}</span> ${esc(m.unit||"")}`:(r.status==="done"?"Cleared this "+(r.kind==="cycle"?"cycle":r.kind.replace("ly","")):"Not cleared yet");
if(m.kind==="amount") fill=`<div class="fill"><i style="width:${Math.min(100,r.value/m.target*100)}%;background:${hue}"></i></div>`;
sub=`${G[r.game].short}, ${sub}`;
} else sub=G[r.game].short;
const lt=r.status==="done"&&r.id!=="__chores"?"done":r.id==="__chores"?`<span style="color:${heat(r.dueAt)}">${r.count}</span>`:`<span style="color:${heat(r.dueAt)}">${left(r.dueAt)}</span>`;
const ed=S.editing===r.id&&r.measure?editor(r):"";
return `<button class="due ${r.status==="done"?"done":""}" data-act="due" data-v="${r.id}"><span class="dot" style="background:${hue}"></span>
<span class="t">${esc(r.title)}${tag}<span class="sub">${sub}</span></span><span class="left tnum">${lt}</span>${fill}</button>${ed}`;
}
function editor(r){
const m=r.measure;
if(m.kind==="amount") return `<div class="editor">Now at <input class="tnum" id="ed-in" type="number" value="${r.value}" min="0"> / ${num(m.target)} ${esc(m.unit||"")}<button class="pill primary" data-act="saveamt" data-v="${r.id}">Save</button><button class="pill" data-act="unfollow" data-v="${r.id}">Stop following</button></div>`;
return `<div class="editor"><button class="pill primary" data-act="tick" data-v="${r.id}">${r.status==="done"?"Mark not cleared":"Mark cleared"}</button><button class="pill" data-act="unfollow" data-v="${r.id}">Stop following</button></div>`;
}
function runningBody(sec,xs){
if(!xs.length) return `<div class="empty">No running events for the games you follow.</div>`;
const full=S.expanded[sec.id]; const cap=sec.limit||xs.length; const shown=full?xs:xs.slice(0,cap);
return shown.map(e=>{const g=G[e.game];const open=S.rowOpen===e.id;return `<button class="runrow" data-act="row" data-v="${e.id}" style="--h:${g.hue}"><span class="rail3"></span>
<span><span class="gname" style="color:${g.hue}">${g.name}</span>${e.own?'<span class="kind yours">your event</span>':""}<br><span class="title">${esc(e.title)}</span></span>
<span class="left tnum" style="color:${heat(e.dueAt)}">${left(e.dueAt)}</span>${meter(e,32,true)}
<span class="range tnum" style="grid-column:2/4">${fmtD(e.startAt)} to ${e.dueAt?fmtD(e.dueAt):"end not announced"}</span>
${open?`<span class="rowacts"><span class="pill primary" data-act="mark" data-v="${e.id}" data-s="done">Mark done</span><span class="pill" data-act="mark" data-v="${e.id}" data-s="ignored">Ignore</span></span>`:""}</button>`}).join("")
+(xs.length>cap?`<button class="more" data-act="more" data-v="${sec.id}">${full?"Show fewer":`Show all ${xs.length}`}</button>`:"");
}
function addMenu(){
const opts=[["dailies","Today's dailies","Tick each game's chores"],["cycles","Endgame cycles","Only the modes on a cycle"],["own","Your own","Only things you added"],["game","One game","Everything for one game"]];
return `<div class="pop" style="left:0;bottom:calc(100% + 8px)"><h4>Add a section</h4>${opts.map(o=>`<button class="pr" data-act="addsecv" data-v="${o[0]}"><span class="dot" style="background:var(--muted)"></span><span>${o[1]}</span><span class="sub">${o[2]}</span></button>`).join("")}</div>`;
}
/* customise popover, scoped to one section */
function popHtml(sec){
const opt=(on,act,v,l,extra="")=>`<button class="opt ${extra}" aria-pressed="${on}" data-act="${act}" data-v="${v}" data-s="${sec.id}">${l}</button>`;
const i=S.layout.indexOf(sec);
const kinds=KINDS.map(([k,l])=>opt(sec.kinds.includes(k),"kind",k,l)).join("");
const games=opt(sec.games==="all","gamesall","all","All my games")+S.games.map(g=>`<button class="opt g" style="--h:${G[g].hue}" aria-pressed="${sec.games!=="all"&&sec.games.includes(g)}" data-act="game" data-v="${g}" data-s="${sec.id}">${G[g].short}</button>`).join("");
const rows=[4,8,12,0].map(n=>opt(sec.limit===n,"limit",n,n||"All")).join("");
const within=[[3,"3 days"],[7,"A week"],[0,"Any time"]].map(([n,l])=>opt((sec.within||0)===n,"within",n,l)).join("");
const sort=opt(sec.sort==="due","sort","due","Due soonest")+opt(sec.sort==="game","sort","game","By game");
return `<div class="pop" id="pop"><h4>Shows</h4><div class="opts">${kinds}</div>
<h4>From</h4><div class="opts">${games}</div>
${sec.id==="dailies"?"":`${sec.where==="rail"?`<h4>Due within</h4><div class="opts">${within}</div>`:""}<h4>Rows before “show more”</h4><div class="opts">${rows}</div><h4>Order</h4><div class="opts">${sort}</div>`}
<div class="popfoot"><button class="addb" data-act="pal" data-q="${sec.games!=="all"&&sec.games.length===1?G[sec.games[0]].short:""}">Add from built-ins</button>
${sec.id==="running"?`<button data-act="archive">Done &amp; ignored</button>`:""}
<button data-act="move" data-v="-1" data-s="${sec.id}" ${i===0?"disabled style=opacity:.4":""}>Move up</button><button data-act="move" data-v="1" data-s="${sec.id}">Move down</button>
<button data-act="hide" data-s="${sec.id}">Hide section</button></div></div>`;
}
/* quick-add palette */
function palResults(q){
q=q.trim().toLowerCase(); const words=q.split(/\s+/).filter(Boolean);
const periodWord=words.find(w=>["weekly","week","monthly","month","cycle","cycles","endgame","dailies","daily"].includes(w));
const gameHit=GAMES.filter(g=>words.some(w=>g.name.toLowerCase().includes(w)||g.short.toLowerCase().includes(w)||g.id===w));
const unit=periodWord?({weekly:"week",week:"week",monthly:"month",month:"month",cycle:"cycle",cycles:"cycle",endgame:"cycle"}[periodWord]):null;
const res={bulk:[],chores:[],built:[]};
const scope=gameHit.length?gameHit.map(g=>g.id):S.games.length?S.games:DEMO_GAMES;
const rest=words.filter(w=>w!==periodWord&&!gameHit.some(g=>g.name.toLowerCase().includes(w)||g.short.toLowerCase().includes(w)||g.id===w));
res.built=ROUTINES.filter(r=>scope.includes(r.game)&&(!unit||r.period.unit===unit)&&rest.every(w=>r.title.toLowerCase().includes(w))).slice(0,9);
if(gameHit.length===1&&unit){ const g=gameHit[0]; const n=ROUTINES.filter(r=>r.game===g.id&&r.period.unit===unit).length; if(n) res.bulk.push({g,unit,n}); }
if(periodWord==="dailies"||periodWord==="daily"||(!unit&&gameHit.length)) res.chores=scope.slice(0,6);
return res;
}
function palHtml(){
const q=UI.pal.q, r=palResults(q);
const unitWord={week:"weekly",month:"monthly",cycle:"cycle"};
let h="";
if(r.bulk.length) h+=`<div class="grp">Follow a whole set</div>`+r.bulk.map(b=>`<button class="pr sel" data-act="bulk" data-v="${b.g.id}" data-s="${b.unit}"><span class="dot" style="background:${b.g.hue}"></span><span>Follow every ${b.g.short} ${unitWord[b.unit]}</span><span class="act">Follow ${b.n}</span><span class="sub">${b.n} built-ins, they appear in Due next</span></button>`).join("");
if(r.chores.length) h+=`<div class="grp">Daily chores</div>`+r.chores.map(g=>{const on=S.chores.includes(g)&&S.games.includes(g);return `<button class="pr" data-act="choreon" data-v="${g}"><span class="dot" style="background:${G[g].hue}"></span><span>${G[g].name} dailies</span><span class="act ${on?"on":""}">${on?"Following":"Follow"}</span><span class="sub">${esc(G[g].chores)}</span></button>`}).join("");
if(r.built.length) h+=`<div class="grp">Built-in for the game</div>`+r.built.map(x=>{const on=S.follow.includes(x.id)&&S.games.includes(x.game);const k=x.period.unit==="week"?"Weekly":x.period.unit==="month"?"Monthly":`Every ${x.period.days} days`;
return `<button class="pr" data-act="follow" data-v="${x.id}"><span class="dot" style="background:${G[x.game].hue}"></span><span>${esc(x.title)}</span><span class="act ${on?"on":""}">${on?"Following":"Follow"}</span><span class="sub">${G[x.game].short}, ${k}${x.measure.kind==="amount"?`, ${num(x.measure.target)} ${esc(x.measure.unit)}`:""}</span></button>`}).join("");
h+=`<div class="grp">Your own</div><button class="pr" data-act="mkown"><span class="dot" style="background:var(--soon)"></span><span>${q.trim()?`Create a weekly task called “${esc(q.trim())}”`:"Create your own event or task"}</span><span class="act">Create</span><span class="sub">Kept apart from the built-ins, under Settings, Your own events</span></button>`;
return `<div class="scrim" data-act="closepal"><div class="pal" role="dialog" aria-label="Add"><div class="q">${ICON.search}<input id="palq" placeholder="Try “zzz weekly”, “star rail cycle” or “dailies”" value="${esc(q)}" autocomplete="off"></div><div class="res" id="palres">${h}</div><div class="hint">Type a game and a kind. Everything you follow lands in Due next straight away.</div></div></div>`;
}
/* settings */
function settings(){
const segm=(opts,cur)=>`<div class="seg">${opts.map(o=>`<button aria-pressed="${o===cur}">${o}</button>`).join("")}</div>`;
const ordered=[...GAMES.filter(g=>S.games.includes(g.id)),...GAMES.filter(g=>!S.games.includes(g.id))];
const nOff=GAMES.length-S.games.length;
const gameRows=ordered.filter(g=>UI.moreGames||S.games.includes(g.id)).map(g=>{const on=S.games.includes(g.id);const n=S.follow.filter(f=>f.includes(":"+g.id+":")).length+(S.chores.includes(g.id)?1:0);
return `<div class="lrow"><span class="dot" style="background:${g.hue}"></span><span>${g.name}<br><span class="s">${on?(n?`${n} thing${n>1?"s":""} followed besides events`:"Events only"):"Not followed"}</span></span><span class="r"><button class="opt g" style="--h:${g.hue}" aria-pressed="${on}" data-act="togglegame" data-v="${g.id}">${on?"Following":"Follow"}</button></span></div>`}).join("");
const lay=S.layout.map(s=>`<div class="lrow"><span>${esc(s.title)}<br><span class="s">${s.kinds.map(k=>KINDS.find(x=>x[0]===k)[1]).join(", ")}${s.games==="all"?", all games":", "+s.games.map(g=>G[g].short).join(", ")}${s.limit?`, ${s.limit} rows`:""}</span></span><span class="r"><button class="opt" aria-pressed="${!s.hidden}" data-act="showsec" data-s="${s.id}">${s.hidden?"Hidden":"Shown"}</button><button class="opt" data-act="editsec" data-s="${s.id}">Customise</button></span></div>`).join("");
const own=S.own.map(o=>`<div class="lrow"><span class="dot" style="background:${G[o.game].hue}"></span><span>${esc(o.title)}<br><span class="s">${G[o.game].short}, ${o.kind==="event"?"event until "+fmtD(o.dueAt):"weekly task"}</span></span><span class="r"><button class="opt">Edit</button></span></div>`).join("")||`<div class="empty">Nothing yet. Built-in weeklies and cycles never appear here.</div>`;
return `<div class="page"><h1>Settings</h1><p class="lead">Everything that changes what you see lives here or in the Customise button on each section. Nothing leaves this browser.</p>
<div class="set"><h2>Games</h2><p>Follow a game to see its events. What else you follow for it (dailies, weeklies, cycles) you add with the Add button or from a section.</p>${gameRows}${nOff?`<button class="more" data-act="moregames">${UI.moreGames?"Hide games you don't follow":`Show ${nOff} more games`}</button>`:""}</div>
<div class="set"><h2>Layout</h2><p>The same settings as each section's Customise button, all in one place.</p>${lay}</div>
<div class="set"><h2>Your own events</h2><p>Only things you created yourself.</p>${own}<div style="margin-top:12px"><button class="pill" data-act="pal">Create one</button></div></div>
<div class="set"><h2>Server region</h2><div class="lrow"><span>Resets and countdowns follow this server</span><span class="r">${segm(["Asia","America","Europe"],"Europe")}</span></div></div>
<div class="set"><h2>Appearance</h2><div class="lrow"><span>Theme</span><span class="r">${segm(["Dark","Light","System"],"Dark")}</span></div></div>
<div class="set"><h2>Backup</h2><p>Save your progress and layout to a file, or load one from another device.</p><div style="display:flex;gap:8px"><button class="pill">Save to a file</button><button class="pill">Load a file</button></div></div></div>`;
}
/* archive */
function archive(){
const xs=items().filter(i=>i.status==="done"||i.status==="ignored").filter(i=>i.src==="event"||(i.own&&i.kind==="event"))
.filter(i=>S.archiveGame==="all"||i.game===S.archiveGame).filter(i=>S.archiveStatus==="all"||i.status===S.archiveStatus);
const o=(on,act,v,l,h)=>`<button class="opt ${h?"g":""}" ${h?`style="--h:${h}"`:""} aria-pressed="${on}" data-act="${act}" data-v="${v}">${l}</button>`;
return `<div class="page"><button class="link" data-act="back">Back to the checklist</button><h1>Done &amp; ignored</h1><p class="lead">Events you finished or set aside. They stay out of every section until you restore them.</p>
<div class="opts" style="margin-bottom:10px">${o(S.archiveStatus==="all","astat","all","All")}${o(S.archiveStatus==="done","astat","done","Done")}${o(S.archiveStatus==="ignored","astat","ignored","Ignored")}</div>
<div class="opts" style="margin-bottom:18px">${o(S.archiveGame==="all","agame","all","Every game")}${S.games.map(g=>o(S.archiveGame===g,"agame",g,G[g].short,G[g].hue)).join("")}</div>
<div class="set">${xs.length?xs.map(e=>`<div class="lrow"><span class="dot" style="background:${G[e.game].hue}"></span><span>${esc(e.title)}<br><span class="s">${G[e.game].short}, ${e.status==="done"?"done":"ignored"}, ${e.dueAt?(e.dueAt<NOW?"ended ":"ends ")+fmtD(e.dueAt):"no end date"}</span></span><span class="r"><button class="pill" data-act="restore" data-v="${e.id}">Restore</button></span></div>`).join(""):`<div class="empty">Nothing here. Open an event and choose Mark done or Ignore to send it here.</div>`}</div></div>`;
}
/* timeline sketch */
function timeline(){
const t0=NOW-14*D, t1=NOW+30*D, x=(t)=>Math.max(0,Math.min(100,(t-t0)/(t1-t0)*100));
const rows=S.games.map(g=>{const evs=items().filter(i=>i.game===g&&i.kind==="event"&&i.status==="open");
return evs.map((e,k)=>`<div class="tlrow"><span style="color:${G[g].hue};font-size:13px;font-weight:600">${k?"":G[g].short}</span><div class="tltrack"><div class="now" style="left:${x(NOW)}%"></div><div class="tlbar" style="left:${x(e.startAt)}%;width:${x(e.dueAt??t1)-x(e.startAt)}%;background:color-mix(in srgb,${G[g].hue} 24%,var(--surface));box-shadow:inset 3px 0 0 ${G[g].hue}">${esc(e.title)}</div></div></div>`).join("")}).join("");
return `<div class="tl"><div class="empty" style="margin-bottom:10px">Timeline is unchanged in this idea. It reads the same Running now section, so its filters apply here too.</div>${rows}</div>`;
}
/* layer: popover, palette, toast, onboarding, tour, why */
function renderLayer(){
const L=document.getElementById("layer"); let h="";
if(UI.toast) h+=`<div class="toast"><span>${UI.toast.msg}</span>${UI.toast.undo?`<button data-act="undo">Undo</button>`:""}</div>`;
if(UI.pal) h+=palHtml();
if(UI.ob) h+=obHtml();
if(UI.why) h+=whyHtml();
L.innerHTML=h;
if(UI.pop){ const sec=S.layout.find(s=>s.id===UI.pop); const anchor=document.getElementById("sec-"+UI.pop);
if(sec&&anchor){ anchor.style.position="relative"; anchor.insertAdjacentHTML("beforeend",popHtml(sec)); const p=document.getElementById("pop"); p.style.top="52px"; p.style.right="12px"; } }
if(UI.pal){ const i=document.getElementById("palq"); i.focus(); i.setSelectionRange(i.value.length,i.value.length); }
if(UI.tour) placeTour();
}
/* onboarding */
function obHtml(){
const o=UI.ob;
if(o.step===1) return `<div class="ob"><div class="obin"><div class="step">Step 1 of 3</div><h1>Which games do you play?</h1><p class="lead">You'll see their events. Nothing else is switched on until you ask for it.</p>
<div class="ggrid">${GAMES.map(g=>`<button class="gopt" style="--h:${g.hue}" aria-pressed="${o.games.includes(g.id)}" data-act="obgame" data-v="${g.id}"><span class="dot" style="background:${g.hue}"></span>${g.name}</button>`).join("")}</div>
<div class="obfoot"><button class="commit" data-act="obnext" ${o.games.length?"":"disabled"}>${o.games.length?`Continue with ${o.games.length} game${o.games.length>1?"s":""}`:"Pick at least one game"}</button></div></div></div>`;
const c=(k,t,d,ex)=>`<button class="choice" aria-pressed="${o.plan===k}" data-act="obplan" data-v="${k}"><strong>${t}</strong><span>${d}</span><span class="ex">${ex}</span></button>`;
const n=ROUTINES.filter(r=>o.games.includes(r.game)).length;
return `<div class="ob"><div class="obin"><div class="step">Step 2 of 3</div><h1>What do you want on your list?</h1><p class="lead">You can change this for any section later, from its Customise button.</p>
${c("events","Just events","Limited-time events and their deadlines. The calmest list.","Good if you only log in for new content.")}
${c("dailies","Events and dailies","Events, plus a tick for each game's daily chores before the reset.",o.games.slice(0,3).map(g=>G[g].chores).join("; "))}
${c("all","Everything that resets","Events, dailies, and every weekly, monthly and endgame cycle we know for your games.",`${n} built-ins for your games, like ${ROUTINES.filter(r=>o.games.includes(r.game)).slice(0,2).map(r=>r.title).join(" and ")||"weekly bosses"}`)}
<div class="obfoot"><button class="link" data-act="obback">Back</button><button class="commit" data-act="obdone">Show me my list</button></div></div></div>`;
}
const TOUR_NEW=[
{sel:"#cust-due",t:"Change a list where you see it",b:"Every section has its own Customise button: what it shows, which games, how many rows."},
{sel:"#addbtn",t:"Follow a weekly, cycle or chore",b:"Press Add or the / key and type a game, like “zzz weekly”. What you pick lands in Due next."},
{sel:"#tab-settings",t:"The rest is in Settings",b:"Games, region, theme, backups, and your own events, kept apart from the built-ins."},
];
const TOUR_BACK=[
{sel:"#sec-due",t:"One list for everything that's due",b:"Events, dailies, weeklies and cycles now share one list, ordered by when you'd lose them."},
{sel:"#cust-due",t:"Edit is now Customise, on every section",b:"The old Edit button at the bottom of the strip is gone. Each section has its own, at the top."},
{sel:"#addbtn",t:"Stopped following something?",b:"Find it again with Add. Type its name and choose Follow. Your old progress is still there."},
{sel:"#tab-settings",t:"Settings moved to a tab",b:"“What you see” and the Games panel are merged into Games and Layout."},
];
function placeTour(){
const st=UI.tour.steps[UI.tour.i]; const el=document.querySelector(st.sel); if(!el) return;
el.scrollIntoView({block:"center"});
const r=el.getBoundingClientRect(), pad=6;
const L=document.getElementById("layer");
L.insertAdjacentHTML("beforeend",`<div class="spot" style="left:${r.left-pad}px;top:${r.top-pad}px;width:${r.width+pad*2}px;height:${r.height+pad*2}px"></div>
<div class="tip" id="tip"><div class="n">${UI.tour.i+1} of ${UI.tour.steps.length}${UI.tour.back?", what's changed":""}</div><h3>${st.t}</h3><p>${st.b}</p>
<div class="f"><button class="skip" data-act="tourend">${UI.tour.back?"Skip the tour":"Skip"}</button><button class="nx" data-act="tournext">${UI.tour.i===UI.tour.steps.length-1?"Done":"Next"}</button></div></div>`);
const tip=document.getElementById("tip"); const tw=320, th=tip.offsetHeight;
let left=Math.min(window.innerWidth-tw-16,Math.max(16,r.left)); let top=r.bottom+14; if(top+th>window.innerHeight-12) top=Math.max(52,r.top-th-14);
tip.style.left=left+"px"; tip.style.top=top+"px";
}
function whyHtml(){
return `<aside class="why"><h2 style="margin:0;font-size:19px">C · One due list</h2>
<h3>The idea</h3><p>You don't think in events, weeklies and cycles; you think in “what do I lose next”. So everything with a deadline goes into one Due next list, ordered by when it's lost, capped so it never floods. Nothing but events is on until you add it. Every section carries its own Customise button, so changing a list happens where you are looking, not at the bottom of the page.</p>
<h3>The foundation underneath</h3><p>Two ideas replace the fifteen scattered prefs (<code>hiddenGames</code>, <code>knownGames</code>, <code>showChores</code>/<code>choresOn</code>/<code>choresOff</code>, <code>showRoutines</code>/<code>routineGames</code>/<code>routinesOff</code>/<code>routinesAsked</code>/<code>routinesHidden</code>, <code>showWeeklyTasks</code>/<code>showMonthlyTasks</code>/<code>showCycleTasks</code>, <code>standardPulls</code>):</p>
<pre>// 1. one stream: every source projects to Item
Item = { id, game, kind: event|daily|weekly|monthly|cycle,
own: boolean, startAt, dueAt, measure?, status }
// feed events, chores, built-ins and your tasks are adapters
// 2. what you follow, and how you look at it
Prefs = {
games: GameId[], // followed, in your order
follow: ItemSourceId[], // chores + built-ins you opted into
layout: Section[],
}
Section = { id, title, where: rail|main,
kinds, games: 'all'|GameId[], limit, sort, hidden }</pre>
<p>Due next, Today's dailies and Running now are just three saved queries. The Timeline and the archive read the same stream.</p>
<h3>How it scales</h3><ul><li>A new kind (a season pass, a shop reset) is one adapter into <code>Item</code>; every section can show it at once.</li><li>A new view (a per-game page, a “cycles only” panel) is a new <code>Section</code> preset, not new prefs and not new components.</li><li>Onboarding answers write sections; they don't flip switches.</li></ul>
<h3>Tradeoffs, honestly</h3><ul><li>A merged list can bury a long cycle under many short dailies; the cap and the folded “Daily chores” line are what stop that, and they need tuning.</li><li>Section configs are power-user surface. Most readers will never open Customise, so the presets have to be right.</li><li>Kind tags are small; a reader who wants weeklies visually apart gets that only by adding a section.</li><li>Migration has to translate today's prefs into sections, once.</li></ul></aside>`;
}
/* ---------------- actions ---------------- */
let undoState=null;
function snapshot(){ undoState=JSON.parse(JSON.stringify(S)); }
function toast(msg,undo=true){ UI.toast={msg,undo}; clearTimeout(toast.t); toast.t=setTimeout(()=>{UI.toast=null;render();},5000); }
document.addEventListener("click",(ev)=>{
const el=ev.target.closest("[data-act]");
if(!el){ if(UI.pop||UI.addMenu){UI.pop=null;UI.addMenu=false;render();} return; }
const a=el.dataset.act, v=el.dataset.v, s=el.dataset.s, sec=S.layout.find(x=>x.id===s);
if(a==="closepal"&&ev.target!==el) return;
ev.stopPropagation();
switch(a){
case "screen": go(v); return;
case "reset": S=demoState(); UI={...UI,pop:null,pal:null,toast:null,ob:null,tour:null,addMenu:false,screen:"main"}; break;
case "why": UI.why=!UI.why; break;
case "tab": S.tab=v; S.view="main"; UI.pop=null; UI.screen=v==="settings"?"settings":"main"; break;
case "archive": S.tab="checklist"; S.view="archive"; UI.pop=null; UI.screen="archive"; break;
case "back": S.view="main"; UI.screen="main"; break;
case "pal": UI.pop=null; UI.addMenu=false; UI.pal={q:el.dataset.q||""}; break;
case "closepal": UI.pal=null; break;
case "pop": UI.pop=UI.pop===v?null:v; UI.addMenu=false; break;
case "addmenu": UI.addMenu=!UI.addMenu; UI.pop=null; break;
case "addsecv": snapshot(); addSection(v); UI.addMenu=false; break;
case "kind": snapshot(); sec.kinds=sec.kinds.includes(v)?sec.kinds.filter(k=>k!==v):[...sec.kinds,v]; break;
case "gamesall": sec.games="all"; break;
case "game": { let gs=sec.games==="all"?[]:[...sec.games]; gs=gs.includes(v)?gs.filter(g=>g!==v):[...gs,v]; sec.games=gs.length?gs:"all"; break; }
case "limit": sec.limit=+v; break;
case "sort": sec.sort=v; break;
case "within": sec.within=+v; break;
case "moregames": UI.moreGames=!UI.moreGames; break;
case "move": { const i=S.layout.indexOf(sec), j=i+(+v); if(j>=0&&j<S.layout.length){ S.layout.splice(i,1); S.layout.splice(j,0,sec);} break; }
case "hide": snapshot(); sec.hidden=true; UI.pop=null; toast(`Hid ${sec.title}. Bring it back from Add a section or Settings, Layout.`); break;
case "showsec": sec.hidden=!sec.hidden; break;
case "editsec": sec.hidden=false; S.tab="checklist"; S.view="main"; UI.pop=s; UI.screen="main"; break;
case "more": S.expanded[v]=!S.expanded[v]; break;
case "due": if(v==="__chores"){ const d=S.layout.find(x=>x.id==="dailies"); if(d.hidden){d.hidden=false;toast("Showing Today's dailies so you can tick them",false);} else document.getElementById("sec-dailies")?.scrollIntoView({behavior:"smooth",block:"center"}); break; }
if(v.startsWith("ev")){ S.rowOpen=v; break; } S.editing=S.editing===v?null:v; break;
case "saveamt": { const n=+document.getElementById("ed-in").value||0; S.progress[v]=n; S.editing=null; break; }
case "tick": S.progress[v]=S.progress[v]?0:1; S.editing=null; break;
case "unfollow": snapshot(); S.follow=S.follow.filter(f=>f!==v); S.own=S.own.filter(o=>o.id!==v); S.editing=null; toast("Stopped following. Find it again with Add."); break;
case "chore": S.choresDone=S.choresDone.includes(v)?S.choresDone.filter(g=>g!==v):[...S.choresDone,v]; break;
case "row": S.rowOpen=S.rowOpen===v?null:v; break;
case "mark": snapshot(); S.status[v]=s; S.rowOpen=null; toast(s==="done"?"Marked done, moved to Done & ignored":"Ignored, moved to Done & ignored"); break;
case "restore": snapshot(); delete S.status[v]; toast("Restored to Running now"); break;
case "astat": S.archiveStatus=v; break;
case "agame": S.archiveGame=v; break;
case "togglegame": snapshot(); S.games=S.games.includes(v)?S.games.filter(g=>g!==v):[...S.games,v]; break;
case "follow": { snapshot(); const r=ROUTINES.find(x=>x.id===v); if(!S.games.includes(r.game)) S.games.push(r.game);
if(S.follow.includes(v)){S.follow=S.follow.filter(f=>f!==v); toast(`Stopped following ${esc(r.title)}`);} else { S.follow.push(v); ensureKind(r.period.unit); toast(`Following ${esc(r.title)}, it's in Due next`);} break; }
case "bulk": { snapshot(); const ids=ROUTINES.filter(r=>r.game===v&&r.period.unit===s).map(r=>r.id); if(!S.games.includes(v)) S.games.push(v); S.follow=[...new Set([...S.follow,...ids])]; ensureKind(s); UI.pal=null; toast(`Following ${ids.length} ${G[v].short} ${s==="week"?"weeklies":s==="month"?"monthlies":"cycles"}`); break; }
case "choreon": snapshot(); if(!S.games.includes(v)) S.games.push(v); if(S.chores.includes(v)) S.chores=S.chores.filter(g=>g!==v); else { S.chores.push(v); ensureKind("daily"); const d=S.layout.find(x=>x.id==="dailies"); d.hidden=false; } toast(`${G[v].short} dailies ${S.chores.includes(v)?"added":"removed"}`); break;
case "mkown": { snapshot(); const t=UI.pal.q.trim()||"New task"; const g=S.games[0]||"genshin"; S.own.push({id:"own:"+Date.now(),game:g,title:t,kind:"weekly",period:{unit:"week",startsOn:1},measure:{kind:"days",required:1}}); ensureKind("own"); UI.pal=null; toast(`Created “${esc(t)}” for ${G[g].short}. It's in Due next and in Settings, Your own events`); break; }
case "undo": if(undoState){S=undoState;undoState=null;} UI.toast=null; break;
case "obgame": UI.ob.games=UI.ob.games.includes(v)?UI.ob.games.filter(g=>g!==v):[...UI.ob.games,v]; break;
case "obnext": UI.ob.step=2; break;
case "obback": UI.ob.step=1; break;
case "obplan": UI.ob.plan=v; break;
case "obdone": finishOnboarding(); break;
case "tournext": if(UI.tour.i<UI.tour.steps.length-1) UI.tour.i++; else UI.tour=null; break;
case "tourend": UI.tour=null; break;
}
render();
});
function ensureKind(unit){ const k={week:"weekly",month:"monthly",cycle:"cycle",daily:"daily",own:"own"}[unit]||unit; const d=S.layout.find(x=>x.id==="due"); if(d&&!d.kinds.includes(k)) d.kinds.push(k); }
function addSection(v){
const id=v+Math.floor(Math.random()*1e4);
if(v==="dailies"){ const d=S.layout.find(x=>x.id==="dailies"); d.hidden=false; toast("Showing Today's dailies"); return; }
const base={id,where:"rail",games:"all",limit:6,sort:"due"};
if(v==="cycles") S.layout.push({...base,title:"Endgame cycles",kinds:["cycle"]});
if(v==="own") S.layout.push({...base,title:"Your own",kinds:["own"]});
if(v==="game"){ const g=S.games[1]||S.games[0]||"zzz"; S.layout.push({...base,title:G[g].short,kinds:ALL_KINDS,games:[g],sort:"due"}); }
UI.pop=id; toast("Section added. Change it with Customise.");
}
function finishOnboarding(){
const o=UI.ob; const s=blankState(); s.games=[...o.games];
if(o.plan==="dailies"||o.plan==="all"){ s.chores=[...o.games]; s.layout[0].kinds=["event","daily","own"]; s.layout[1].hidden=false; }
if(o.plan==="all"){ s.follow=ROUTINES.filter(r=>o.games.includes(r.game)).map(r=>r.id); s.layout[0].kinds=[...ALL_KINDS]; }
S=s; UI.ob=null; UI.screen="main"; render(); UI.tour={steps:TOUR_NEW,i:0,back:false};
}
function go(k){
UI.pop=null; UI.pal=null; UI.ob=null; UI.tour=null; UI.addMenu=false; UI.toast=null; UI.screen=k;
if(k==="main"){ S.tab="checklist"; S.view="main"; }
if(k==="add"){ S.tab="checklist"; S.view="main"; UI.pal={q:"zzz weekly"}; }
if(k==="settings"){ S.tab="settings"; }
if(k==="archive"){ S.tab="checklist"; S.view="archive"; }
if(k==="onboard"){ S=blankState(); UI.ob={step:1,games:["genshin","zzz","hsr"],plan:"dailies"}; }
if(k==="return"){ S=demoState(); render(); UI.tour={steps:TOUR_BACK,i:0,back:true}; }
render();
}
document.addEventListener("input",(ev)=>{ if(ev.target.id==="palq"){ UI.pal.q=ev.target.value; const r=document.getElementById("palres"); const tmp=document.createElement("div"); tmp.innerHTML=palHtml(); r.innerHTML=tmp.querySelector("#palres").innerHTML; } });
document.addEventListener("keydown",(ev)=>{
if(ev.key==="/"&&!UI.pal&&!UI.ob&&!/input|textarea/i.test(document.activeElement.tagName)){ ev.preventDefault(); UI.pal={q:""}; render(); }
if(ev.key==="Escape"){ UI.pal=null; UI.pop=null; UI.addMenu=false; UI.tour=null; render(); }
});
window.addEventListener("resize",()=>{ if(UI.tour) render(); });
render();
</script>
</body>
</html>
@@ -0,0 +1 @@
{"reason":"stop-server.sh","timestamp":1792128694}
+26 -45
View File
@@ -56,7 +56,7 @@ falling back to checked-in fixtures.
## Three constraints that shape everything ## Three constraints that shape everything
1. **No accounts, no logins, no user records.** Completion state lives in the browser's 1. **No accounts, no logins, no user records.** Completion state lives in the browser's
`localStorage`, keyed by event ID. There is no user table and no session. Any request implying `localStorage`, in a v2 envelope keyed by local game/server account and unchanged event ID. There is no user table and no session. Any request implying
"sync across devices" is solved with export/import JSON, not a server-side user. "sync across devices" is solved with export/import JSON, not a server-side user.
2. **No LLM in the pipeline.** Event data is extracted by deterministic code-based parsers only. 2. **No LLM in the pipeline.** Event data is extracted by deterministic code-based parsers only.
There is no Anthropic dependency, no API key, and no per-run inference cost. A source that There is no Anthropic dependency, no API key, and no per-run inference cost. A source that
@@ -133,6 +133,15 @@ cycle:
run `bun run build:feed`, which builds from snapshots and fixtures) before serving. A worktree run `bun run build:feed`, which builds from snapshots and fixtures) before serving. A worktree
also needs its own `bun install`. also needs its own `bun install`.
**Browser verification must include an ordinary HTTP network hostname, not only localhost.**
Localhost is a secure context even over HTTP, so it conceals API restrictions that affect LAN and
WireGuard addresses. On 2026-10-16 the redesign called `crypto.randomUUID()` during first-visit
migration: it was undefined on HTTP `10.20.20.2`, and startup mislabeled that TypeError as a saved-data
failure. Browser-local IDs now come from `state/reader/ids.ts` using 128-bit `crypto.getRandomValues`,
which works on HTTP too. Use that helper for accounts, operations, clock revisions and backups;
never remint existing identities. A Chrome test hostname mapped to loopback reproduces the HTTP
security boundary without contacting another machine. Test startup and subsequent writes there.
**`build:js` passes `--production`, and that flag is load-bearing.** Without it Bun bundles React's **`build:js` passes `--production`, and that flag is load-bearing.** Without it Bun bundles React's
*development* build: 566 KB rather than 344 KB (164 KB vs 103 KB gzipped), every element creation *development* build: 566 KB rather than 344 KB (164 KB vs 103 KB gzipped), every element creation
validated at runtime, and `StrictMode` double-invoking effects — which in this app means validated at runtime, and `StrictMode` double-invoking effects — which in this app means
@@ -167,8 +176,9 @@ src/ingest/ html.ts, dates.ts (sixteen formats), merge.ts, sanitize.ts, ro
parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts, parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts,
stellasora.ts, arustats.ts, karendar.ts — keyed by SITE, not game stellasora.ts, arustats.ts, karendar.ts — keyed by SITE, not game
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
src/client/ React app, service worker, manifest src/client/ ReaderApp (shared interfaces), components/redesign/, service worker, manifest
state/ progress, daily log, ignores, prefs, sort — all localStorage state/reader/ active v2 envelope, accounts/clocks, migration, archive, backup/restore
state/ legacy v1 helpers, progress validators, prefs normalization, sort
gameOrder.ts — the reader's game order, and the A–Z fallback; pure gameOrder.ts — the reader's game order, and the A–Z fallback; pure
useCustom.ts — the reader's own games and events (PRD F13) useCustom.ts — the reader's own games and events (PRD F13)
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
@@ -1228,48 +1238,19 @@ to an open page). Four things hold it up:
keeps a retired source's slot warm rather than pruning it. `games` itself is left in feed order on keeps a retired source's slot warm rather than pruning it. `games` itself is left in feed order on
purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it, so ordering it at source purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it, so ordering it at source
would let a display preference reach the code that hides a reader's games. would let a display preference reach the code that hides a reader's games.
- **Reordering is a settings-only affordance.** The focus bar and the dailies strip are the fastest - **Reordering is a settings-only affordance.** Your games provides ordinary ↑↓ buttons for
tap targets in the app, and a drag target on top of a tick target costs somebody a streak the first keyboard, screen reader and touch. Live focus/daily tick targets are never draggable.
time it misfires — so the live surfaces are never draggable. Both a handle and ↑↓ buttons ship, - **Settings uses named pages.** General, Your games, Appearance, Lists, Region & time,
because touch fires no drag events at all: the arrows are the mechanism and the drag is the pointer Data & sources, Backup, About Event Clock and Changelog. Keep the complete colophon in About;
fast path, and being ordinary buttons is what makes the whole thing reachable by keyboard and Changelog has its own page and no footer link. The footer always states data age and links About.
screen reader without a second implementation of the same interaction. Reset overrides live per game/server; display timezone is a separate preference.
- **The settings panel is groups that state their own answer, and they ship closed.** Six - **Both interfaces share the reader.** Structured (default, Round 6 B rail) and Relaxed (Round 7)
`<details>` groups — games, server region, appearance, what you see, your own, your progress — each are CSS/layout choices over one feature tree. Theme, palette and row density are independent.
with its current state on the summary line (`Europe`, `17 of 18 on · A–Z`, `plus finished, not Do not mount the old v1 persistence hooks alongside ReaderApp's v2 store. See DATA-MODEL for
started`). It was one open block in two columns, which is readable at four games and not at account IDs, clock revisions, migration recovery, backups and export validation.
eighteen, where the game list is eighteen rows of four controls sitting above the checkbox somebody - **A game's routines stay together.** The separate daily panel keeps direct ticks; grouped
came to tick. The state line is the half that makes collapsing honest: without it every question routines use per-game/server summaries. Collapse policy is configurable and focus can expand a
about how the app is set up costs a click, so the closed panel has to be a report and not a menu. group without changing that saved policy. Timeline reset strips sit next to their game's events.
Three rules follow. **Do not default a group open** to make something inside it findable — the empty
states name the group as well as the switch, which is what makes that unnecessary. The summaries are
**derived from `prefs` at render**, never stored, so they cannot drift from the controls they
describe. And **one group answers one question**, which is what the sixth group is: the region and
the theme were a group called `Reading`, grouped on "both are how do I read this?" and summarising
as `Europe · Dark` — two unrelated answers joined by a dot, under a name for neither of them, in a
panel whose whole premise is that a closed line answers *its* group. The region is not a reading
preference: region-scoped ends and every daily reset are cut on that server's clock (§ Domain
rules), so it is the one control here that can make a countdown wrong, and it is now a line of its
own rather than filed behind a word for the theme.
Native `<details>` for the reason the reorder arrows are ordinary buttons: keyboard and screen
reader reach it without a second implementation. `summary` is not an `a`, `button` or `[tabindex]`,
so it needs its own `:focus-visible` rule in `styles.css` — the shared one does not reach it.
- **The panel is one column of groups, and it uses the page's width.** Those are two claims and only
the first survived a desktop read: the rows were held to `max-w-3xl` inside a shell a third wider,
so every rule inside the panel stopped short of the panel's own, and it was the one block on a wide
page not using the width — between a two-column checklist and a three-column footer that both do.
A name at one end of a row and its state at the other is what "running now" already does with a
title and its countdown, so full width is the house pattern rather than a stretch. Prose inside a
group keeps its own measure, and the game list — the one thing tall enough to need it — takes **two
columns of its own past `lg` once there are nine or more games**, flowing down then across so the
numbers still read in a straight line and an arrow still swaps a row with its neighbour. Eighteen
rows in one column was a 1,000px ribbon with two thirds of the width empty beside it.
- **A game's dailies stay together, and grouping never re-sorts inside a group.** `dailyGroups`
(`Dailies.tsx`) emits a game's standing chore followed by that game's repeating events, in the
order those arrived; it replaced `[...chores, ...repeating]`, which put a game's chore and its own
login event at opposite ends of the strip. Collapsed, the grouping is **adjacency only** — no
per-game headings, because that strip is the part of the page answerable in ten seconds and a
heading per game makes it the tallest block on it, pushing "next to expire" down the page.
- **A day already gone is still tickable, and the window bounds display and never data.** People play - **A day already gone is still tickable, and the window bounds display and never data.** People play
at midnight and tick at breakfast, so `catchUpDays` (`src/shared/daily.ts`) offers the last at midnight and tick at breakfast, so `catchUpDays` (`src/shared/daily.ts`) offers the last
`CATCH_UP_DAYS` game-days for a standing chore and for an event whose end was never announced — `CATCH_UP_DAYS` game-days for a standing chore and for an event whose end was never announced —
+34 -3
View File
@@ -10,9 +10,12 @@ note only says where to start.
([spec](docs/superpowers/specs/2026-10-14-built-in-routines-design.md)), every-N-days cycles for ([spec](docs/superpowers/specs/2026-10-14-built-in-routines-design.md)), every-N-days cycles for
tasks and built-ins ([spec](docs/superpowers/specs/2026-10-16-cycle-routines-design.md)), and tasks and built-ins ([spec](docs/superpowers/specs/2026-10-16-cycle-routines-design.md)), and
battle pass windows as feed events ([spec](docs/superpowers/specs/2026-10-16-pass-windows-design.md), battle pass windows as feed events ([spec](docs/superpowers/specs/2026-10-16-pass-windows-design.md),
see its § Outcome). Plans are beside them in `docs/superpowers/plans/`. Nothing is in flight: no see its § Outcome). Plans are beside them in `docs/superpowers/plans/`. The redesign attempt is uncommitted and was rejected for substantial markup divergence.
open branches or worktrees. **Start with [the replacement-agent handoff](docs/handoffs/2026-10-16-redesign-fidelity-handoff.md).**
- **No next task has been chosen.** The open items below are the candidates; the owner decides. The existing plan/ledger contain superseded completion claims; no extra branch/worktree.
- **Redesign rejected for markup divergence; presentation correction pending.** See docs/design-reviews/2026-10-16-redesign-implementation.md.
The assigned follow-up is to faithfully port the approved markup and finish the redesign.
Unrelated open items below remain separate.
The ones that need the owner in the game first are marked as checks. The ones that need the owner in the game first are marked as checks.
- **How the owner works** (their standing instructions, also in their personal Claude settings): - **How the owner works** (their standing instructions, also in their personal Claude settings):
once they approve a design, carry it through spec → plan → execution with no extra review stops, once they approve a design, carry it through spec → plan → execution with no extra review stops,
@@ -30,6 +33,17 @@ note only says where to start.
## Open items ## Open items
- [ ] **Redesign Settings → Your own events and games** (owner, 2026-10-17: “the split-up from
the mockup is nice, but I guess it needs a bigger design”). Deferred on purpose: it works as well
as it does today, which is enough for now. Direction from the owner: a heading per game you
created, with that game's events listed under it, taking inspiration from the Routines tab —
possibly reusing the routine editor (`components/redesign/RoutineDrawer.tsx`, the right-docked
create/edit shelf) as the base for an event editor. Not in the Round 6/7 mockups, so it needs its
own short design (brainstorm → spec → plan; owner approves once, then carry it through). Keep the
current behaviour (`components/YourOwn.tsx`) working until the replacement is accepted.
- Owner, same day, an alternative to weigh in that design: drop the separate settings page and
put an **Add** button on the Settings sidebar's “Your games” list, opening an event editor
there (a new game, or a new event under a game).
- [ ] Make it clear which game each *This week & month* chip belongs to (owner, 2026-10-16: "it can - [ ] Make it clear which game each *This week & month* chip belongs to (owner, 2026-10-16: "it can
become a bit unclear"). With built-ins for seven games, that row now holds many chips whose become a bit unclear"). With built-ins for seven games, that row now holds many chips whose
titles do not name their game — "Weekly Routine", "Weekly Challenge", "City Stamina", titles do not name their game — "Weekly Routine", "Weekly Challenge", "City Stamina",
@@ -235,3 +249,20 @@ note only says where to start.
`EventForm`, `src/client/components/TaskForm.tsx`). Review them as a reader would — field `EventForm`, `src/client/components/TaskForm.tsx`). Review them as a reader would — field
order, defaults, copy, validation messages, how a cadence and a measure are chosen, phone order, defaults, copy, validation messages, how a cadence and a measure are chosen, phone
width — against `docs/DESIGN.md` and `docs/PRODUCT.md`. width — against `docs/DESIGN.md` and `docs/PRODUCT.md`.
- [ ] Follow up the 2026-10-18 pre-ship review of the v1→v2 reader migration. A schema-guardian review
found nothing blocking: event IDs, day keys and the feed contract are unchanged, and v1 keys are
never removed, so a rollback finds them byte-identical. These gaps remain, all low severity:
- Orphaned v1 entries (marks or amounts for a deleted `myevent:`/`mytask:`, or any entry whose game
can't be resolved) are not carried into v2 account data (`state/reader/migration.ts`,
`gameOf`). They survive in `legacyRaw`, `gacha-tracker:v2:migration-recovery` and the untouched v1
keys. Consider a catch-all bucket so a restored custom item finds them.
- Importing a v1 file whose region differs from the reader's account lands its marks in a new
`account:import-N`, not the visible account (`state/reader/import.ts`, `export.ts`). Kept, but
out of sight.
- `initializeReader` reconciles and writes back outside `withReaderLock`, with no compare-and-set
(`state/reader/store.ts`). A second tab's write in that window can be overwritten.
- Storage pressure: every save carries `legacyRaw`, and every automatic backup embeds the state,
`legacyRaw` and the v1 keys again, with no size cap (only manual backups are capped). Heavy
readers could reach the ~5 MB quota. Failures are reported, not silent.
- After a rollback, anything done in the new build lives only in v2 and isn't shown by the old app.
It's stranded, not lost.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Event Clock · Three layouts</title><link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet"><style>
:root{color-scheme:dark;--ground:#0e1117;--panel:#141923;--rule:#323b4e;--ink:#e4e7ef;--muted:#afb8c7;--accent:#8eaff6}*{box-sizing:border-box}body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.65 Barlow,system-ui,sans-serif}main{max-width:1000px;margin:0 auto;padding:64px 28px}h1{font:600 44px/1.1 "Barlow Condensed",sans-serif;margin:0 0 20px}h2{font:600 28px/1.2 "Barlow Condensed",sans-serif;margin:0 0 10px}p{max-width:70ch;color:var(--muted);margin:12px 0}a{color:var(--accent);text-underline-offset:4px}:focus-visible{outline:2px solid var(--accent);outline-offset:5px}.design{display:grid;grid-template-columns:54px 1fr auto;gap:24px;align-items:start;padding:32px 0;border-top:1px solid var(--rule)}.letter{font:600 34px "Barlow Condensed",sans-serif;color:var(--muted)}.design .open{align-self:center;padding:10px 16px;border:1px solid var(--rule);text-decoration:none;white-space:nowrap}.tradeoff{font-size:14px}.designs{margin:36px 0}.notes{border-top:1px solid var(--rule);padding-top:24px;font-size:14px}.notes h2{font-size:23px}@media(max-width:650px){main{padding:36px 20px}h1{font-size:36px}.design{grid-template-columns:32px 1fr;gap:16px}.design .open{grid-column:2;justify-self:start}.design h2{font-size:26px}}
</style></head><body><main><h1>Give the events room.</h1><p>Three complete layouts for Event Clock. Each keeps the agreed routines, Archive, settings and onboarding, while moving focus and freshness controls out of the way of the event list.</p><p>Compare the same busy calendar in each. The top review bar can simulate older data, open onboarding and reset the sample state.</p><div class="designs">
<section class="design"><span class="letter">A</span><div><h2>Inline desk</h2><p>Game focus beside the list heading. A small freshness line with Details. Your one-click dailies and pinned routines stay in the familiar left rail.</p><p class="tradeoff">The smallest change to the layout you already liked; retains two columns.</p></div><a class="open" href="a-inline.html">Open A</a></section>
<section class="design"><span class="letter">B</span><div><h2>Game desk</h2><p>Game focus, daily ticks and source status share the left rail. The event heading stays clear, with more attention on the list itself.</p><p class="tradeoff">Controls have one predictable home; the rail holds more, and stacks before events on a phone.</p></div><a class="open" href="b-rail.html">Open B</a></section>
<section class="design"><span class="letter">C</span><div><h2>Open ledger</h2><p>A wide event list with a quick daily strip. Other pinned routines open above the list, with your choice of collapsed, expanded or always open.</p><p class="tradeoff">Most space for event titles; daily and routine panels scroll with the list.</p></div><a class="open" href="c-open.html">Open C</a></section>
</div><section class="notes"><h2>Shared changes to try</h2><p>Routines → Checklist display now controls the daily panel and group expansion. Numeric fields keep their native one-step arrows.</p><p>Settings includes Changelog as its own page, custom interface palettes, per-game source links and thanks, reset-time overrides with Automatic, and clearly labelled backup previews. Upcoming events is part of What do you want to track.</p><p>Returning user opens a guided tour in your preferred view. Take it or keep browsing; your progress stays intact.</p><p>This is a review prototype. Codes, source health and historical data are illustrative. The production app and earlier mockups are unchanged.</p><p><a href="../round5/converged.html">Compare with Round 5</a></p></section></main></body></html>
File diff suppressed because it is too large. Load diff
+53
View File
@@ -364,3 +364,56 @@ other, so `test/update.test.tsx` pins both ends of the `skip-waiting` handshake
cleanly because state is in SQLite. cleanly because state is in SQLite.
- **No server-side rendering.** The feed is small and cacheable; a static SPA is enough. - **No server-side rendering.** The feed is small and cacheable; a static SPA is enough.
- **No websockets.** Events change on a scale of hours; a 5-minute cache is more than adequate. - **No websockets.** Events change on a scale of hours; a 5-minute cache is more than adequate.
## Reader application (2026-10-16 redesign)
`App.tsx` exports `ReaderApp`; only the v2 reader store writes production progress. Legacy hooks
remain as compatibility helpers, validators and tests, not mounted writers. ReaderContent coordinates
shared Checklist/Timeline/Routines/Archive/Settings, account-aware detail adapters and timed archive
transitions. `components/redesign/` contains the feature pages. `redesign.css` scopes Relaxed and
Structured via data-interface, with one application tree and independent theme/density.
`state/reader/` owns migration, schema, reducer, store, clocks, accounts, archive, backups, restore,
imports, routines and event selectors. Operations re-read the persisted envelope under Web Locks;
an IndexedDB readwrite transaction serializes browsers without Web Locks. Failed locking/storage
never publishes an optimistic success. Storage events update other tabs. Field timestamps protect
newer edits and tombstones; account writes rebase changed routine choices. Legacy deltas reconcile
on startup into the original migrated accounts; raw keys remain preserved.
Clock revisions freeze automatic metadata and overrides. Imported historical clocks are excluded
from active clock resolution; history resolves each explicit revision. Display timezone lives in
DisplayTimeContext and never changes the feed.
Events and redeem codes use separate validated static feeds. `build-codes.ts` reads pinned snapshots
or fixtures offline and preserves firstObservedAt in snapshots/codes-first-seen.json. The refresh
runner combines event adapters and count-only code sources while sharing robots, floors, per-host
spacing and snapshot gates. No fake GachaEvents represent codes. Both feeds are network-first in
the worker; manual refresh bypasses the browser cache. Shell update waiting/activation is unchanged.
Backups are outside the reader envelope. Restore validates first, writes a recovery export, prepares
a pending transaction, then replaces live state. Startup finishes an interrupted transaction before
opening the reader. Browser UI restore uses the same cross-tab lock as ordinary edits.
### Ordinary HTTP / WireGuard hosts
Reader identity minting uses `state/reader/ids.ts`: 128-bit `crypto.getRandomValues` tokens for
accounts, operations, reset revisions and backups. The redesign's initial `randomUUID` calls were a
startup bug on ordinary HTTP network hosts; unlike localhost, these are not browser secure contexts,
even when reached through a WireGuard tunnel. Existing IDs are unchanged and valid. Initialization
preserves migration recovery before minting, so the failed first visit did not discard legacy bytes.
Regression verification uses a Chrome test hostname mapped to loopback with isSecureContext false,
randomUUID unavailable and actual getRandomValues/IndexedDB available. Existing-save migration,
new setup, editing, additional servers, reset override/revert, backup and reload all pass there.
### Presentation correction (2026-10-17)
`ChecklistEvent`/`NextDeadline` render the approved list hierarchy; `TimelinePage` owns the shared
axis for event spans and routine periods. `RoutinesPage` groups by reset or account, with native
`RoutineEditor` dialogs. `ArchiveDetail` reads saved archive snapshots and never routes historical
progress through the live event editor. Settings keeps one shared tree for both interfaces, with
separate default-view/custom-content pages and direct game profiles. Structured is the absent-value
default at prepaint, preference normalization, setup and appearance application. No state keys,
event identities or stored clock revisions changed for this presentation correction.
+49 -1
View File
@@ -244,7 +244,10 @@ CREATE TABLE snapshots (
`region_ends` and `payload` hold JSON as TEXT; parse them through the Zod schema on read so a `region_ends` and `payload` hold JSON as TEXT; parse them through the Zod schema on read so a
malformed row surfaces at the boundary rather than deep in the UI. malformed row surfaces at the boundary rather than deep in the UI.
## Client-side storage ## Legacy client-side storage (v1 compatibility)
Production uses the [active v2 reader envelope](#active-reader-state-v2-2026-10-16). The keys and
merge rules in this section describe migration input and retained compatibility helpers.
Namespaced, versioned, and small. Nothing here ever goes to the server. Namespaced, versioned, and small. Nothing here ever goes to the server.
@@ -772,3 +775,48 @@ Two timestamp fields distinguish crawler activity from site updates: `lastConfir
the crawler last successfully contacted and verified the source (HTTP 200 or 304 Not Modified), and the crawler last successfully contacted and verified the source (HTTP 200 or 304 Not Modified), and
`contentChangedAt` records when the served bytes last changed. Both default to `null` so feeds built `contentChangedAt` records when the served bytes last changed. Both default to `null` so feeds built
before their introduction continue to validate. before their introduction continue to validate.
## Active reader state (v2, 2026-10-16)
The localStorage/export descriptions above document legacy v1 compatibility. Production mounts
only `ReaderApp` and persists `gacha-tracker:v2:reader`, validated by state/reader/schema.ts.
One stable `account:<id>` represents one tracked game/server; it is browser-local, not a login.
Original event/task/daily IDs remain unchanged inside account data. Account data contains progress,
ignored, and daily/amount maps nested by clock revision. A revision freezes effectiveAt, region,
reset override and automaticBase (offset/hour/minute). Changes append revisions; imported clocks
can be historicalOnly and never replace the local active reset.
The envelope includes accounts, data, archive, seenEvents, prefs, customGames/customEvents/tasks,
codeSeen (per game), codeRedeemed (per account), and eventSeen (per account). Archive/seen keys are
JSON.stringify([accountId,eventId]); archive snapshots retain event, outcome/time, resolved end,
clockRevision, progress, days and amounts. Account/revision cross-references are validated before
restore. Field timestamps and operation IDs rebase writes and preserve deletion tombstones. Unknown
fields pass through exports; progress imports retain opaque root/account extensions in
importedExtensions as well as mergeable known fields. Existing same-server settings remain local.
Migration preserves raw v1 keys in place and in migration recovery, creates one account per game at
the saved region and maps old follows/pins/logs. Newly offered games remain off. Startup compares
legacyRaw with surviving v1 keys to carry older-tab deltas into the original account without
resurrecting newer v2 deletions. Parser aliases carry progress, ticks, amounts and archive/seen IDs.
Keys outside the envelope:
- gacha-tracker:v2:migration-recovery — raw pre-migration bytes.
- gacha-tracker:v2:backups — at most 7 daily + 4 weekly + 6 monthly UTC bucket snapshots.
- gacha-tracker:v2:restore-recovery — the complete pre-restore export.
- gacha-tracker:v2:restore-pending — prepared replacement, resumed before mounting on startup.
Exports use format gacha-tracker-export, version 2, exportedAt, reader and legacy. Progress exports
mark scope progress; full imports preview replacement, progress imports preview a merge. Backups
are never recursively included. Original v1 export envelopes remain convertible.
Prefs add interface (structured/default or relaxed), density (standard/default or compact), scheme
(slate/warm/plum/custom), separate light/dark customPalette, per-game gameColors, timezone
(null/Automatic or IANA), checklist daily placement/expansion and redesignTourSeen. Main view remains
soon or timeline; utility navigation is transient. Future/unreadable records remain exportable even
when this build cannot offer editing controls.
The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`)
for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the
interface default as of the owner's 2026-10-17 instruction; explicit saved Relaxed is preserved.
+137 -31
View File
@@ -2,84 +2,84 @@
name: Event Clock name: Event Clock
description: Live and upcoming gacha events, sorted by what expires next. description: Live and upcoming gacha events, sorted by what expires next.
colors: colors:
ground: "#12141c" ground: "#1a1c24"
surface: "#1b1e2a" surface: "#22252f"
raised: "#232739" raised: "#30333c"
hairline: "#2c3145" hairline: "#43454c"
ink: "#e9ebf3" ink: "#eeeae3"
ink-strong: "#ffffff" ink-strong: "#ffffff"
muted: "#888fa6" muted: "#b1afad"
faint: "#5a6178" faint: "#9c9b9b"
scrim: "rgb(18 20 28 / 0.8)" scrim: "rgb(18 20 28 / 0.8)"
glow: "#1a1e2c" glow: "#1a1e2c"
calm: "#55607f" calm: "#55607f"
near: "#5b8def" near: "#8eb3f4"
soon: "#e8b33c" soon: "#e9b95b"
critical: "#ff4d6a" critical: "#ff8598"
ground-light: "#edf0f7" ground-light: "#f3f1ec"
surface-light: "#ffffff" surface-light: "#fffdf8"
raised-light: "#dfe4f0" raised-light: "#f0eeea"
hairline-light: "#c9d1e2" hairline-light: "#dddbd8"
ink-light: "#151824" ink-light: "#28272d"
ink-strong-light: "#000000" ink-strong-light: "#000000"
muted-light: "#535b73" muted-light: "#69676a"
faint-light: "#767e94" faint-light: "#6e6d6e"
scrim-light: "rgb(26 31 48 / 0.42)" scrim-light: "rgb(26 31 48 / 0.42)"
glow-light: "#ffffff" glow-light: "#ffffff"
calm-light: "#8a92a8" calm-light: "#8a92a8"
near-light: "#2b62c6" near-light: "#315fa5"
soon-light: "#8a6008" soon-light: "#805a12"
critical-light: "#c81e3c" critical-light: "#b12947"
typography: typography:
numeral: numeral:
fontFamily: "Chakra Petch, ui-sans-serif, system-ui, sans-serif" fontFamily: "Bricolage Grotesque, ui-sans-serif, system-ui, sans-serif"
fontSize: "2.75rem" fontSize: "2.75rem"
fontWeight: 700 fontWeight: 700
lineHeight: 1 lineHeight: 1
letterSpacing: "-0.025em" letterSpacing: "-0.025em"
fontFeature: "\"tnum\" 1" fontFeature: "\"tnum\" 1"
display: display:
fontFamily: "Chakra Petch, ui-sans-serif, system-ui, sans-serif" fontFamily: "Bricolage Grotesque, ui-sans-serif, system-ui, sans-serif"
fontSize: "1.75rem" fontSize: "1.75rem"
fontWeight: 600 fontWeight: 600
lineHeight: 1.15 lineHeight: 1.15
letterSpacing: "-0.025em" letterSpacing: "-0.025em"
wordmark: wordmark:
fontFamily: "Chakra Petch, ui-sans-serif, system-ui, sans-serif" fontFamily: "Bricolage Grotesque, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.9375rem" fontSize: "0.9375rem"
fontWeight: 700 fontWeight: 700
letterSpacing: "0.02em" letterSpacing: "0.02em"
reading: reading:
fontFamily: "Chakra Petch, ui-sans-serif, system-ui, sans-serif" fontFamily: "Bricolage Grotesque, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.875rem" fontSize: "0.875rem"
fontWeight: 600 fontWeight: 600
fontFeature: "\"tnum\" 1" fontFeature: "\"tnum\" 1"
title: title:
fontFamily: "Public Sans, ui-sans-serif, system-ui, sans-serif" fontFamily: "Figtree, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.9375rem" fontSize: "0.9375rem"
fontWeight: 500 fontWeight: 500
lineHeight: 1.375 lineHeight: 1.375
body: body:
fontFamily: "Public Sans, ui-sans-serif, system-ui, sans-serif" fontFamily: "Figtree, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.875rem" fontSize: "0.875rem"
fontWeight: 400 fontWeight: 400
lineHeight: 1.625 lineHeight: 1.625
summary: summary:
fontFamily: "Public Sans, ui-sans-serif, system-ui, sans-serif" fontFamily: "Figtree, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.8125rem" fontSize: "0.8125rem"
fontWeight: 400 fontWeight: 400
lineHeight: 1.375 lineHeight: 1.375
control: control:
fontFamily: "Public Sans, ui-sans-serif, system-ui, sans-serif" fontFamily: "Figtree, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.75rem" fontSize: "0.75rem"
fontWeight: 500 fontWeight: 500
label: label:
fontFamily: "Chakra Petch, ui-sans-serif, system-ui, sans-serif" fontFamily: "Bricolage Grotesque, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.6875rem" fontSize: "0.6875rem"
fontWeight: 600 fontWeight: 600
letterSpacing: "0.14em" letterSpacing: "0.14em"
tag: tag:
fontFamily: "Public Sans, ui-sans-serif, system-ui, sans-serif" fontFamily: "Figtree, ui-sans-serif, system-ui, sans-serif"
fontSize: "0.625rem" fontSize: "0.625rem"
fontWeight: 500 fontWeight: 500
rounded: rounded:
@@ -170,7 +170,11 @@ components:
# Design System: Event Clock # Design System: Event Clock
## Overview The active Relaxed/Structured layout and palette contract is recorded in
[Active redesign system](#active-redesign-system-2026-10-16). The component rationale below describes
the preceding instrument-panel system; use the active section and live token resolver for new work.
## Original component rationale
**Creative North Star: "The Lit Instrument Panel"** **Creative North Star: "The Lit Instrument Panel"**
@@ -528,3 +532,105 @@ rather than being toned down.
chooses. chooses.
- **Don't** put a second control inside a row, or a drag target on the focus bar or the dailies - **Don't** put a second control inside a row, or a drag target on the focus bar or the dailies
strip. strip.
## Shipped redesign system (2026-10-18)
The owner accepted this build as the initial redesign on 2026-10-18, after screen-by-screen review
against the approved mockups (`design/mockups/round6/b-rail.html` for Structured,
`design/mockups/round7/converged.html` for Relaxed) and the corrections recorded round by round in
`docs/handoffs/2026-10-17-redesign-second-rejection-handoff.md`. This section supersedes the original
shell/type/palette/layout descriptions above. Those older sections remain the rationale for the detail,
timeline and uncertainty components that carried over. Overrides live in `src/client/redesign.css`
and `src/client/state/appearance.ts`.
### Shell and layout
- **Interfaces.** Settings → Appearance offers Structured (the Round 6 B rail; the default for new
installs and missing preferences) and Relaxed (Round 7). A saved Relaxed choice stays Relaxed.
Structured uses Barlow Condensed/Barlow and adjoining ruled sections. Relaxed uses Bricolage
Grotesque/Figtree with separated panels. Fonts keep system fallbacks offline.
- **Navigation.** The tabs are Checklist, Timeline, Routines and Archive, with Settings on the right.
The EVENTCLOCK logo returns to the default view. Each page is in the address bar (`#checklist`,
`#settings/games/genshin`, …), and changing tab starts at the top of the page.
- **Width.** The page fills up to 1920px (Relaxed 1840px) and centres beyond that.
- **Two insets on `.reader-ui`.** `--edge` (16px) between content and the page's outer edges.
`--gutter` (24px) where a side column (Checklist rail, Settings nav, Archive sidebar) meets the main
content. At ≤700px, where everything stacks, both are the edge.
- **Checklist rail.** 360px wide (Relaxed 380px; 280/300px at medium widths). Top to bottom: the
focus pills, Next to expire (headline, meter and the next three), Today's dailies, then pinned
routines grouped by game.
### Urgency and colour
- **Two axes, never crossed.** Game hue answers whose event: the 3px strip on the left of each
Standard row, the Compact game cell, the pills. The urgency ramp answers how long: countdowns and
meter ticks.
- **The urgency ramp is live's.** Grey for a week or more, blue under a week, yellow under 3 days, red
under a day: `--color-urgency-{calm,near,soon,critical}`, set by `urgencyTokens`. It is separate
from the accent, so a chosen accent never changes urgency.
- **Reader-editable.** Settings → Appearance → Urgency colours sets each step per theme
(`prefs.urgencyPalette`), with any scheme. Values are nudged only as far as 3:1 contrast, the
large-text bar.
- **Fallback for the live UI.** `URGENCY_COLOR` falls back to the old `--color-*` tokens, so the
live UI is unchanged.
- **Schemes.** Slate, Warm, Plum and Custom shift surfaces. Custom stores ground/surface/text/accent
per theme. Text and accent derive at least 4.5:1 contrast on ground and surface. Game colours are
set per game in Your games.
Default Slate tokens:
| Role | Structured dark | Structured light | Relaxed dark | Relaxed light |
| --- | --- | --- | --- | --- |
| Ground | #0e1117 | #eef0f5 | #1a1c24 | #f3f1ec |
| Surface | #141923 | #ffffff | #22252f | #fffdf8 |
| Primary text | #eeeae3 | #28272d | #eeeae3 | #28272d |
| Accent | #8eb3f4 | #315fa5 | #8eb3f4 | #315fa5 |
### Checklist
- **Heading.** "Running now" sits with the sort and density buttons on one row. Under them is one
line: the count on the left and live's legend on the right ("bars = time left · colour = ends in a
week+ / under a week / under 3 days / under a day").
- **Standard rows.** Game, title, optional description, then the countdown, date and meter, with the
chevron 10px from the date. Every row has a meter, with or without a description; the mockup hid
it on rows without one, and the owner chose consistency.
- **Compact rows.** A ledger with Game / Event / Time left / Ends in columns.
- **"Not started yet."** It opens a second section: a 48px gap, a 2px accent rule and a raised band
with a "Coming up" eyebrow. In Compact its columns read Duration / Starts in.
### Focus pills
They have live's chip feel: 30px tall, 12px text, a 30% hue border unpressed, the full hue with a 14%
wash when pressed. State changes ease over 130ms. Hover adds a hue ring and a 1px lift, a press
scales to 0.96, and games with nothing outstanding dim to 50%. The "Focus" label is a 12px body-face
legend on the same baseline as "Next: …".
### Ticking things off
- **Today's dailies.** The whole row is the target. A tick washes the row in its game's hue, pops the
box and draws the check. A pip bar and "N of M done" sit under the heading. A check-in goal (for
example "5/5 wk") counts as done once met, with a dotted check, and shows its goal count instead of
a streak.
- **Pinned routines** use the same card language. A box ticks a check-in routine, or completes an
amount routine in one click. "+1" steps an amount, and the count opens an editor. Typing a value at
or over the target completes it after a 450ms pause.
- **Completion animation.** The bar fills, the row warms, then the box pops. Unticking undoes it:
the amount from before goes back, or the editor opens when that value isn't known.
- **Celebration.** A single completion fires live's small `Fireworks` in the row. Finishing a whole
section (every daily, or every routine in a game's group) fires `Fireworks grand`: seven bursts in
two waves with the game's hue mixed in, a light sweep, and the "All done" line popping.
- **When it fires.** Only an action taken now that completes the item or section, never arriving
complete. Reduced motion removes all of it.
### Other pages
- **Routines.** Bands by reset (Dailies, Weeklies, Monthlies, Cycles) or by game. The table has tick
and click-to-edit amount cells. The library and create/edit drawers are shelves.
- **Timeline.** Two looks (new and classic) chosen in Appearance. Dailies are not on the timeline.
- **Settings.** About, Changelog, Data & sources (list options first), Region & time, Appearance
(interface, theme, scheme, urgency colours, default view, game order, timeline design, focus bar,
checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and
per-game Setup / Codes / About.
- **Returning-user tour.** Routines → Checklist → Timeline → Redeem codes → Archive → Settings.
Continue stays in a fixed place, Back sits beside it, and Skip is apart. "Replay the introduction"
starts it at step 1.
+19
View File
@@ -640,3 +640,22 @@ fixture is the regression test proving the parser still handles the previous for
guessing, impossible calendar dates are rejected, ranges crossing New Year roll the start year back, guessing, impossible calendar dates are rejected, ranges crossing New Year roll the start year back,
and abbreviated months parse. `test/merge.test.ts` covers cross-source agreement, disagreement, and and abbreviated months parse. `test/merge.test.ts` covers cross-source agreement, disagreement, and
rerun disambiguation. These are the last line of defense before a wrong date reaches a user. rerun disambiguation. These are the last line of defense before a wrong date reaches a user.
## Redeem codes source (2026-10-16)
Genshin codes use https://game8.co/games/Genshin-Impact/archives/304759, separately from events.
One descriptive-agent robots request and one page request returned HTTP 200 during capture; no
access workaround or repeated source fetch was used. The raw page and capture metadata are pinned
as fixtures/genshin/game8-codes-2026-10-16.*, with ten independently counted current codes. The
parser reads actual code inputs/current reward rows and excludes expired programme sections; a
future-dated programme fixture variant verifies that still-current programmes are admitted.
Unannounced expiry remains null. A printed calendar date is expiresOn, never a fabricated exact
expiresAt instant. Attribution is retained on every code.
src/ingest/codes/registry.ts registers a count-only refresh source. The existing runner shares robots,
six-hour floors, conditional requests, per-host spacing and snapshot gates across event/code sources.
Event adapter schemas are unchanged. scripts/build-codes.ts builds data/codes.v1.json offline from
snapshots or fixtures and preserves observation timestamps in snapshots/codes-first-seen.json.
Code newness is per game; redemption is per local game/server account and never clears newness by
itself. No code row is synthesized into the event feed.
+81 -103
View File
@@ -111,14 +111,9 @@ few pixels apart the board is being asked the one question it exists to answer a
reader to squint. It also gives a one-day event a bar to read and to tap rather than the 34px reader to squint. It also gives a one-day event a bar to read and to tap rather than the 34px
minimum every short bar collapses to. minimum every short bar collapses to.
**What has not started yet is shown to a new reader, and either can switch it** — `prefs.showUpcoming`, **Upcoming events are optional and off initially.** New setup asks “Show events that haven't
which governs **both views**: the board plots them, and the checklist keeps its "Not started yet" started” under “What do you want to track?”. The same saved answer governs Checklist and Timeline;
section (F2). One question, asked once, because they are the same events. It was off by default, Checklist separates them under “Upcoming events”. Existing saved choices survive migration.
because this app answers *what expires next* and a reader with fourteen lanes has a next patch queued
behind every one of them. Since 2026-10-13 (repository owner) first-run setup turns it **on**: a new
reader should see what is coming before deciding they do not want it. The stored default is still
`false`, so an existing install whose saved prefs predate the field keeps today's answer rather than
having it switched on underneath its reader.
On the board that is structural as well as editorial: the window is drawn from what is plotted, so On the board that is structural as well as editorial: the window is drawn from what is plotted, so
showing the future stretches the right edge weeks past today and squeezes the running bars the showing the future stretches the right edge weeks past today and squeezes the running bars the
@@ -129,10 +124,8 @@ quiet fortnight, and this app does not leave a reader to infer what it is not sh
page header states `N live · N upcoming` whatever the setting says, and either view left with page header states `N live · N upcoming` whatever the setting says, and either view left with
nothing to show says so in words and names the setting rather than rendering an empty column. nothing to show says so in words and names the setting rather than rendering an empty column.
The switch is in settings (F4), with the two other answers to *what am I allowed to look at* — The switch lives in Settings → Lists, because it decides which events enter both main views.
`showCompleted` and `showIgnored` — and **not** in the board's own header beside the stacking and Done and ignored events live in Archive; Lists has no redundant Archive link or reveal switches.
scale controls. That is the line those two draw: they reshape what is already on the board, which is
why they are reached for while reading it, and this one decides what is on it at all.
**Switched on, the board says in words where they begin.** A bar drawn to the right of the "now" **Switched on, the board says in words where they begin.** A bar drawn to the right of the "now"
rule is only *implicitly* in the future, and implicitly is not a standard this product holds itself rule is only *implicitly* in the future, and implicitly is not a standard this product holds itself
@@ -198,42 +191,17 @@ an explicit "show all N". This truncates the *view* only: the order is untouched
the cut are still counted in the header, still on the timeline, and one tap away. the cut are still counted in the header, still on the timeline, and one tap away.
**F3 — Mark completed.** **F3 — Mark completed.**
A toggle on every event, in both views. State is written to `localStorage` immediately and Done writes durable progress and a metadata snapshot immediately. A confirmation remains for about
optimistically — there is no server round trip and no failure case. Completed events stay visible 1.5 seconds before the row fades/collapses into Archive. Undo is available for eight seconds;
but de-emphasized; a filter toggles them out entirely. Archive Restore remains available afterwards. A failed write is disclosed and never presented as
saved. Reduced motion removes the fade.
**F4 — Filters.** **F4 — Settings and visibility.**
Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views. Settings has named pages: General, Your games, Appearance, Lists, Region & time, Data & sources,
Preferences persist in `localStorage`. Backup, About Event Clock, and Changelog. Your games controls visibility/order, tracking choices,
servers, reset overrides and colours; Lists controls upcoming events and timeline grouping.
The panel holding them is **six collapsed groups, each stating its own answer on the summary line** Routines owns follows and independent Checklist/Timeline pins. Utility navigation does not replace
— games, server region, appearance, what you see, your own games and events, your progress. It was the saved default main view. Empty states name where to change the relevant choice.
one open block of everything, laid out in two columns on a wide screen, which read fine at four games
and stopped working at eighteen: the game list alone is eighteen rows of four controls, and it sat
above the checkbox a reader had come down here to tick. Collapsing on its own would only trade that
friction for another, though — a group that shows nothing but its name turns *what is my region set
to?* into a click — so each summary answers its group's question, and the closed panel is a six-line
report of how the app is configured. Opening one is for changing an answer, not for reading it.
Three things follow. **The groups ship closed**, including the one holding the filters that other
copy points at: the empty states name both the switch and the group it is in, which is more findable
than a checkbox in a wall was. **The summary states are derived, never stored** — they are a reading
of `prefs`, so they cannot disagree with the controls inside. And **a group answers one question**.
The region and the theme began as one group called *Reading*, on the argument that both are "how do I
read this?" — which summarised as `Europe · Dark`, two unrelated answers joined by a dot, under a
name for neither. The region is not a reading preference: it is which server the reader's account is
on, and region-scoped ends, undated deadlines and every daily streak are read off that server's
clock, so it is the one control in this panel that can make a countdown wrong. It gets its own line,
and says as much when opened.
**The panel uses the page's width**, like every other block on it. The rows were held to a narrower
measure on the argument that a name and its state stop reading as one line across a wide screen, but
the panel's own rule spans the shell, so the rules inside it stopped a third of the column short and
the panel became the one thing on a desktop page not using the width — sitting between a two-column
checklist and a three-column footer that both do. A title at one end of a full-width row and its
countdown at the other is what "running now" does directly above it. Prose inside a group keeps a
readable measure, and the game list takes two columns of its own on a wide screen once there are nine
or more games, because eighteen rows in one column is a thousand pixels of ribbon down the left.
**F4a — Focus one game at a time.** **F4a — Focus one game at a time.**
Switching games on and off says *which games the reader plays*, and is set once. It is the wrong Switching games on and off says *which games the reader plays*, and is set once. It is the wrong
@@ -253,15 +221,26 @@ has left the feed is ignored rather than obeyed, so it can never strand the read
whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing
waiting says so before it is visited. waiting says so before it is visited.
**F5 — Region selection.** **F5 — Servers and display timezone.**
A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed Default server is chosen in setup and Region & time. Each locally tracked game/server has its own
end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a stable account identity and progress. “Add another server” is for simultaneously playing multiple
guess from the browser timezone, shown as a dismissible "showing America server times — change". servers: it starts fresh tracking choices and never clones marks. Server/reset changes append clock
revisions so history stays on its original clock. Overrides can revert to Automatic. Display timezone
is Automatic by default or a searchable IANA zone; it changes printed dates and manual date entry,
never a source's exact timestamps or game-day keys.
**F6 — Export / import.** **F6 — Export / import and backup.**
Because there are no accounts, moving between devices is manual: download a JSON file of completed No login is involved. Export progress / Import progress merges per game/server, retaining local
IDs and preferences, upload it elsewhere. Import merges rather than replaces, and never removes a preferences and active reset configuration. Day logs union; newer records/amounts win conflicts.
completion the user already has. Imported clock revisions remain accessible as historical clocks. Unknown extensions are retained in
the envelope. Export all / Import all restores the complete validated envelope through a preview,
with a recovery copy before replacement. Undo last restore reverses it. Version-1 exports remain
convertible; original v1 browser keys are retained after migration.
Local backup history keeps seven daily, four weekly and six monthly snapshots while the app is
used. UTC buckets keep their latest snapshot; backups never contain other backups. View backup
opens the preview; Restore this backup is the separate committing action. Storage failures are
visible. Browser deletion removes local history; export is the way to keep an external copy.
**F12 — Record your own progress and effort.** **F12 — Record your own progress and effort.**
Three states, not two: untouched, doing it, done. Plus an optional effort estimate — quick, short, Three states, not two: untouched, doing it, done. Plus an optional effort estimate — quick, short,
@@ -418,15 +397,16 @@ the one after it — because they answer "what ends soonest". The timeline draws
every occurrence in view, because it answers "what is the rhythm". every occurrence in view, because it answers "what is the rhythm".
**F8 — First-run setup.** **F8 — First-run setup.**
Before any events are shown, the reader picks which games they play, and how they want to read Before events are shown, a four-step wizard asks for games, default server and preferred appearance,
them. A calendar full of games they tracking kinds, and the default main view. Games are alphabetical, nothing selected. Events are the
don't play is worse than an empty one — it buries the thing they came for. Nothing is preselected only initially selected tracking kind. Dailies, weekly/monthly and cycles have short descriptions;
and the button stays disabled until something is chosen; guessing on their behalf and hoping they unavailable kinds remain selectable and say nothing is currently detected. Upcoming visibility
notice is worse than asking. **The games are listed alphabetically by name**, not in feed order: belongs to that tracking step and starts off. Server and appearance can be changed later.
everywhere else a lane's position carries meaning, but here the reader is scanning for the two or
three names they already know, and a list ordered by which game happened to hold the first event row Returning readers get a one-time introduction and optional four-step tour. Skipping or finishing
gives them nothing to scan by. The choice is stored as *hidden* games, the inverse — which is a storage preserves progress and the saved main view; General can replay it.
shape, not a policy: what happens to a game added later is decided separately, below.
**A game added later arrives switched off.** Adding a source is our decision, not the reader's, and **A game added later arrives switched off.** Adding a source is our decision, not the reader's, and
someone who plays two games did not ask for the other twelve; a calendar that fills itself up is the someone who plays two games did not ask for the other twelve; a calendar that fills itself up is the
@@ -447,10 +427,13 @@ The view question (F2) sits under it, with each option drawn rather than only de
it arrives already answered, and the screen says where to change it afterwards: the tabs are small it arrives already answered, and the screen says where to change it afterwards: the tabs are small
text in a corner, which is the one control a first-time reader will not find on their own. text in a corner, which is the one control a first-time reader will not find on their own.
**F9 — Ignore an event.** **F9 — Ignore and Archive.**
Distinct from completing one. "Done" keeps an event visible and counted; "not interested" removes it Ignore saves immediately and follows the same confirmation/Undo transition as Done. Both move to
from both views entirely. Ignored events stay recoverable: a count and a reveal toggle appear in Archive with event metadata, notes, goals, logs, resolved end and clock revision. Previously seen
settings once there is something to reveal. followed events that expire unfinished also enter Archive; unknown-end events never expire by a
guessed date. Per-game history is default, with an alternate chronological history. Story statistics
include completion, check-ins and shortfalls where known; ignored events are excluded from success
rates. Restore recovers saved progress without pretending an expired event is live.
**F10 — Works offline.** **F10 — Works offline.**
The reader's question is answered entirely by data already on the device, and countdowns run off the The reader's question is answered entirely by data already on the device, and countdowns run off the
@@ -459,9 +442,9 @@ the last feed it downloaded. Offline is disclosed in the header and above the fo
data must never be presented as current. data must never be presented as current.
**F11 — Credit and disclaimer.** **F11 — Credit and disclaimer.**
The sources that compile these calendars, and the studios that make the games, are named on the same Settings → About Event Clock preserves the full colophon, source credits, unofficial/unaffiliated
screen as the data rather than one navigation step away. The page states plainly that it is disclaimer and feedback links. The footer links About. Each game's About lists the real data sources,
unofficial and unaffiliated, and that the source page is the authority when the two disagree. links and thanks; event detail links its authoritative source.
**F14 — Say when a new version of the app is ready.** **F14 — Say when a new version of the app is ready.**
F10 caches the shell so the app survives losing signal, and the same cache is why a reader who never F10 caches the shell so the app survives losing signal, and the same cache is why a reader who never
@@ -483,42 +466,29 @@ arrive without a reload, and calling that a new version would train readers to d
unread. unread.
**F7 — Freshness disclosure.** **F7 — Freshness disclosure.**
The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours The footer always states data age, never build age. Only tracked games with stale data produce a
stale, its lane carries a warning badge. Never present stale data as current — the whole value compact “Event data may be out of date” row in the shared updates area. New events and new codes
proposition is trust in the dates. remain distinct notices. Data & sources and event detail expose the last successful check, last
content change, source links and recorded reasons. A successful 304 confirms a check without
claiming the wiki changed.
**The reader can ask for the newest data.** A reload is not enough — the browser may keep the feed Check for new data revalidates event and code feeds, compares their content rather than generatedAt,
for minutes — so *Check for new data* beside the age asks the server past that copy and swaps a newer and reports updated, unchanged or failure. It does not scrape source sites from the browser.
feed in without a reload. It says what happened: updated, nothing newer published yet (sources are
read about twice a day, and the button cannot make that sooner), or the server could not be reached.
"Newer" is judged on events and source health, never on the build stamp. Disabled offline; nothing
polls on its own.
**The staleness notice names only the games the reader has switched on, and says that it does.** **F15 — Appearance.**
The headline age is about the feed and stays whole; the notice under it is an *instruction*, and the Relaxed (Round 7) and Structured (Round 6 B rail) share one component/state tree. Structured is the default (owner decision, 2026-10-17); an explicitly saved Relaxed preference is preserved.
only remedy it offers is "go and check that game's source page" — which a reader cannot act on for a Interface choice, Standard/Compact density, Dark/Light/System theme and Slate/Warm/Plum/Custom
game they turned off and cannot see a single row of. With nineteen lanes and two switched on, naming scheme are independent. Custom stores ground/surface/text/accent separately for light and dark;
eleven they do not play buries the one they do, and a warning about games nobody reads is a warning rendered colours derive readable contrast without overwriting the saved choices. Per-game colours
nobody reads (F8: a game we add is a game they never asked for, and arrives switched off, so an have a picker, hex field, preview and reset. Checklist dailies can occupy a separate direct-tick panel
untouched install would be warned about the whole catalogue). or grouped routines, with collapsed/expanded/always-expanded policies; these controls also live in
Routines. Numeric routines use native step-one inputs.
Both halves of that are load-bearing. Scoping it silently would be the worse half: "nothing has The earlier theme rationale below still applies to both interfaces.
refreshed" is a claim about the whole calendar, so a reader with fourteen of eighteen lanes off would
read a sentence about four as one about all of them. The notice therefore states whose games it
counted, in the same words the empty list uses — *the games you have switched on*. Credit in the
column beside it is owed to every source we read and is not scoped; nor is the "last refreshed" line,
which reports the feed and feeds the bug form.
**The staleness notice differentiates crawler checks from site updates.** If our crawler successfully
checked the wiki recently (e.g. confirming HTTP 304) but the site's content has not changed in over
two days, the notice explicitly distinguishes the two: "data pulled X ago, site updated Y ago", with
an expandable breakdown detailing each source's check and content update times.
**F15 — Light mode, with dark still the default.**
The app is a lit instrument panel and that is what it should be on first sight, but it is also read The app is a lit instrument panel and that is what it should be on first sight, but it is also read
on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork
worked a theme toggle out first and is credited for the idea in the colophon. So the ground is worked a theme toggle out first and is credited for the idea in the colophon. So the ground is
the reader's choice: **Dark**, **Light**, or **System** to follow the device, in settings next to the the reader's choice: **Dark**, **Light**, or **System** to follow the device, in Settings → Appearance, separately from the
region — both are "how do I read this?" and neither changes what the page knows. region — both are "how do I read this?" and neither changes what the page knows.
Dark is what it ships as, and deliberately not `System`. A reader whose laptop is in light mode has Dark is what it ships as, and deliberately not `System`. A reader whose laptop is in light mode has
@@ -571,7 +541,15 @@ placed. It also arrives switched off (F8), so settings is where they meet it, wh
would move it anyway. **A game's dailies stay together** — a repeating event the reader marked sits would move it anyway. **A game's dailies stay together** — a repeating event the reader marked sits
with the standing chore of the game it came from, because the reader thinks in games and not in kinds. with the standing chore of the game it came from, because the reader thinks in games and not in kinds.
**F17 — Weekly and monthly tasks.** **F17 — Routines.**
Routines is the library and management page for daily, weekly, monthly and cycle tasks. Follow each
per server; pin independently to Checklist and Timeline. Built-ins offer Make it mine, copying only
that account's logs; custom routines remain editable. Catch up & recorded history exposes logs on
their original reset revisions. A separate Timeline reset strip sits directly after its game's events;
with strips off, Ending soonest remains a single cross-game deadline queue.
The cadence and goal semantics below remain the domain contract; the redesign moves their controls
from the former Your own section to Routines.
Some chores roll weekly or monthly rather than daily — earning ZZZ's weekly activity points, Some chores roll weekly or monthly rather than daily — earning ZZZ's weekly activity points,
finishing Endfield's weekly routine — and no source publishes them, so they were nowhere in the app. finishing Endfield's weekly routine — and no source publishes them, so they were nowhere in the app.
A reader can now set them up per game in *Your own*: a title, every week (from a weekday they pick), A reader can now set them up per game in *Your own*: a title, every week (from a weekday they pick),
@@ -624,7 +602,7 @@ instant on every server rolls on that server's reset for every reader.
The app now says what changed, in two tiers, both tied to git. **Short entries** — one line each, The app now says what changed, in two tiers, both tied to git. **Short entries** — one line each,
tagged New, Fixed, or Games & sources — appear once as *What's new*, above the lists, after a new tagged New, Fixed, or Games & sources — appear once as *What's new*, above the lists, after a new
version loads; *Got it* dismisses them. **Release notes** — a few paragraphs per feature or version loads; *Got it* dismisses them. **Release notes** — a few paragraphs per feature or
milestone, summarised from the history and the design specs — are in the footer's *Changelog*, each milestone, summarised from the history and the design specs — are in Settings → *Changelog*, each
linked to the commits it covers and the specs it draws on. Data refreshes are not changes in this linked to the commits it covers and the specs it draws on. Data refreshes are not changes in this
sense; the footer already states the data's age. sense; the footer already states the data's age.
+8 -7
View File
@@ -71,7 +71,7 @@ away for more coverage (see Product Principles).
## Capabilities and Constraints ## Capabilities and Constraints
**Capabilities.** The full feature set is specified as F1–F16 in `docs/PRD.md`: **Capabilities.** The full feature set is specified as F1–F18 in `docs/PRD.md`:
- A timeline: one lane per game, or a single queue sorted by soonest end. - A timeline: one lane per game, or a single queue sorted by soonest end.
- A checklist of everything running, sorted by soonest end. - A checklist of everything running, sorted by soonest end.
@@ -79,16 +79,17 @@ away for more coverage (see Product Principles).
- Daily checklists, with past days that can still be filled in. - Daily checklists, with past days that can still be filled in.
- Ignoring an event, and filters by game or type. - Ignoring an event, and filters by game or type.
- Focusing on one game at a time. - Focusing on one game at a time.
- Region selection. - Separate game/server tracking, reset corrections and Automatic or named display timezone.
- JSON export and import. - JSON export/import, recoverable restore and 7 daily / 4 weekly / 6 monthly local backups.
- The reader's own games and events, one-off or repeating. - The reader's own games and events, one-off or repeating.
- First-run setup. - Events-first new setup and a one-time returning-reader introduction.
- Offline use. - Offline use.
- A notice when a new version of the app is ready. - A notice when a new version of the app is ready.
- Dark or light mode, plus a "follow the system" option. - Dark or light mode, plus a "follow the system" option.
- The reader's own game order. - The reader's own game order, per-game colours, Relaxed/Structured interfaces and custom schemes.
- A Routines library with independent view pins, an Archive with story statistics, and sourced redeem codes.
The app currently covers twenty games from twenty-two sources. The app covers twenty games; source registries are the authoritative current count.
**Hard constraints** (`AGENTS.md` § Three constraints): **Hard constraints** (`AGENTS.md` § Three constraints):
@@ -112,7 +113,7 @@ The app currently covers twenty games from twenty-two sources.
**Terminology:** **Terminology:**
- The user is a **reader**. - The user is a **reader**.
- The two views are **Checklist** (stored id `"soon"`) and **Timeline**. - The main views are **Checklist** (stored id `"soon"`) and **Timeline**; Routines, Archive and Settings are utility pages.
- An event's states are **untouched**, **doing it** and **done**. - An event's states are **untouched**, **doing it** and **done**.
- **Ignore** is not the same as done. - **Ignore** is not the same as done.
- A **lane** is one game's row or source. - A **lane** is one game's row or source.
+19
View File
@@ -976,3 +976,22 @@ Facts stated above that a future reader may want to re-check, with how they were
| `marisaimpact.com` states no year on any calendar | stripped tags from `/calendar89` and `/calendar90` and grepped the text for `20\d\d` — zero matches on either | | `marisaimpact.com` states no year on any calendar | stripped tags from `/calendar89` and `/calendar90` and grepped the text for `20\d\d` — zero matches on either |
| Its calendar URL is per-version, with no stable alias | `/calendar90` is `200` and covers `Aug 20 - Oct 22`; `/valk/calendar90` — the scheme its own `og:url` uses — is `404`; the nav links only `/calendar88` and `/calendar89` | | Its calendar URL is per-version, with no stable alias | `/calendar90` is `200` and covers `Aug 20 - Oct 22`; `/valk/calendar90` — the scheme its own `og:url` uses — is `404`; the nav links only `/calendar88` and `/calendar89` |
| Its schedule is not behind an API | `GET /calendar90/__data.json` returns 144 bytes: an `ogImage` URL and nothing else | | Its schedule is not behind an API | `GET /calendar90/__data.json` returns 144 bytes: an `ogImage` URL and nothing else |
## Redeem codes source (2026-10-16)
Genshin codes use https://game8.co/games/Genshin-Impact/archives/304759, separately from events.
One descriptive-agent robots request and one page request returned HTTP 200 during capture; no
access workaround or repeated source fetch was used. The raw page and capture metadata are pinned
as fixtures/genshin/game8-codes-2026-10-16.*, with ten independently counted current codes. The
parser reads actual code inputs/current reward rows and excludes expired programme sections; a
future-dated programme fixture variant verifies that still-current programmes are admitted.
Unannounced expiry remains null. A printed calendar date is expiresOn, never a fabricated exact
expiresAt instant. Attribution is retained on every code.
src/ingest/codes/registry.ts registers a count-only refresh source. The existing runner shares robots,
six-hour floors, conditional requests, per-host spacing and snapshot gates across event/code sources.
Event adapter schemas are unchanged. scripts/build-codes.ts builds data/codes.v1.json offline from
snapshots or fixtures and preserves observation timestamps in snapshots/codes-first-seen.json.
Code newness is per game; redemption is per local game/server account and never clears newness by
itself. No code row is synthesized into the event feed.
@@ -0,0 +1,125 @@
# Approved redesign — implementation review, 2026-10-16
**Status: rejected by the owner for substantial divergence from the approved Round 7 markup.**
The completion claims below describe the earlier implementation report and are withdrawn as
evidence of design acceptance. Functional tests and overflow checks did not establish visual or
interaction fidelity. A faithful markup-to-component correction is still required.
The approved [spec](../superpowers/specs/2026-10-16-ui-redesign-design.md) and
[16-task plan](../superpowers/plans/2026-10-16-ui-redesign.md) are implemented in the working tree.
Execution was Native. Nothing has been committed, pushed or published.
## Delivered
Relaxed (Round 7, default) and Structured (Round 6 B rail) share one reader/component tree; Appearance
switches between them independently of theme, palette and Standard/Compact density. The redesign
includes Routines and view pins, direct daily ticks, configurable grouping, multi-server progress,
reset override/revert with accessible clock history, game colours/source credits, custom palettes,
Automatic/IANA timezone, new and returning onboarding, compact source/new-event/code notices,
Archive/story statistics, timed Done/Ignore/Undo, local rolling backups and recoverable restore.
Settings About preserves the colophon; Changelog has its own page and no footer link.
The codes feed contains ten real Genshin codes from the pinned Game8 capture. Source fetching remains
deterministic and shares the existing conduct gates; no additional community requests were needed
while implementing or reviewing. Unstated expiry stays unknown. Codes newness and per-server
redemption are separate. Manual refresh checks both feeds and discloses partial code-feed failure.
## Verification
- **1,587 tests pass**, zero failures, across 74 files. Strict TypeScript and whitespace checks pass.
- The suite also runs without `public/` or `docs/` in the Docker COPY layout: 1,586 pass, one
intentional README-only skip, zero failures. No tests depend on a preceding build.
- Full offline build passes: 209 events across 20 games, ten codes, production JS/CSS/static shell.
Final shell build `f5b25675010f` was loaded on a fresh origin (port 41880).
- Chrome checks cover both interfaces, both themes, 1440/390 widths, Standard/Compact, reduced motion
and an effective 200% CSS viewport. No horizontal page overflow or page errors in that matrix.
- Browser journeys pass for migration, saved preferred Timeline, events-only setup, selectable empty
tracking kinds, upcoming placement, isolation from unselected games, extra server with fresh state,
direct ticks, reset override/revert/history, per-server redemption, Done/Ignore/Undo, unknown-end
custom creation, real export download/upload/restore, backup preview/restore/Undo, custom palette,
timezone selection/search, offline reload and the real service-worker update/reload handshake.
- Two real browser tabs save concurrently with Web Locks explicitly disabled: IndexedDB serialization
preserves both edits. Unit regressions cover stale progress fields and independent routine pins.
- A fresh independent reviewer completed whole-change review and focused re-reviews. All important
findings were fixed with reproductions; final review reports no remaining important findings.
## Review fixes and durable decisions
Deleted custom definitions no longer reappear from seen snapshots. Progress imports preserve local
active reset configuration, retain imported clocks as history, and reuse those revisions on repeated
imports. Opaque extensions survive subsequent transfers without duplicate accumulation. Timeline
focus applies to routines as well as events; Ending soonest uses one global queue when reset strips
are off. The browser fallback uses an IndexedDB transaction rather than unlocked whole-envelope
writes. Account rebase preserves independent routine/pin edits and explicit retirement removal.
Original v1 bytes remain available after migration. Restore validates account/clock references and
saves recovery before replacement. Setup completes before seen-history capture. Source diagnostics
state what the feed records; absent failure reasons are described as unavailable, never guessed.
## Practical limits
Backups are browser-local and created while the app is used; they cannot recover storage a reader
clears. Older completion IDs whose metadata was never retained remain recoverable raw data, but
cannot produce invented archive descriptions or historical statistics. Codes currently have one
registered game/source. External fonts have system fallbacks offline. Calendar-date code expiry is
shown as a date without an invented instant. No accounts, remote sync or push notifications added.
## Captures inspected
[Relaxed, light, desktop](assets/2026-10-16-redesign/relaxed-light-1440.png) ·
[Structured, dark, desktop](assets/2026-10-16-redesign/structured-dark-1440.png) ·
[Structured, dark, phone](assets/2026-10-16-redesign/structured-dark-390.png)
Additional transient browser logs and captures are in `/tmp/redesign-browser-*` and
`/tmp/redesign-final-*`; the facts above and representative images are recorded here for handoff.
## Follow-up: first-visit HTTP/WireGuard startup failure
The owner reported the recovery screen on first visit through WireGuard `10.20.20.2`. The precise
failure reproduced in Chrome at an ordinary HTTP hostname: `crypto.randomUUID` was undefined,
initial account minting threw, and initialization returned the generic unavailable/saved-data error.
Localhost-only verification had missed this because localhost is a browser secure context.
All reader ID consumers now use 128-bit `crypto.getRandomValues` through `state/reader/ids.ts`.
Three new regressions failed before the fix and pass afterwards. Browser verification on an actual
insecure HTTP origin passes both existing-save migration and fresh setup, ordinary edits, adding a
server, reset override/revert, backups and reload; the original v1 bytes remain unchanged. Focused
independent review found no important issues. The full suite now passes **1,590 tests** across 75
files. Typecheck, production rebuild and whitespace checks pass. Rebuilt shell: `9752bd6bf1d2`.
The existing preview serves the rebuilt files; hard-refreshing bypasses the browser's cached JS.
No storage deletion or identity remapping was performed.
## Owner rejection: markup fidelity
Direct comparison confirms substantial divergence despite matching outer gutters/panels/fonts:
- Round 7's event-entry structure separates event content, a large deadline with supporting text,
and chevron. Production reused EventRow's prior internal structure and decorated it with CSS.
- Round 7's Routines uses game bands and aligned columns for progress, cycle/reset, pins and menus.
Production renders a flattened library with checkbox options and no equivalent table structure.
- Round 7's settings sidebar includes individual game entries with descriptions, leading into their
profiles. Production substitutes a generic Your games page and game selector.
- Round 7's Timeline places routine periods/progress/reset markers on the shared time axis.
Production appends ordinary routine controls below event timelines and uses textual reset strips.
- Round 7's Archive story includes interactive per-game outcome breakdowns. Production substitutes
per-game text totals.
- The approved header has navigation counts and a dedicated Settings placement. Production uses
the simpler generic navigation and a different header summary.
References: design/mockups/round7/converged.html (final CSS from line 1003 onward and final renderer
overrides); production ReaderApp.tsx, EventRow.tsx, TimelinePage.tsx, RoutinesPage.tsx,
SettingsLayout.tsx, GameSettings.tsx, ArchiveStory.tsx. Earlier render functions/CSS in the mockup
are superseded by later overrides and must not be used in isolation.
Root delivery error: the implementation preserved/reused existing presentation components and
added selected reference styling instead of faithfully translating the approved rendered markup.
Verification checked functionality and overflow without comparing equivalent reference/production
screens and interactions. The final review concentrated on data preservation and did not close that
design-fidelity gap. The owner should not have had to identify it after a completion claim.
No implementation edits or data resets were made during this rejection audit. Existing storage and
HTTP fixes remain in place. Required correction: use the approved rendered markup, hierarchy and
interactions as the presentation contract, then connect real reader data and compare matching states.
@@ -0,0 +1,167 @@
# Routine redesign: Round 4 review
This records the owner's decisions from three mockup rounds and the subsequent review on
2026-10-16. These are proposed product changes, **not shipped behaviour**. The implementation spec
and migration plan come after review of this combined prototype.
## Review artifact
Open [the standalone Round 4 prototype](../../design/mockups/round4/converged.html) in a browser.
The review copy is also placed in `~/gacha-mockups/round4/converged.html`, linked from the existing
Samba index. Earlier rounds remain intact there.
The prototype extends the Round 3 archive variant, keeping its ruled timetable visual language and
routine library. It is deliberately separate from the production app. Its storage namespace is
`event-clock:mockup:round4:v1`, never a production `gacha-tracker` key. Reset demo restores sample
state; reloading otherwise keeps density, choices and progress where browser storage is available.
## Decisions to preserve
- Checklist and Timeline retain their names. Routines and Archive have their own tabs, as does
Settings. Settings uses a sidebar with General first.
- Routines owns follows, individual editing and independent Checklist/Timeline pins. Built-ins do
not appear under Your own events and games. The library puts Create your own routine first.
- Checklist keeps compact per-game routine summaries in its rail. Focusing a game expands its
routines; All collapses them. Remove the unrelated Your own events callout from the rail.
- The main event list has two densities: Standard by default, with wrapping titles, breathing room
and supporting copy, and Compact using the dense table. Density is remembered and independent of
routine pins, filters, sort order and Timeline. Both densities open the same event detail.
- Game setup offers composable Events / Dailies / Weeklies & monthlies / Cycles shortcuts; individual
follows remain editable in Routines. Events is a visibility choice, rather than a routine.
- Each game can have multiple servers, with separate routine and daily progress. An additional
server starts with events only and fresh routine choices; it does not copy the first server's
follows, pins or ticks.
- Onboarding begins with events only. Explain each type in plain language, especially cycles:
routines following the game's own cadence, such as a mode resetting every fourteen days. Ask
whether Checklist or Timeline should open first. Defer pin placement until readers have seen the
views.
- Returning readers receive an introduction to the new layout in either main view, with existing
choices and progress preserved. Changelog remains accessible afterward.
- Codes and About live beside Setup in the game's settings area. In-app notices appear in both
main views. New means discovered since the reader last viewed that game's codes, not unredeemed.
Viewing clears the new indicator without marking redemption. Redemption is separate per server.
- Archive defaults to per-game groups, with the ledger's story panel and small statistics above
them. History timeline is an additional view. Records remain in the save, with description,
source and progress available after the live source changes.
- Done and Ignore immediately save an archive record, then retain the active row for about 1.5
seconds with Completed/Ignored · Moved to Archive before fading and closing the gap. Undo remains
available afterward; Restore also lives in Archive. Reduced motion keeps the confirmation delay
and removes the fade. Unknown end dates stay unknown in archived records.
- Expired unfinished events belong in Archive as unfinished, rather than silently disappearing.
- Timeline uses full rows for routines. It offers Ending soonest and By game. Separate reset strips
are optional and sit directly below their own game's events on the same axis. In separate-strip
mode, Ending soonest orders game groups by their next deadline to preserve this adjacency.
## What to review
1. Compare the same 21 events in Standard and Compact; focus Genshin and inspect its routine rail.
2. Complete or ignore an event, observe confirmation/fade, Undo, then open its saved Archive detail
and Restore it. Try an event with no announced end.
3. Follow/unfollow a routine from the library and pin it independently to each view.
4. Add a server in game settings and confirm the other server's choices and ticks stay separate.
5. Open Codes, mark one redeemed on a single server, and simulate a new arrival from the demo bar.
6. Compare the per-game Archive with History timeline, and check both timeline reset layouts.
7. Walk through New user, then Returning user with Timeline as the default.
## Owner's feedback after Round 4
The combined prototype was received positively. Further requirements:
- Restore the richer statistics from Round 3 H's Ledger with a story. Round 4 reduced that panel
to completed/ignored/unfinished counts, which lost what the owner liked. Keep the per-game
archive as the default body, but restore the completion percentage for the current month with
an overall comparison, current/best completion run, logged event check-in days, and unfinished
events with near-miss detail. Retain the compact per-game outcome breakdown from that panel.
Ignored events must not be counted as failures in the completion percentage. Definitions and
scope labels must be explicit: recorded archive history is not all play history, event check-in
totals are not unique calendar days, and incomplete/unknown progress cannot support a fabricated
near-miss claim. These statistics should use saved progress and dates, not invented telemetry.
- Keep the colophon, potentially in Settings, and add an app-level About section distinct from a
game's About tab. Preserve source/studio credits, author and contributor acknowledgements and
useful project links. Moving credits must not hide the feed-age and stale-source disclosures
that readers need while looking at deadlines.
- The grouped routine summaries are easier to scan but add friction to ticking daily chores.
Explore a separate daily panel with directly available one-click ticks, selectable as an
alternative to grouping dailies with the other routines. Keep server progress separate and
avoid showing the same tick in both panels. Placement and default are not yet settled.
- Appearance should support colour schemes in addition to dark/light/system. The choice between
curated palettes and reader-edited colours remains open. Any scheme must preserve game identity,
urgency meanings, and readable contrast in both modes.
- Each game's Settings → Setup includes a custom game-colour control, a preview and Reset to
default. Remember the override and apply it consistently wherever that game appears, including
Checklist, Timeline, Routines and Archive. It belongs to the game, shared across its servers,
separately from the global interface colour scheme. Use the existing readable-hue approach to
preserve legibility in dark and light appearances without changing the reader's saved base
colour. Game colour expresses identity; urgency colours retain their meaning. Export/import
must retain these overrides in the eventual implementation.
- Clarify the additional-server action as simultaneous account tracking. Use “Track another
server” with helper text: “Play on more than one server? Add your other server to track both
accounts separately.” Keep changing the existing server as a distinct control. The added-server
setup still starts with fresh routine choices and separate progress, as previously agreed.
- Standard event rows without descriptions currently feel too empty. Size rows to their actual
content: reserve no description line when absent, reduce vertical padding for title-only rows,
and keep the countdown/meter stack from imposing a description-sized minimum height. Preserve
wrapping titles and comfortable tap targets; Standard remains roomier than Compact without
forcing every event to the same tall height. Do not invent fallback description text to fill
the space.
- Region & time must let the reader select their display timezone. Automatic, using the browser's
timezone, is the default; a manual selection is remembered and can be returned to Automatic.
Display named IANA zones with their current UTC offsets, so daylight-saving changes follow the
selected zone rather than a fixed offset. This affects displayed dates/times, not the server
clocks that determine deadlines, reset boundaries and stored game-day keys. Reader-authored
event entry must explicitly identify the timezone used to interpret its input.
- In onboarding's What do you want to track step, all type choices stay selectable even when a
game has no current matching routines or windows. Explain the empty state beside the choice;
distinguish no current active window from no supported built-in routine. An empty result is not
a disabled control. Retain the reader's selected tracking intent rather than deriving it solely
from which routines happen to exist today. The implementation spec must reconcile this intent
with the earlier shortcut-only composition model and define how newly supported routines are
offered without silently expanding what existing readers track.
- Initial setup also asks for default game server, preferred appearance and whether to show
events that have not started. The selected server initializes game setup and individual games
can override it. Appearance includes Dark / Light / System and the agreed colour-scheme choices
once those are settled. Upcoming events remain off initially, consistent with the existing
default, until the reader selects otherwise. The previously agreed Checklist/Timeline default
question remains. Present these as a short preferences step rather than crowding the tracking
matrix with unrelated controls.
## Prototype boundaries and implementation work still ahead
- “Now” and reset clocks are fixed for review. The final app must use the existing per-game reset
rules and actual region-specific end times.
- Most event content was carried over from the earlier prototypes. Codes, historical archive
records, version groups and some descriptions are illustrative. No new source fetch runs here.
Live-list source links are matched by title against the existing local feed; unmatched sample
events receive no invented source URL.
Game8's supplied Genshin code page is a source-shape reference, not an implemented adapter.
- Only in-app code notices are proposed. Push, email and background notifications remain outside
the product scope.
- Some inherited controls remain placeholders: backup and portions of own-event/routine editing.
- The archive snapshot works within this prototype's save. Production needs an explicit snapshot
retention/export format and a policy for capturing events before they drop from the feed.
- Production needs a reviewed migration for existing progress into per-server storage; no existing
event ID, daily ID or day key may be changed casually. The archive and code state also need a
documented export/import contract. This artifact is not a storage implementation to copy.
- `docs/PRD.md`, `docs/DATA-MODEL.md`, `docs/ARCHITECTURE.md`, `docs/DESIGN.md` and the matching
AGENTS guidance must be updated with the eventual implementation. They describe the shipped app
today, so this review record does not rewrite them as though the redesign had landed.
## Verification
Browser verification and screenshots are performed with scratch-installed puppeteer-core and
Chrome, at 1440px and 390px. The prototype needs no Bun build or production feed changes. The test
journeys cover density parity, navigation, completion/ignore delay and Undo, archived detail and
Restore, separate code visibility/redemption, server setup, reset-strip adjacency, onboarding,
returning users, persistence, reduced motion and unknown end dates.
Both width passes completed with no JavaScript errors and no page-level horizontal overflow in
the checked screens. The initial mobile pass caught a confirmation notice intercepting Archive
row taps; informational notice surfaces now let pointer events through while Undo remains a real
button. An early verification-script failure also tried to open a Star Rail event after onboarding
had selected only Genshin; the scenario now resets sample state before testing an unknown end.
Impeccable's mechanical detector reports palette, typography and size differences against the
**shipped** DESIGN.md, plus inherited stripe treatments. The new Barlow timetable appearance is
intentional and selected in earlier rounds; this prototype does not rewrite the shipped design
reference or add detector suppressions. Its visual language remains subject to this review.
@@ -0,0 +1,112 @@
# Routine redesign: Round 5 review
This is a review prototype, not a production release or an approved implementation spec. It
combines the owner's feedback after Round 4, recorded in
[the preceding review](2026-10-16-redesign-round4.md), and the subsequent freshness, backup and
focus-bar requests.
Open [Round 5](../../design/mockups/round5/converged.html), or its copy at
`~/gacha-mockups/round5/converged.html` through the shared index. Earlier rounds remain unchanged.
## What changed
- **One-click dailies:** an optional panel exposes daily ticks directly, separately per server.
The alternative groups dailies with the routine summaries. A daily appears in only one place;
pins still decide which dailies appear. The separate panel is the prototype's starting choice,
to test the owner's request for immediate access; this default remains reviewable.
- **Focus alternatives:** a labelled game toolbar is the starting choice. The review bar's Focus
selector also offers a compact game switcher and a picker at the top of the Checklist rail.
Each changes the same focus state and counts, and preserves auto-expanding focused routines.
Timeline retains a visible toolbar/switcher rather than acquiring a Checklist rail.
- **Content-sized Standard rows:** events lacking descriptions use less vertical padding and
omit the segmented meter. They retain a wrapping title, countdown and tap target. No filler
descriptions are added.
- **About and colophon:** Settings → About Event Clock retains the author, source communities,
studio credits, acknowledgements, reset-clock reference, disclaimer and project links. The
footer links there. Source credits come from the existing local feed, not the selected games.
- **Freshness:** Checklist and Timeline visibly state data age and warn about older schedules for
the games switched on. Source breakdown distinguishes last successful check from last content
change and explains unchanged pages, overdue pulls and never-fetched data. Unknown causes are
described as unreported, not invented. Settings → Data & sources exposes the same information.
The review bar offers Current / Some stale / All stale / Never fetched / Offline. These are
labelled illustrative states; no real fetch runs. Check for new data demonstrates a no-newer-feed
result and is disabled offline. The headline data timestamp covers the demo feed as a whole;
warnings and named source rows cover selected games. No build timestamp is used.
- **Appearance:** Dark / Light / System plus three curated scheme previews, Slate / Warm / Plum.
This answers the palette request for review, not the unresolved possibility of a fully editable
interface palette. Semantic deadline colours remain separate from game identity.
- **Game colour:** Setup adds a colour picker, hex input, preview and Reset to default. An override
applies to all the game's servers and all its views. The stored base colour remains unchanged
while its rendered colour is adjusted for contrast in the chosen appearance.
- **Timezone:** Automatic is the default; named zones can be selected and searched in Region &
time. Dates use the selected zone, including daylight saving, without changing server reset
boundaries. The prototype's per-game reset clocks remain simplified.
- **Additional servers:** “Track another server” explains simultaneous separate account tracking.
Added servers still start with fresh routine choices and progress.
- **Onboarding:** initial preferences now ask default server, mode/scheme, timezone and whether
upcoming events should appear. Game selection, tracking types and Checklist/Timeline default
follow. All tracking types remain selectable even if no matching built-in exists. Availability
is explained, and selected intent is saved separately from actual follows. The no-matching-types
message does not incorrectly call that selection events only. New supported built-ins are
described as offers for review rather than automatic extra work for existing readers.
- **Archive story:** restores monthly/overall completion percentages, current/best completion
run, logged event check-ins and unfinished/near-miss counts, plus per-game outcome bars, above
the default per-game archive. Ignored events are excluded from completion-rate denominators.
Progress and statistics derive from illustrative saved archive records, and scope is labelled.
History timeline remains an alternate view.
- **Automatic backup history:** Backup shows seven daily, four weekly and six monthly snapshots,
with latest-per-period compression. The retained historical examples are labelled sample; today's
snapshot updates from the demo's own state. Restore previews game/routine/archive/progress counts
and preserves current data as a recovery copy first. Undo last restore is available. Manual
Back up now and demo JSON export/import live alongside the automatic history.
## Backup semantics used for this review
The most recent seven days retain one latest snapshot per day. Older snapshots fold into four
consecutive seven-day windows. Older records then fold into six calendar-month buckets, keeping
the latest snapshot in each. This is up to seventeen retained snapshots, plus a recovery copy from
the last restore. No snapshot is invented for a day the reader never opened the app.
The demo clock is fixed and its bucket calculations use UTC. Production needs to settle and
document the stable calendar basis independently of display-timezone changes, handle storage
capacity/failures, validate restore files, and use the real export contract. Backups are browser
local and are created while the app is used; they cannot be promised for days when it stays closed.
Export remains the way to keep a copy outside that browser or move to another device.
## How to inspect
1. Compare focus options in the review bar. Focus Genshin; tick EU and Asia dailies separately.
2. Compare Standard/Compact, especially rows without descriptions, and switch the daily layout.
3. Use the Data selector to inspect every freshness state, then expand Why the data is older.
4. Open About, Data & sources, Region & time and Appearance. Change timezone, scheme and game colour.
5. Open Archive to inspect richer stats and History timeline.
6. Open Backups, review a monthly sample, restore it, then Undo last restore. Demo export files are
separate from the production app's format and storage.
7. Open New user. Choose preferences and Genshin, then select Cycles despite no matching built-in.
8. Set Timeline as the default and use Returning user to check the introduction appears there.
## Verification and boundaries
Chrome journeys at 1440px and 390px exercised direct daily ticks, layout choices, focus alternatives,
freshness variants, credits, timezone persistence, light/dark/system and schemes, game-colour reset,
fresh server setup, Archive stats, backup restore/recovery, onboarding with an empty type,
returning-reader introduction and completion/Undo. Both passes had no JavaScript errors and no
page-level horizontal overflow in the checked screens. A follow-up checked latest-per-period
retention across 400 simulated days, all-games empty-type toggles, and desktop sticky toolbar/rail
separation.
The mechanical design detector reports 30 findings, chiefly differences from the shipped palette,
Barlow typography and size/radius ramp, plus inherited archive stripe treatments. These are review
artifacts in the owner-selected replacement visual direction; no production design reference or
detector suppression was changed to make the prototype appear to match the shipped app.
An inherited Round 4 Reset handler initially discarded the new state fields. Round 5 now owns
reset initialization and its browser check passes. The active prototype data namespace is
`event-clock:mockup:round5:v1`; production localStorage keys and progress are untouched.
The existing demo library/create form, independent pins, completion confirmation/fade/Undo,
Archive restore and code-newness/redemption distinctions remain. Some inherited own-event editors
are still placeholders. Codes, old Archive history, backup history and source health are sample
content. Real parsers, source refreshes, app storage migrations and production backup machinery are
not implemented by this artifact. The shipped specification and design documents remain about the
shipped app until the redesign's spec and implementation are reviewed.
@@ -0,0 +1,105 @@
# Routine redesign: three quieter layouts
Review artifacts, not a production release. This round responds to the owner's concern that
Round 5's focus toolbar and stale-data notice occupied too much space, and incorporates the
additional feedback received while building the alternatives.
Open [the comparison index](../../design/mockups/round6/index.html). The shared copy lives at
`~/gacha-mockups/round6/index.html`; earlier rounds stay available. Each design is a complete
standalone HTML prototype with the same sample event data and the shared features below.
## The alternatives
- **A — Inline desk:** retains the left routine/daily rail. Game focus is a small selector beside
the event heading, and freshness is a single understated line. Source reasons are one click away.
- **B — Game desk:** puts game focus, daily ticks and a short source-status report in the rail.
There is no full-width freshness or focus band above the event list. It reserves more attention
for the list; its tradeoff is a more populated rail that stacks before events on mobile.
- **C — Open ledger:** replaces the rail with a full-width event list, a brief next-deadline row,
direct daily ticks in a horizontal strip, and a disclosure for other pinned routines. Game focus
and a short freshness report sit with the event heading. It gives titles the most width; the
routine controls scroll with the list.
The chosen timetable appearance is preserved. These compare structure and interaction rather than
asking the owner to pick a new visual identity again.
## Shared decisions implemented for review
- **Changelog is its own Settings page.** The owner explicitly chose this over a main app tab,
and explicitly declined a footer link. Remove the awkward Changelog button from About, remove
it from the footer, and render release notes in the page rather than the old overlay. Existing
returning-reader news links lead to that Settings page.
- **Freshness remains honest and available.** Keep the full data timestamp and age in the footer,
scope warnings to selected games, and expose successful-check/content-change times and reasons
in Data & sources. Older source data is also explained in the event's detail. Unknown causes
remain unreported, not invented. The review bar's Data selector tests the illustrative scenarios.
- **Dailies on Checklist belongs in Routines as well.** Routines → Checklist display controls the
same preference as the daily panel shortcut and Appearance. Choose Separate panel or Grouped
with routines. Independent pins still govern whether a routine appears.
- **Checklist routine groups have three behaviours:** Collapsed by default, Expanded by default
(still collapsible), and Always expanded (no collapse buttons). Focusing a game expands its
routines. The wide layout also honours this for its enclosing routine section.
- **Numeric fields show the native stepper.** Browser increase/decrease controls remain visible
and use step one, as requested. They are not replaced by a separate custom spinner.
- **Appearance includes Custom.** Edit background, surface, text and interface accent separately
for light and dark. Stored choices are retained while preview text/surfaces are adjusted for
contrast. Game identities and deadline urgency remain separate from the custom interface accent.
Individual game colours retain their own control and reset.
- **Reset timing can be overridden per game/server.** Game Setup and Region & time expose Automatic
versus a custom server-local hour/minute, with Revert to Automatic. The prototype applies the
override consistently to daily/routine periods and reset summaries. Reader-authored routines
with their own explicit reset retain it. The automatic source clocks remain simplified here;
this is not a production reset migration.
- **A game's About names its sources.** Links come from that game's entries in the existing local
feed, including separate event/banner/pass sources. Use readable schedule labels and thank the
editors. This differs from the global About's full, unfiltered colophon.
- **Remove Done and ignored events from Lists.** That row only linked to Archive and was not a
setting. Archive keeps its own main navigation tab.
- **Backup rows say View backup.** They open a preview headed Backup from [date], showing the
snapshot's contents. Restore this backup is the explicit action there; the current state is
preserved as a recovery copy. The seven-day/four-week/six-month history is retained.
- **Returning readers get a guided introduction.** The review bar's Returning user control opens
a short welcome in whichever view is preferred, with Take the quick tour and Keep browsing.
Four steps visit Routines, Checklist, Archive and Settings. Back and Skip are available; finishing
returns to the preferred view. Games, follows and progress remain intact. Production will bind
this to a saved release marker, so it is offered once rather than every load.
- **Upcoming events moves to What do you want to track?** It is no longer in the initial preference
step. The initial preferences retain default server, appearance and timezone; all type choices
remain selectable and explained even when no matching built-in exists.
Standard/Compact density, richer Archive statistics and the history timeline, separate server
progress, code-newness versus redemption, completion confirmation/fade/Undo, timezone and the
existing routine library/create form remain available in every alternative.
## Boundaries
Each alternative uses its own `event-clock:mockup:round6:<letter>:v1` browser namespace. No production
keys, parsers or feed data are changed. Source-health states, codes, archived history and historical
backups are illustrative. Clock overrides affect the demo's period calculations, not any real stored
game-day keys. Production must review migration and the preservation of historical ticks before
introducing reader overrides. These are disposable prototypes, not code to transplant into the app.
## Verification record
The browser pass checks all three designs at 1440px and 390px: focus and daily controls, native
numeric stepping, all disclosure modes, source reasons in event detail, the Changelog page and its
absence from About/footer, custom-palette contrast, reset override/revert, per-game source credits,
Lists cleanup, backup labels, the moved upcoming option, and the returning-user tour preserving
progress. Screenshots cover each main layout plus the new shared pages.
The first mobile pass found a backup-preview smooth scroll continuing after navigation, allowing a
subsequent automated tap to hit the fixed review bar. The preview now scrolls instantly, avoiding a
pending animation against a different screen.
The confirming pass completed all six combinations of design and viewport with no JavaScript
errors or page-level horizontal overflow in the checked screens. Custom-palette checks included
equal chosen background/text colours and confirmed the rendered text was adjusted to at least
4.5:1. Per-server override checks exercised 05:30, verified the 90-minute change from 04:00, then
returned to Automatic. The tour returned a Timeline-default reader to Timeline with saved games,
follows and progress unchanged.
Design-hook triage: retain the owner-selected Barlow typography. Narrow value exceptions for that
font are scoped to `design/mockups/round6/index.html` only, with evidence in `.impeccable/config.json`.
No whole-file or whole-rule exclusions are added. The shipped DESIGN.md remains the production
reference; prototype differences in palette/size and inherited archive treatments remain review
findings rather than being suppressed broadly.
@@ -0,0 +1,80 @@
# Routine redesign: one airier design
**Later owner decision:** retain two interfaces after all: Round 7 and Round 6 **B**, selected
in Settings. The earlier single-interface direction below records the mockup review, not the
final implementation decision. Replace “Older schedules” with “Event data may be out of date”.
See the [production redesign specification](../superpowers/specs/2026-10-16-ui-redesign-design.md).
Review prototype, not shipped behaviour. Open
[the unified mockup](../../design/mockups/round7/converged.html), also copied to
`~/gacha-mockups/round7/converged.html` through the shared index.
## The owner's correction
Round 6's three alternatives were rejected. The owner asked for:
- Older-data notices in the same structure as New since you last looked, appearing only when
games have older data.
- The current production game's focus-pill structure, placed at the top of the left rail as in B.
- A more airy, easy-to-read design informed by the original
`~/gacha-mockups/c-one-due-list.html`.
The initial idea of a Simple mode or a spacing toggle was then explicitly reconsidered: the owner
does not want two distinct UIs. Carry the spirit of C into one unified interface instead.
## What this mockup does
**A shared updates area.** New code notices and Older schedules use two compact rows in one
component above the Checklist's event list and above Timeline. The older row lists affected games
and links to Details & reasons; no full-width health banner or rail status panel remains. It is
absent when the selected games have current sources. If there are no new codes either, the whole
updates area disappears. Source reasons remain available in Data & sources and event detail; the
full feed timestamp stays in the footer on every load. Scope is the games the reader has switched
on, rather than only the temporary focus. Repetitive older-data labels on individual list rows are
removed because the updates row already names those games.
**The existing focus behaviour.** `src/client/components/GameFocus.tsx` is the reference: All and
game pills, game hues, outstanding counts, and Next progressing through games and then back to All.
Tapping the focused pill returns to All. Pills wrap in their own panel at the top of the Checklist
rail; they are absent for a single-game setup. Timeline uses the same pills above its lanes. Focus
never changes which games are followed.
**One visual world.** The original C informs separated panels, quieter rules, softened surfaces,
more room around controls and a warmer reading rhythm. Bricolage Grotesque and Figtree replace the
compressed timetable typography consistently across the prototype, including settings and forms.
The rail and event list have a real gutter; focus, deadline, dailies and routines have separated
surfaces. Light mode uses warm paper and dark mode a softer charcoal. No Simple/Detailed mode or
global spacing toggle is added. Standard/Compact remain two row densities of this same appearance.
Rows with no description remain shorter; comfortable layout is not a fixed tall event height.
Direct daily ticks, routine disclosure choices, independent pins, custom palettes, per-game colours
and reset overrides, source links and thanks, Changelog in Settings, Archive statistics and history,
backup history/recovery, timezone and both onboarding flows remain available. The daily-panel layout
shortcut is removed from the Checklist rail to reduce controls there; its Routines and Appearance
settings continue to edit the same preference.
## Validation
Chrome journeys at 1440px and 390px passed with no JavaScript errors and no page-level horizontal
overflow in the checked views. They cover conditional/scope-aware notices, empty updates, focus
toggle/advance, spacing, routine expansion, reset override/revert, source credit links, custom
scheme controls, light-theme game-colour contrast on ground and panel, Changelog location, backup
history, Archive stats, Timeline, completion/fade/Undo and onboarding. Contrast adjustment now
considers both panel and page grounds, while retaining saved base game colours.
Screenshots were inspected for Checklist in both appearances, Routines, Data & sources, Archive
and Timeline. The first verification script used a nonexistent review-bar Settings shortcut; it
was corrected to use the actual Settings tab. No product change was needed for that script error.
The mechanical design check flags differences from the **shipped** DESIGN.md in palette, type
size, radius and inherited archive treatments. Those remain review findings, not broad suppressions.
The intentional Bricolage font exception is scoped to this one prototype file, with rationale in
`.impeccable/config.json`. The production visual reference is not rewritten before implementation.
## Boundaries
The namespace is `event-clock:mockup:round7:v1`. Earlier mockups and production progress remain
unchanged. Historical records, codes and source health are illustrative; clocks are fixed and
automatic reset defaults simplified. No source requests or production parser/storage changes run
here. This is a disposable visual review artifact; the redesign still needs a reviewed production
specification and migration plan.
@@ -0,0 +1,101 @@
# Redesign fidelity correction — 2026-10-17
> **Owner rejected this implementation after browsing it:** “This is still not even close to the
> mockup.” The completion claim is withdrawn. Screenshots and review verdicts below are historical
> evidence of the rejected attempt, not acceptance. Continue from
> [the second-rejection handoff](../handoffs/2026-10-17-redesign-second-rejection-handoff.md):
> launch the current server, then ask the owner for the biggest differences before further UI edits.
The owner asked to repair the rejected implementation against Round 7 Relaxed and Round 6 B
Structured, then explicitly made **Structured the default**. Existing saved Relaxed choices remain
valid. This correction is uncommitted in the existing checkout; no branch, publication or data-source
refresh was performed.
## What changed
- Checklist uses dedicated reference-shaped event rows and next-deadline panel, with separate copy,
countdown, supporting date, segmented meter and detail chevron. Header counts and Settings placement
follow the reference. Both interfaces retain the owner-approved focus pills.
- Routines has By reset/By game bands, aligned game/routine/progress/view-pin/menu columns, live totals,
direct daily status, amount bars/inputs, library and a native custom-routine drawer. Bulk pins combine
writes per account. Multi-server row identity includes both account and routine.
- Timeline plots event spans and routine periods on one axis with frozen labels, progress fill and
reset markers. In-progress bar labels remain visible after scrolling. Deadline ordering includes
routine resets, and separate reset strips remain available.
- Settings restores separate Default view and Your own events and games pages, game profiles, tracking
summaries and Other games. Profiles follow tracking → servers → colour → reset timing. A server
selector above tracking keeps choices independent without duplicating tall account panels.
- Archive restores the story, interactive outcome bars, game sidebar, saved rows and history timeline.
Its detail dialog reads saved metadata, original clock region, notes, days and amounts. It does not
open the live progress editor or replace historical state with the current clock.
- Structured defaults agree across prepaint, normalization, setup and applied appearance. No IDs,
localStorage key spaces, migrations or ingestion rules were changed by this correction.
## Reference comparisons
Images are in [the evidence directory](assets/2026-10-17-fidelity/). Representative matched views:
| View | App | Approved reference |
| --- | --- | --- |
| Structured Checklist | [Desktop](assets/2026-10-17-fidelity/app-structured-dark-1440.png) | [Desktop](assets/2026-10-17-fidelity/reference-structured-dark-1440.png) |
| Relaxed Checklist | [Desktop](assets/2026-10-17-fidelity/app-relaxed-dark-1440.png) | [Desktop](assets/2026-10-17-fidelity/reference-relaxed-dark-1440.png) |
| Relaxed mobile | [390px](assets/2026-10-17-fidelity/app-relaxed-dark-390.png) | [390px](assets/2026-10-17-fidelity/reference-relaxed-dark-390.png) |
| Routines | [Structured](assets/2026-10-17-fidelity/app-structured-routines.png) | [Structured](assets/2026-10-17-fidelity/reference-structured-routines.png) |
| Timeline | [Structured](assets/2026-10-17-fidelity/app-structured-timeline.png) | [Structured](assets/2026-10-17-fidelity/reference-structured-timeline.png) |
| Game Setup | [Relaxed](assets/2026-10-17-fidelity/app-relaxed-settings.png) | [Relaxed](assets/2026-10-17-fidelity/reference-relaxed-settings.png) |
| Archive | [Structured](assets/2026-10-17-fidelity/app-structured-archive.png) | [Structured](assets/2026-10-17-fidelity/reference-structured-archive.png) |
Additional captures cover both themes at 1440px/390px, populated mobile utility pages, compact rows,
event/archive details, custom-routine drawers, archive history and the Settings pages.
The comparison harness freezes time at 2026-10-16 12:00 UTC and intercepts the event-feed request
with the prototype's **synthetic demonstration events**. It seeds disposable browser storage with
matching routine choices and 38 archive snapshots. These fixtures are review evidence only: they
are not imported by production, copied into public, or written into source snapshots. Actual setup,
HTTP migration and offline checks use the built app and its real feed separately.
Expected semantic differences: real account rows include Asia events, so totals differ from the
prototype's game-only event list. One prototype custom task was not seeded. Production has no
version metadata for archive events, so it groups saved records without inventing version names.
Real reset clocks and available routines govern progress. Code/release notices reflect actual data;
Checklist comparisons hide these notices to align the first viewport. The Structured prototype's
filter dropdown is intentionally superseded by the owner's focus-pill decision.
Reproduce with [fidelity-capture.mjs](fidelity-capture.mjs), from the repository root, setting
`PUPPETEER_MODULE` to the scratch installation's ESM entry and `APP_URL` / `MOCKUP_URL` to fresh
running origins. Chrome is `/usr/bin/google-chrome-stable`; the harness maps event-clock.test to
loopback. Rebuild CSS/JS/static without fetching the feed, and use a new app port after each rebuild.
## Independent review
The first reviewer invocation hit a session usage limit; a retry succeeded. Its initial material
findings were invisible scrolled timeline labels, substituted Settings navigation, routine column/
status hierarchy, multi-account row-key collisions, and insufficient populated Archive evidence.
One follow-up also required correcting the game-profile hierarchy. Each finding was fixed and the
same screenshot paths recaptured. Final independent verdict: **“ship the reviewed corrections.”**
All reported material findings were resolved. This verdict is scoped to the compared screens and
reported fixes; it is not owner acceptance or blanket proof of every interaction.
The Impeccable detector ran once and returned `[]` (no findings).
## Verification
Final browser build: `a28021bbe1a8`, production React/JSX transform, 117 bundled modules.
- TypeScript passes. Full offline suite: **1,594 passed, 0 failed**, 5,410 assertions.
- Docker-equivalent copied file layout, without docs/design/public: 1,593 passed, one intentional
docs-dependent skip, no failures. This validates the COPY-layout assumption; no Docker image was built.
- Eight interface/theme/width combinations: no document overflow or page exceptions. Both densities,
reduced motion/effective 200% viewport, custom palette and timezone selection checked.
- Ordinary insecure HTTP hostname: new setup, existing v1 migration, daily writes, second server,
reset override/revert, backup and reload pass; original legacy bytes retained. Structured is the
missing-choice default and saved Relaxed survives reload.
- Real two-tab simultaneous writes with Web Locks disabled pass using the existing IndexedDB lock.
Offline reload passes on localhost's service-worker-capable origin.
- Final-build interaction checks: multi-server bulk pins preserve the other view's placement; numeric
edits and custom-routine creation persist; completion, Undo, archive detail, Restore and Escape pass.
Final profile's server/reset controls were rechecked on insecure HTTP after layout changes.
- `git diff --check` passes. No new runtime dependency, source fetch, commit or publication.
The earlier rejection is preserved in the 2026-10-16 report. Passing tests did not establish its
visual fidelity; the side-by-side evidence and scoped review above are the correction's design record.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more