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}