aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 16:24:32 +0300
committerAhmed <git@gumx.cc>2026-06-14 16:24:32 +0300
commit004f2e9964c36e62b4da8d272cfd76c56dc81f1d (patch)
treebfc5d7a84c588c4fe1699f16a5a8c773fb1a322a
init: coffee cups contributions tracker
-rwxr-xr-x.build.yml15
-rw-r--r--LICENSE21
-rw-r--r--README.md119
-rw-r--r--ccc.html924
-rw-r--r--data.json94
-rw-r--r--hooks/post-receive8
6 files changed, 1181 insertions, 0 deletions
diff --git a/.build.yml b/.build.yml
new file mode 100755
index 0000000..6fce0a4
--- /dev/null
+++ b/.build.yml
@@ -0,0 +1,15 @@
+image: alpine/edge
+oauth: pages.sr.ht/PAGES:RW
+packages:
+ - hut
+environment:
+ site: ccc.demo.gumx.cc
+sources:
+ - https://git.sr.ht/~gumxcc/ccc
+tasks:
+ - package: |
+ cd ccc
+ mv ccc.html index.html
+ tar -cvz index.html > ../site.tar.gz
+ - upload: |
+ hut pages publish -d $site site.tar.gz
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dd35f66
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ahmed Mohamed Alaa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5ade843
--- /dev/null
+++ b/README.md
@@ -0,0 +1,119 @@
+# ccc
+
+Coffee Cups Contributions. A single-file, dependency-free coffee intake tracker with a GitHub-style heatmap. No build step, no framework, plain HTML, CSS, and JavaScript.
+
+Live at [coffee.gumx.cc](https://coffee.gumx.cc). Source at [git.gumx.cc/ccc](https://git.gumx.cc/ccc).
+
+---
+
+## Features
+
+- **Contributions heatmap** — 53 × 7 grid (Sunday-aligned) covering the past 52 weeks, rendered in SVG. Month labels along the top, Mon/Wed/Fri labels on the left. Today's cell has a visible outline.
+- **5-level greyscale scale** — cup counts are bucketed proportionally to `MAX_CUPS` using `Math.round`. A legend is shown below the heatmap.
+- **Hover tooltips** — hovering any cell shows the date and cup count in a fixed-position tooltip.
+- **Two modes** — local mode (full editing, localStorage) or static site mode (read-only, fetches from a remote URL).
+- **Stats** — today's cups, current streak, total cups, and daily average.
+- **Light and dark mode** — full `prefers-color-scheme` support via CSS custom properties. The heatmap re-renders if the OS theme changes mid-session.
+
+---
+
+## Files
+
+| File | Description |
+|---|---|
+| `ccc.html` | The entire application — one self-contained HTML file |
+| `data.json` | Example data file for static site mode |
+| `README.md` | This document |
+| `LICENSE` | MIT licence |
+
+---
+
+## Local Mode
+
+Local mode is active when `DATA_URL` is set to `""` (the default). All data is persisted to `localStorage` under the key `ccc_v1`.
+
+### Logging Coffee
+
+A **− / count / +** adjuster lets you set how many cups to log (1–10). The **+** button disables when `logCount` would exceed the remaining cups for today. The **log coffee** button disables when today has already reached `MAX_CUPS`. Both controls re-evaluate after every action.
+
+Clicking **log coffee** adds the selected count to today's total, hard-capped at `MAX_CUPS`.
+
+### Editing Past Days
+
+Click any heatmap cell to open an inline popover for that day. The popover shows:
+
+- The date
+- **− / number input / +** to adjust the count (0–`MAX_CUPS`)
+- **set** and **cancel** buttons
+
+Keyboard shortcuts work inside the popover: **Enter** confirms, **Escape** cancels. Clicking anywhere outside the popover also cancels. Setting a day's count to 0 removes the entry entirely.
+
+A hint below the log controls reads: *"click any cell to edit that day's count"*.
+
+### Import and Export
+
+- **Export JSON** — downloads your current data as `ccc-data.json`.
+- **Import JSON** — opens a file picker; selecting a valid JSON file replaces the current data. Invalid files show an alert.
+
+The data format is described below.
+
+---
+
+## Static Site Mode
+
+Static site mode is active when `DATA_URL` is set to a non-empty URL string. On load, the app fetches `data.json` (or whatever file is at that URL) and renders the heatmap from it.
+
+In static site mode:
+
+- All editing controls are hidden.
+- Import and export are unavailable.
+- A **"static site mode — read-only"** notice appears below the title.
+- Data is never written to `localStorage`.
+
+To use static site mode, host `ccc.html` and `data.json` on any static hosting service (GitHub Pages, Netlify, etc.), then set `DATA_URL` to the full URL of `data.json`.
+
+---
+
+## Configuration
+
+The `CONFIG` block is at the top of the `<script>` tag in `ccc.html`:
+
+```js
+const CONFIG = {
+ DATA_URL: "", // "" = local mode | "<url>" = static site mode
+ MAX_CUPS: 6, // maximum cups allowed per day
+ WEEK_START: 1, // first day of the week: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
+};
+```
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `DATA_URL` | string | `""` | URL to fetch `data.json` from. Empty string activates local mode. |
+| `MAX_CUPS` | integer | `6` | Maximum cups allowed per day. Controls the colour scale and log/edit caps. |
+| `WEEK_START` | integer | `1` | First day of the week. `0` = Sunday, `1` = Monday (ISO default), through `6` = Saturday. Affects the heatmap column alignment and day labels. |
+
+---
+
+## Data Format
+
+Data is stored as a JSON object with a single `entries` key. Each entry is a date string (`YYYY-MM-DD`) mapped to a cup count (integer, 1–`MAX_CUPS`). Days with zero cups are omitted.
+
+```json
+{
+ "entries": {
+ "2026-04-07": 3,
+ "2026-04-08": 2
+ }
+}
+```
+
+- Dates must match the pattern `YYYY-MM-DD`.
+- Values must be positive integers. Zero or negative values are ignored on import.
+- Values exceeding `MAX_CUPS` are clamped on import.
+- Deleting an entry (setting it to 0 via the cell editor) removes the key entirely.
+
+---
+
+## Licence
+
+MIT — see [LICENSE](LICENSE).
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
diff --git a/data.json b/data.json
new file mode 100644
index 0000000..7f45a82
--- /dev/null
+++ b/data.json
@@ -0,0 +1,94 @@
+{
+ "entries": {
+ "2026-01-01": 2,
+ "2026-01-02": 2,
+ "2026-01-03": 3,
+ "2026-01-04": 3,
+ "2026-01-05": 3,
+ "2026-01-06": 1,
+ "2026-01-07": 2,
+ "2026-01-08": 2,
+ "2026-01-09": 1,
+ "2026-01-10": 2,
+ "2026-01-11": 1,
+ "2026-01-12": 4,
+ "2026-01-13": 3,
+ "2026-01-14": 1,
+ "2026-01-15": 1,
+ "2026-01-16": 1,
+ "2026-01-17": 2,
+ "2026-01-18": 3,
+ "2026-01-19": 3,
+ "2026-01-20": 3,
+ "2026-01-21": 2,
+ "2026-01-22": 2,
+ "2026-01-23": 1,
+ "2026-01-24": 1,
+ "2026-01-25": 2,
+ "2026-01-26": 2,
+ "2026-01-27": 1,
+ "2026-01-28": 2,
+ "2026-01-29": 1,
+ "2026-01-30": 2,
+ "2026-01-31": 2,
+ "2026-02-01": 1,
+ "2026-02-02": 1,
+ "2026-02-03": 1,
+ "2026-02-04": 3,
+ "2026-02-07": 2,
+ "2026-02-08": 2,
+ "2026-02-09": 3,
+ "2026-02-10": 1,
+ "2026-02-11": 3,
+ "2026-02-13": 2,
+ "2026-02-14": 2,
+ "2026-02-15": 2,
+ "2026-02-16": 2,
+ "2026-02-17": 1,
+ "2026-02-18": 1,
+ "2026-02-19": 1,
+ "2026-02-23": 2,
+ "2026-02-24": 1,
+ "2026-02-25": 2,
+ "2026-02-27": 1,
+ "2026-02-28": 1,
+ "2026-03-01": 1,
+ "2026-03-02": 2,
+ "2026-03-03": 1,
+ "2026-03-04": 1,
+ "2026-03-06": 1,
+ "2026-03-07": 2,
+ "2026-03-09": 2,
+ "2026-03-10": 2,
+ "2026-03-12": 1,
+ "2026-03-13": 1,
+ "2026-03-14": 2,
+ "2026-03-15": 3,
+ "2026-03-16": 3,
+ "2026-03-17": 1,
+ "2026-03-18": 1,
+ "2026-03-19": 2,
+ "2026-03-20": 1,
+ "2026-03-21": 1,
+ "2026-03-23": 1,
+ "2026-03-24": 3,
+ "2026-03-25": 3,
+ "2026-03-26": 3,
+ "2026-03-27": 4,
+ "2026-03-28": 4,
+ "2026-03-29": 4,
+ "2026-03-30": 2,
+ "2026-03-31": 2,
+ "2026-04-01": 1,
+ "2026-04-02": 2,
+ "2026-04-03": 2,
+ "2026-04-04": 3,
+ "2026-04-05": 1,
+ "2026-04-06": 2,
+ "2026-04-07": 1,
+ "2026-04-08": 3,
+ "2026-04-09": 2,
+ "2026-04-10": 2,
+ "2026-04-11": 2
+ }
+} \ No newline at end of file
diff --git a/hooks/post-receive b/hooks/post-receive
new file mode 100644
index 0000000..051bd55
--- /dev/null
+++ b/hooks/post-receive
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+WORK=/home/git/build/ccc
+WEBROOT=/var/www/coffee.gumx.cc
+mkdir -p "$WEBROOT/fonts"
+cp "$WORK/ccc.html" "$WEBROOT/index.html"
+rsync -rlptD "$WORK/fonts/" "$WEBROOT/fonts/" 2>/dev/null || true
+echo "ccc deployed to coffee.gumx.cc"