diff options
| author | Ahmed <git@gumx.cc> | 2026-06-14 16:24:32 +0300 |
|---|---|---|
| committer | Ahmed <git@gumx.cc> | 2026-06-14 16:24:32 +0300 |
| commit | 004f2e9964c36e62b4da8d272cfd76c56dc81f1d (patch) | |
| tree | bfc5d7a84c588c4fe1699f16a5a8c773fb1a322a /ccc.html | |
init: coffee cups contributions tracker
Diffstat (limited to 'ccc.html')
| -rw-r--r-- | ccc.html | 924 |
1 files changed, 924 insertions, 0 deletions
diff --git a/ccc.html b/ccc.html new file mode 100644 index 0000000..bf2f6fe --- /dev/null +++ b/ccc.html @@ -0,0 +1,924 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>gumx / coffee</title> + <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1IDUiPjxyZWN0IHg9IjEiIHk9IjAiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz48cmVjdCB4PSIyIiB5PSIxIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+PHJlY3QgeD0iMCIgeT0iMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPjxyZWN0IHg9IjEiIHk9IjIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz48cmVjdCB4PSIyIiB5PSIyIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+PC9zdmc+"> + <style> + @font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Regular.woff2); font-weight: normal; } + @font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Bold.woff2); font-weight: bold; } + + /* ── CONFIG ─────────────────────────────────────────────── */ + /* Edit these two values to configure the app. */ + /* DATA_URL: "" = local mode, "<url>" = static site mode */ + /* MAX_CUPS: maximum cups per day */ + /* WEEK_START: first day of week (0=Sun, 1=Mon … 6=Sat) */ + + :root { + --bg: #fff; + --fg: #111; + --muted: #888; + --border: #ccc; + --cell-0: #eee; + --cell-1: #bbb; + --cell-2: #888; + --cell-3: #555; + --cell-4: #222; + --today-outline: #111; + --tooltip-bg: #000; + --tooltip-fg: #fff; + --hover-bg: #111; + --hover-fg: #fff; + --popover-bg: #fff; + --popover-border: #ccc; + } + + @media (prefers-color-scheme: dark) { + :root { + --bg: #111; + --fg: #eee; + --muted: #777; + --border: #444; + --cell-0: #222; + --cell-1: #444; + --cell-2: #666; + --cell-3: #999; + --cell-4: #ccc; + --today-outline: #eee; + --tooltip-bg: #fff; + --tooltip-fg: #000; + --hover-bg: #eee; + --hover-fg: #111; + --popover-bg: #1a1a1a; + --popover-border: #444; + } + } + + *, + *::before, + *::after { + unicode-bidi: plaintext; + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + font-family: "Kawkab Mono", monospace; + background: var(--bg); + color: var(--fg); + margin: 0; + padding: 4rem 0; + line-height: 1.4; + font-size: 16px; + min-height: 100%; + overflow-wrap: break-word; + } + + header, main, footer { max-width: 800px; margin-inline: auto; padding: 0 2rem; } + h1, header, footer { text-align: center; } + main { text-align: left; } + header { margin-bottom: 1em; } + footer { margin-top: 3em; } + a { color: inherit; } + + h1 { + font-size: 1.2rem; + margin-bottom: 0.25rem; + } + + h2 { + font-size: 0.95rem; + margin: 1.5rem 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + } + + hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.25rem 0; + } + + ul { + list-style: none; + padding: 0; + } + + ul li { + padding: 0.1rem 0; + } + + .subtitle { + color: var(--muted); + font-size: 0.85rem; + margin-bottom: 1rem; + } + + .static-notice { + font-size: 0.8rem; + color: var(--muted); + border: 1px solid var(--border); + padding: 0.4rem 0.7rem; + margin-bottom: 1rem; + display: inline-block; + } + + /* ── HEATMAP ─────────────────────────────────────────────── */ + .heatmap-wrap { + overflow-x: auto; + margin: 0.5rem 0 0.25rem; + } + + #heatmap-svg { + display: block; + font-family: inherit; + } + + .cell { + cursor: pointer; + } + + .cell:hover rect { + opacity: 0.75; + } + + /* ── LEGEND ─────────────────────────────────────────────── */ + .legend { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.35rem; + flex-wrap: wrap; + } + + .legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; + flex-shrink: 0; + } + + /* ── CONTROLS ────────────────────────────────────────────── */ + .controls { + margin-top: 0.25rem; + } + + .row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + } + + button { + font-family: inherit; + font-size: 0.85rem; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + padding: 0.2rem 0.65rem; + cursor: pointer; + line-height: 1.5; + transition: background 0.1s, color 0.1s; + } + + button:hover:not(:disabled) { + background: var(--hover-bg); + color: var(--hover-fg); + } + + button:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .count-display { + min-width: 1.8rem; + text-align: center; + font-size: 0.85rem; + } + + .hint { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.3rem; + } + + /* ── STATS ───────────────────────────────────────────────── */ + .stats-list li { + display: flex; + gap: 0.75rem; + } + + .stats-list .label { + color: var(--muted); + min-width: 130px; + } + + .stats-list .value { + font-weight: bold; + } + + /* ── TOOLTIP ─────────────────────────────────────────────── */ + #tooltip { + position: fixed; + background: var(--tooltip-bg); + color: var(--tooltip-fg); + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + pointer-events: none; + z-index: 1000; + white-space: nowrap; + display: none; + border-radius: 2px; + } + + /* ── POPOVER ─────────────────────────────────────────────── */ + #popover { + position: absolute; + background: var(--popover-bg); + border: 1px solid var(--popover-border); + padding: 0.75rem 1rem; + z-index: 500; + display: none; + min-width: 200px; + font-size: 0.85rem; + } + + #popover .pop-title { + font-size: 0.75rem; + color: var(--muted); + margin-bottom: 0.5rem; + } + + #popover .pop-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; + } + + #popover input[type="number"] { + font-family: inherit; + font-size: 0.85rem; + width: 3.5rem; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + padding: 0.2rem 0.4rem; + text-align: center; + -moz-appearance: textfield; + } + + #popover input[type="number"]::-webkit-inner-spin-button, + #popover input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + } + + #popover .pop-actions { + display: flex; + gap: 0.4rem; + } + + /* ── IMPORT HIDDEN ───────────────────────────────────────── */ + #import-input { + display: none; + } + </style> +</head> + +<body> + + <div id="tooltip"></div> + <div id="popover"> + <div class="pop-title" id="pop-title"></div> + <div class="pop-row"> + <button id="pop-dec">−</button> + <input type="number" id="pop-input" min="0" max="6"> + <button id="pop-inc">+</button> + </div> + <div class="pop-actions"> + <button id="pop-set">set</button> + <button id="pop-cancel">cancel</button> + </div> + </div> + + <header> + <h1><a href="https://gumx.cc">gumx</a> / coffee</h1> + </header> + + <main> + + <div id="static-notice" style="display:none"> + <span class="static-notice">static site mode — read-only</span> + </div> + + <div class="heatmap-wrap"> + <svg id="heatmap-svg"></svg> + </div> + + <div class="legend" id="legend"></div> + + <div id="controls" class="controls"> + <hr> + <h2>Log</h2> + <div class="row"> + <button id="btn-dec-log">−</button> + <span class="count-display" id="log-count-display">1</span> + <button id="btn-inc-log">+</button> + <button id="btn-log">log coffee</button> + </div> + <p class="hint">click any cell to edit that day's count</p> + <hr> + <h2>Data</h2> + <div class="row"> + <button id="btn-export">export json</button> + <button id="btn-import-trigger">import json</button> + <input type="file" id="import-input" accept=".json,application/json"> + </div> + </div> + + <hr> + <h2>Stats</h2> + <ul class="stats-list" id="stats-list"> + <li><span class="label">today</span><span class="value" id="stat-today">0 cups</span></li> + <li><span class="label">current streak</span><span class="value" id="stat-streak">0 days</span></li> + <li><span class="label">total cups</span><span class="value" id="stat-total">0</span></li> + <li><span class="label">daily average</span><span class="value" id="stat-avg">0.0</span></li> + </ul> + + </main> + + <footer> + <hr> + <a href="https://twt.gumx.cc">twt</a> / + <a href="https://git.gumx.cc">git</a> / + <a href="https://mail.gumx.cc">mail</a> / + <a href="https://irc.gumx.cc">irc</a> / + <a href="https://files.gumx.cc">files</a> / + <a href="https://vpn.gumx.cc">vpn</a> / + <a href="https://pgp.gumx.cc">pgp</a> / + <a href="https://demo.gumx.cc">demo</a> / + <a href="https://wk.fo">wk.fo</a> + <br> + <a href="https://git.gumx.cc/ccc">source</a> + </footer> + + <script> + // ───────────────────────────────────────────────────────────── + // CONFIG — edit these values to configure the app + // ───────────────────────────────────────────────────────────── + const CONFIG = { + DATA_URL: "data.json", // "" = local mode | "<url>" = static site mode + MAX_CUPS: 5, // maximum cups allowed per day + WEEK_START: 6, // first day of the week: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat + }; + // ───────────────────────────────────────────────────────────── + + const STORAGE_KEY = "ccc_v1"; + const CELL_SIZE = 10; + const CELL_GAP = 1; + const STEP = CELL_SIZE + CELL_GAP; + const WEEKS = 53; + const DAYS = 7; + const LEFT_OFFSET = 28; // space for day labels + const TOP_OFFSET = 18; // space for month labels + + // ── State ───────────────────────────────────────────────────── + let entries = {}; // { "YYYY-MM-DD": count } + let logCount = 1; + let popoverDate = null; + let isStaticMode = CONFIG.DATA_URL !== ""; + + // ── Date utils ──────────────────────────────────────────────── + function toISO(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + + function todayISO() { + return toISO(new Date()); + } + + function parseDate(iso) { + const [y, m, d] = iso.split("-").map(Number); + return new Date(y, m - 1, d); + } + + function addDays(d, n) { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; + } + + function formatDisplay(iso) { + const d = parseDate(iso); + const opts = {weekday: "short", year: "numeric", month: "short", day: "numeric"}; + return d.toLocaleDateString(undefined, opts); + } + + // ── Color scale ─────────────────────────────────────────────── + function cellLevel(count) { + if (!count || count <= 0) return 0; + if (CONFIG.MAX_CUPS <= 0) return 0; + const ratio = count / CONFIG.MAX_CUPS; + const level = Math.round(ratio * 4); + return Math.min(4, Math.max(1, level)); + } + + const LEVEL_VARS = ["--cell-0", "--cell-1", "--cell-2", "--cell-3", "--cell-4"]; + + function getCSSColor(level) { + return `var(${LEVEL_VARS[level]})`; + } + + // ── Heatmap ─────────────────────────────────────────────────── + function buildHeatmap() { + const svg = document.getElementById("heatmap-svg"); + svg.innerHTML = ""; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayStr = toISO(today); + + // Grid: column 0 = oldest week, column 52 = current week. + // Rows run from WEEK_START (row 0) to WEEK_START+6 (row 6) mod 7. + // endDate = last day of the current week (WEEK_START + 6 days from the week's start). + const ws = ((CONFIG.WEEK_START % 7) + 7) % 7; // normalise to 0-6 + const todayDow = today.getDay(); // 0=Sun … 6=Sat + // How many days until the end of the current week (the day before ws, mod 7) + const daysUntilEnd = ((ws - 1 - todayDow + 7) % 7); // 0 when today IS the last day + const endDate = addDays(today, daysUntilEnd); + const startDate = addDays(endDate, -(WEEKS * 7 - 1)); // 53 weeks back + + const totalWidth = LEFT_OFFSET + WEEKS * STEP; + const totalHeight = TOP_OFFSET + DAYS * STEP; + + svg.setAttribute("width", totalWidth); + svg.setAttribute("height", totalHeight); + svg.setAttribute("viewBox", `0 0 ${totalWidth} ${totalHeight}`); + + // ── Month labels ────────────────────────────────────────── + let lastMonth = -1; + for (let col = 0; col < WEEKS; col++) { + const colStartDate = addDays(startDate, col * 7); + const month = colStartDate.getMonth(); + if (month !== lastMonth) { + lastMonth = month; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", LEFT_OFFSET + col * STEP); + text.setAttribute("y", 10); + text.setAttribute("font-size", "9"); + text.setAttribute("fill", "var(--muted)"); + text.textContent = MONTHS[month]; + svg.appendChild(text); + } + } + + // ── Day labels ──────────────────────────────────────────── + // Build a 7-element label array rotated so row 0 = WEEK_START. + // Show the label on row 1 and row 3 (alternating visible rows). + const ALL_DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const DAY_LABELS = Array.from({length: 7}, (_, row) => { + const dow = (ws + row) % 7; + // Show label on rows 1 and 3 (skip row 0 and even rows ≥2 to avoid clutter) + return (row === 1 || row === 3 || row === 5) ? ALL_DAY_NAMES[dow] : null; + }); + DAY_LABELS.forEach((label, row) => { + if (!label) return; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", LEFT_OFFSET - 4); + text.setAttribute("y", TOP_OFFSET + row * STEP + CELL_SIZE - 1); + text.setAttribute("font-size", "9"); + text.setAttribute("fill", "var(--muted)"); + text.setAttribute("text-anchor", "end"); + text.textContent = label; + svg.appendChild(text); + }); + + // ── Cells ───────────────────────────────────────────────── + const tooltip = document.getElementById("tooltip"); + + for (let col = 0; col < WEEKS; col++) { + for (let row = 0; row < DAYS; row++) { + const cellDate = addDays(startDate, col * 7 + row); // row 0 = week-start day + cellDate.setHours(0, 0, 0, 0); + if (cellDate > today) continue; // don't render future cells + + const iso = toISO(cellDate); + const count = entries[iso] || 0; + const level = cellLevel(count); + const isToday = iso === todayStr; + + const x = LEFT_OFFSET + col * STEP; + const y = TOP_OFFSET + row * STEP; + + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.setAttribute("class", "cell"); + g.setAttribute("data-date", iso); + g.setAttribute("data-count", count); + + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", x); + rect.setAttribute("y", y); + rect.setAttribute("width", CELL_SIZE); + rect.setAttribute("height", CELL_SIZE); + rect.setAttribute("rx", "2"); + rect.setAttribute("fill", getCSSColor(level)); + + if (isToday) { + rect.setAttribute("stroke", "var(--today-outline)"); + rect.setAttribute("stroke-width", "1.5"); + } + + g.appendChild(rect); + + // Tooltip events + g.addEventListener("mouseenter", (e) => { + const cups = entries[iso] || 0; + const label = cups === 1 ? "cup" : "cups"; + tooltip.textContent = `${formatDisplay(iso)} — ${cups} ${label}`; + tooltip.style.display = "block"; + positionTooltip(e); + }); + g.addEventListener("mousemove", positionTooltip); + g.addEventListener("mouseleave", () => { + tooltip.style.display = "none"; + }); + + // Click to edit (local mode only) + if (!isStaticMode) { + g.addEventListener("click", (e) => { + e.stopPropagation(); + openPopover(iso, e.target); + }); + } + + svg.appendChild(g); + } + } + } + + function positionTooltip(e) { + const tooltip = document.getElementById("tooltip"); + const margin = 12; + let x = e.clientX + margin; + let y = e.clientY + margin; + const tw = tooltip.offsetWidth; + const th = tooltip.offsetHeight; + if (x + tw > window.innerWidth - 8) x = e.clientX - tw - margin; + if (y + th > window.innerHeight - 8) y = e.clientY - th - margin; + tooltip.style.left = x + "px"; + tooltip.style.top = y + "px"; + } + + // ── Legend ──────────────────────────────────────────────────── + function buildLegend() { + const legend = document.getElementById("legend"); + legend.innerHTML = ""; + + const span0 = document.createElement("span"); + span0.style.color = "var(--muted)"; + span0.style.fontSize = "0.75rem"; + span0.textContent = "less"; + legend.appendChild(span0); + + for (let i = 0; i <= 4; i++) { + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + swatch.style.background = `var(${LEVEL_VARS[i]})`; + if (i === 0) { + swatch.style.border = "1px solid var(--border)"; + } + legend.appendChild(swatch); + } + + const span1 = document.createElement("span"); + span1.style.color = "var(--muted)"; + span1.style.fontSize = "0.75rem"; + span1.textContent = `more (max ${CONFIG.MAX_CUPS})`; + legend.appendChild(span1); + } + + // ── Stats ───────────────────────────────────────────────────── + function updateStats() { + const today = todayISO(); + const todayCups = entries[today] || 0; + + document.getElementById("stat-today").textContent = + `${todayCups} / ${CONFIG.MAX_CUPS} cup${todayCups === 1 ? "" : "s"}`; + + // Streak: consecutive days with at least 1 cup, ending today or yesterday + let streak = 0; + let check = new Date(); + check.setHours(0, 0, 0, 0); + + // If today has no cups, start from yesterday + if (!entries[today]) { + check = addDays(check, -1); + } + + while (true) { + const iso = toISO(check); + if (entries[iso] && entries[iso] > 0) { + streak++; + check = addDays(check, -1); + } else { + break; + } + } + + document.getElementById("stat-streak").textContent = + `${streak} day${streak === 1 ? "" : "s"}`; + + const allDates = Object.keys(entries).filter(k => entries[k] > 0); + const totalCups = allDates.reduce((s, k) => s + entries[k], 0); + const activeDays = allDates.length; + const avg = activeDays > 0 ? (totalCups / activeDays).toFixed(1) : "0.0"; + + document.getElementById("stat-total").textContent = totalCups; + document.getElementById("stat-avg").textContent = `${avg} cups/day`; + } + + // ── Controls ────────────────────────────────────────────────── + function updateLogButtons() { + const today = todayISO(); + const todayCups = entries[today] || 0; + const remaining = CONFIG.MAX_CUPS - todayCups; + + const btnDec = document.getElementById("btn-dec-log"); + const btnInc = document.getElementById("btn-inc-log"); + const btnLog = document.getElementById("btn-log"); + const display = document.getElementById("log-count-display"); + + // clamp logCount + if (logCount < 1) logCount = 1; + if (logCount > 10) logCount = 10; + if (logCount > remaining) logCount = Math.max(1, remaining); + + display.textContent = logCount; + + btnDec.disabled = logCount <= 1; + btnInc.disabled = logCount >= remaining || logCount >= 10; + btnLog.disabled = remaining <= 0; + } + + document.getElementById("btn-dec-log").addEventListener("click", () => { + logCount = Math.max(1, logCount - 1); + updateLogButtons(); + }); + + document.getElementById("btn-inc-log").addEventListener("click", () => { + const today = todayISO(); + const todayCups = entries[today] || 0; + const remaining = CONFIG.MAX_CUPS - todayCups; + logCount = Math.min(10, Math.min(remaining, logCount + 1)); + updateLogButtons(); + }); + + document.getElementById("btn-log").addEventListener("click", () => { + const today = todayISO(); + const current = entries[today] || 0; + const newVal = Math.min(CONFIG.MAX_CUPS, current + logCount); + if (newVal > 0) { + entries[today] = newVal; + } + saveData(); + refresh(); + }); + + // ── Popover ─────────────────────────────────────────────────── + function openPopover(iso, targetEl) { + const popover = document.getElementById("popover"); + const input = document.getElementById("pop-input"); + const title = document.getElementById("pop-title"); + + popoverDate = iso; + const current = entries[iso] || 0; + + title.textContent = formatDisplay(iso); + input.value = current; + input.max = CONFIG.MAX_CUPS; + updatePopoverButtons(); + + // Position near the cell + const svgEl = document.getElementById("heatmap-svg"); + const svgRect = svgEl.getBoundingClientRect(); + const targetRect = targetEl.closest("g").getBoundingClientRect + ? targetEl.getBoundingClientRect() + : svgRect; + + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + let px = targetRect.left + scrollLeft + CELL_SIZE + 4; + let py = targetRect.top + scrollTop; + + popover.style.display = "block"; + + // After display, check bounds + const pw = popover.offsetWidth; + const ph = popover.offsetHeight; + const maxLeft = scrollLeft + window.innerWidth - pw - 8; + const maxTop = scrollTop + window.innerHeight - ph - 8; + + if (px > maxLeft) px = targetRect.left + scrollLeft - pw - 4; + if (py > maxTop) py = maxTop; + if (py < scrollTop + 4) py = scrollTop + 4; + + popover.style.left = px + "px"; + popover.style.top = py + "px"; + + input.focus(); + input.select(); + } + + function closePopover() { + document.getElementById("popover").style.display = "none"; + popoverDate = null; + } + + function updatePopoverButtons() { + const input = document.getElementById("pop-input"); + const val = parseInt(input.value) || 0; + document.getElementById("pop-dec").disabled = val <= 0; + document.getElementById("pop-inc").disabled = val >= CONFIG.MAX_CUPS; + } + + document.getElementById("pop-dec").addEventListener("click", () => { + const input = document.getElementById("pop-input"); + const val = Math.max(0, (parseInt(input.value) || 0) - 1); + input.value = val; + updatePopoverButtons(); + }); + + document.getElementById("pop-inc").addEventListener("click", () => { + const input = document.getElementById("pop-input"); + const val = Math.min(CONFIG.MAX_CUPS, (parseInt(input.value) || 0) + 1); + input.value = val; + updatePopoverButtons(); + }); + + document.getElementById("pop-input").addEventListener("input", updatePopoverButtons); + + document.getElementById("pop-set").addEventListener("click", () => { + if (!popoverDate) return; + const val = parseInt(document.getElementById("pop-input").value) || 0; + if (val <= 0) { + delete entries[popoverDate]; + } else { + entries[popoverDate] = Math.min(CONFIG.MAX_CUPS, val); + } + saveData(); + closePopover(); + refresh(); + }); + + document.getElementById("pop-cancel").addEventListener("click", closePopover); + + document.getElementById("pop-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") document.getElementById("pop-set").click(); + if (e.key === "Escape") closePopover(); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && popoverDate) closePopover(); + }); + + document.addEventListener("click", (e) => { + const popover = document.getElementById("popover"); + if (popover.style.display !== "none" && !popover.contains(e.target)) { + closePopover(); + } + }); + + // ── Export / Import ─────────────────────────────────────────── + document.getElementById("btn-export").addEventListener("click", () => { + const data = JSON.stringify({entries}, null, 2); + const blob = new Blob([data], {type: "application/json"}); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "ccc-data.json"; + a.click(); + URL.revokeObjectURL(url); + }); + + document.getElementById("btn-import-trigger").addEventListener("click", () => { + document.getElementById("import-input").click(); + }); + + document.getElementById("import-input").addEventListener("change", (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const parsed = JSON.parse(ev.target.result); + if (parsed && typeof parsed.entries === "object") { + entries = {}; + for (const [k, v] of Object.entries(parsed.entries)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(k) && typeof v === "number" && v > 0) { + entries[k] = Math.min(CONFIG.MAX_CUPS, v); + } + } + saveData(); + refresh(); + } else { + alert("Invalid data format. Expected { \"entries\": { ... } }"); + } + } catch { + alert("Failed to parse JSON file."); + } + }; + reader.readAsText(file); + e.target.value = ""; + }); + + // ── Storage ─────────────────────────────────────────────────── + function saveData() { + if (isStaticMode) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify({entries})); + } + + function loadLocalData() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.entries === "object") { + entries = {}; + for (const [k, v] of Object.entries(parsed.entries)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(k) && typeof v === "number" && v > 0) { + entries[k] = v; + } + } + } + } catch { + entries = {}; + } + } + + async function loadRemoteData() { + try { + const res = await fetch(CONFIG.DATA_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const parsed = await res.json(); + if (parsed && typeof parsed.entries === "object") { + entries = {}; + for (const [k, v] of Object.entries(parsed.entries)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(k) && typeof v === "number" && v > 0) { + entries[k] = v; + } + } + } + } catch (err) { + console.error("ccc: failed to load data from", CONFIG.DATA_URL, err); + } + } + + // ── Render ──────────────────────────────────────────────────── + function refresh() { + buildHeatmap(); + buildLegend(); + updateStats(); + if (!isStaticMode) updateLogButtons(); + } + + // ── Dark mode observer ──────────────────────────────────────── + const darkMQ = window.matchMedia("(prefers-color-scheme: dark)"); + darkMQ.addEventListener("change", () => { + // Re-render SVG so CSS vars re-apply (SVG fill="var(...)" doesn't + // automatically repaint on MQ change in some browsers) + refresh(); + }); + + // ── Init ────────────────────────────────────────────────────── + async function init() { + if (isStaticMode) { + document.getElementById("static-notice").style.display = "block"; + document.getElementById("controls").style.display = "none"; + await loadRemoteData(); + } else { + loadLocalData(); + } + refresh(); + } + + init(); + </script> +</body> + +</html>
\ No newline at end of file |
