commit ed3a0d3ea35ad001425f88e4187d98e117167533 Author: Spencer Flagg Date: Thu Apr 23 16:45:06 2026 +0200 Initial commit: raycer accountability PWA Vanilla HTML/JS/CSS PWA with Dexie offline-first sync, Hono+SQLite backend, served via nginx reverse-proxy. Two seed goals (no-sugar, no-social-media) for users ray and cer. Local dev runs at https://raycer.test via the shared Traefik proxy. Production deploys to https://raycer.altweb.me on cool2026/personal via docker-compose.coolify.yaml — see deploy/COOLIFY.md. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4778850 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +build/ +.env +.env.local +*.log +*.sqlite +*.sqlite-journal +*.sqlite-wal +*.sqlite-shm +.DS_Store +.vscode/ +.idea/ +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e52f915 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# raycer + +A minimal habit-accountability PWA for two users (`ray` and `cer`). + +- **Frontend:** vanilla HTML/JS/CSS, Dexie.js, service worker, installable PWA +- **Backend:** Node 20 + Hono + better-sqlite3 +- **Reverse proxy:** Traefik on `raycer.test` (see `~/Code/Personal_TOOLS/traefik-local-proxy`) +- **Sync:** offline-first with outbox + last-write-wins server upsert + +## Run + +```bash +docker compose up -d --build +~/Code/Personal_TOOLS/traefik-local-proxy/add-domain.sh raycer +open https://raycer.test +``` + +## Layout + +``` +frontend/ nginx:alpine serving the static PWA, proxies /api to backend +backend/ Hono API + SQLite (volume: raycer-data) +``` + +## Goals (preseeded) + +- **No sugar** — 2026-04-23 → 2026-05-21 +- **No social media** — 2026-04-23 → 2026-05-21 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0fc0fbc --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +data +*.sqlite* +.env +.git +README.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0eb794b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,30 @@ +# --- builder: install deps (better-sqlite3 needs build toolchain) --- +FROM node:20-alpine AS builder +WORKDIR /app + +RUN apk add --no-cache python3 make g++ + +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund + +# --- runtime --- +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + DB_PATH=/data/raycer.sqlite + +RUN addgroup -S app && adduser -S app -G app && \ + mkdir -p /data && chown -R app:app /data + +COPY --from=builder /app/node_modules ./node_modules +COPY --chown=app:app package.json ./ +COPY --chown=app:app src ./src + +USER app +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 + +CMD ["node", "src/server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c136121 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "raycer-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "@hono/node-server": "^2.0.0", + "better-sqlite3": "^12.9.0", + "hono": "^4.12.14", + "zod": "^4.3.6" + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..1df56b6 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,33 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const DB_PATH = process.env.DB_PATH || './data/raycer.sqlite'; + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS goals ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS completions ( + user TEXT NOT NULL CHECK (user IN ('ray','cer')), + goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + date TEXT NOT NULL, + completed INTEGER NOT NULL CHECK (completed IN (0,1)), + updated_at INTEGER NOT NULL, + PRIMARY KEY (user, goal_id, date) + ); + + CREATE INDEX IF NOT EXISTS idx_completions_updated_at ON completions(updated_at); +`); diff --git a/backend/src/routes/completions.js b/backend/src/routes/completions.js new file mode 100644 index 0000000..b0da077 --- /dev/null +++ b/backend/src/routes/completions.js @@ -0,0 +1,94 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { db } from '../db.js'; + +export const completions = new Hono(); + +const listSinceStmt = db.prepare(` + SELECT user, goal_id, date, completed, updated_at + FROM completions + WHERE updated_at > ? + ORDER BY updated_at ASC +`); + +const listAllStmt = db.prepare(` + SELECT user, goal_id, date, completed, updated_at + FROM completions + ORDER BY updated_at ASC +`); + +const getStmt = db.prepare(` + SELECT updated_at FROM completions WHERE user = ? AND goal_id = ? AND date = ? +`); + +const upsertStmt = db.prepare(` + INSERT INTO completions (user, goal_id, date, completed, updated_at) + VALUES (@user, @goal_id, @date, @completed, @updated_at) + ON CONFLICT(user, goal_id, date) DO UPDATE SET + completed = excluded.completed, + updated_at = excluded.updated_at + WHERE excluded.updated_at > completions.updated_at +`); + +const goalExistsStmt = db.prepare(`SELECT 1 AS x FROM goals WHERE id = ?`); + +const writeSchema = z.object({ + user: z.enum(['ray', 'cer']), + goal_id: z.string().min(1), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD'), + completed: z.boolean().optional().default(true), + updated_at: z.number().int().positive(), +}); + +completions.get('/', (c) => { + const since = Number(c.req.query('since') || 0); + const rows = since > 0 ? listSinceStmt.all(since) : listAllStmt.all(); + return c.json({ + completions: rows.map((r) => ({ ...r, completed: !!r.completed })), + server_now: Date.now(), + }); +}); + +completions.put('/', async (c) => { + const body = await c.req.json().catch(() => ({})); + const parsed = writeSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid body', details: parsed.error.issues }, 400); + } + const data = { + user: parsed.data.user, + goal_id: parsed.data.goal_id, + date: parsed.data.date, + completed: parsed.data.completed ? 1 : 0, + updated_at: parsed.data.updated_at, + }; + + if (!goalExistsStmt.get(data.goal_id)) { + return c.json({ error: 'unknown goal_id' }, 404); + } + + upsertStmt.run(data); + const row = getStmt.get(data.user, data.goal_id, data.date); + return c.json({ ok: true, stored_updated_at: row?.updated_at ?? null }); +}); + +completions.delete('/', async (c) => { + const body = await c.req.json().catch(() => ({})); + const parsed = writeSchema + .omit({ completed: true }) + .extend({ updated_at: z.number().int().positive() }) + .safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid body', details: parsed.error.issues }, 400); + } + const data = { + user: parsed.data.user, + goal_id: parsed.data.goal_id, + date: parsed.data.date, + completed: 0, + updated_at: parsed.data.updated_at, + }; + upsertStmt.run(data); + const row = getStmt.get(data.user, data.goal_id, data.date); + return c.json({ ok: true, stored_updated_at: row?.updated_at ?? null }); +}); diff --git a/backend/src/routes/goals.js b/backend/src/routes/goals.js new file mode 100644 index 0000000..e77de9e --- /dev/null +++ b/backend/src/routes/goals.js @@ -0,0 +1,15 @@ +import { Hono } from 'hono'; +import { db } from '../db.js'; + +export const goals = new Hono(); + +const listStmt = db.prepare(` + SELECT id, name, description, start_date, end_date, created_at + FROM goals + ORDER BY created_at ASC, id ASC +`); + +goals.get('/', (c) => { + const rows = listStmt.all(); + return c.json({ goals: rows }); +}); diff --git a/backend/src/seed.js b/backend/src/seed.js new file mode 100644 index 0000000..83dc1b0 --- /dev/null +++ b/backend/src/seed.js @@ -0,0 +1,32 @@ +import { db } from './db.js'; + +const SEED_GOALS = [ + { + id: 'g_no_sugar', + name: 'No sugar', + description: 'No refined sugar. No soda, no dessert, no sweet snacks.', + start_date: '2026-04-23', + end_date: '2026-05-21', + }, + { + id: 'g_no_social', + name: 'No social media', + description: 'No X. No instagram. No youtube shorts.', + start_date: '2026-04-23', + end_date: '2026-05-21', + }, +]; + +const insert = db.prepare(` + INSERT OR IGNORE INTO goals (id, name, description, start_date, end_date, created_at) + VALUES (@id, @name, @description, @start_date, @end_date, @created_at) +`); + +const now = Date.now(); +const tx = db.transaction((rows) => { + for (const g of rows) insert.run({ ...g, created_at: now }); +}); +tx(SEED_GOALS); + +const count = db.prepare('SELECT COUNT(*) AS n FROM goals').get().n; +console.log(`[seed] goals in db: ${count}`); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..29eaee0 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,23 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import './db.js'; +import './seed.js'; +import { goals } from './routes/goals.js'; +import { completions } from './routes/completions.js'; + +const app = new Hono(); + +app.use('*', logger()); +app.use('/api/*', cors({ origin: '*', allowMethods: ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS'] })); + +app.get('/api/health', (c) => c.json({ ok: true, ts: Date.now() })); + +app.route('/api/goals', goals); +app.route('/api/completions', completions); + +const port = Number(process.env.PORT || 3000); +serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }, (info) => { + console.log(`[raycer-backend] listening on :${info.port}`); +}); diff --git a/deploy/COOLIFY.md b/deploy/COOLIFY.md new file mode 100644 index 0000000..a0b5244 --- /dev/null +++ b/deploy/COOLIFY.md @@ -0,0 +1,131 @@ +# Deploying raycer to Coolify + +raycer runs in production at **https://raycer.altweb.me** as a Docker +Compose application managed by Coolify v4 on the `cool2026` instance. + +## Where things live + +- **Coolify dashboard:** https://cool2026.altweb.me +- **Server:** `personal` (Linode 91853095, Amsterdam, 2 GB) + - server UUID: `locg048kwko4sws8wcggc0o4` + - public IP: `172.235.183.140` +- **Project:** `tools` (UUID `u8wooo0wwk4k8wcw48ww8oo8`) +- **Source:** Forgejo, `https://forgejo-rko8sk40400wscowk4scko0w.altweb.me/spencer/raycer.git`, branch `main` (mirrored to GitLab `spencerflagg/raycer`) +- **Compose file:** `/docker-compose.coolify.yaml` (the local-dev `docker-compose.yml` is for `raycer.test` only and is not used in prod) + +## Containers + +| Service | Image source | Internal port | Public | Memory limit | +|----------|-----------------------------|---------------|-------------|--------------| +| backend | built from `./backend` | 3000 | (internal) | 256 MiB | +| frontend | built from `./frontend` | 80 | raycer.altweb.me | 64 MiB | + +The frontend's nginx reverse-proxies `/api/*` to `http://backend:3000`. + +## Required env (set in Coolify dashboard) + +| Variable | Value | Notes | +|-----------------------------|------------------------------------|-------| +| `SERVICE_FQDN_FRONTEND_80` | `https://raycer.altweb.me` | Coolify magic var; injects Traefik labels for the frontend service. | + +The backend's `NODE_ENV`, `PORT`, and `DB_PATH` are set inside the compose file. + +## Persistent storage + +A named Docker volume `raycer-data` is mounted into the backend at +`/data` and holds `raycer.sqlite`. Coolify creates and manages this +volume; it survives redeploys. + +To inspect or back up: + +```bash +ssh root@172.235.183.140 +docker volume inspect # find via: docker volume ls | grep raycer +docker run --rm -v :/data -v $(pwd):/backup alpine \ + tar czf /backup/raycer-sqlite-$(date +%F).tgz -C /data . +``` + +## Healthchecks + +Both containers have a `HEALTHCHECK` (backend hits `/api/health`, +frontend hits `/`). Coolify's "deployment healthy" gate uses these. + +## DNS + +A record `raycer.altweb.me -> 172.235.183.140` (Linode domain ID +`1544692`, TTL 300). To recreate: + +```bash +linode-cli domains records-create 1544692 \ + --type A --name raycer --target 172.235.183.140 --ttl_sec 300 +``` + +## How the app was created + +The Coolify v4 dashboard does not have a great "create from existing +config" path, so the original create call hit the API directly: + +```bash +TOKEN=$(jq -r '.instances[] | select(.name=="cool2026") | .token' \ + ~/.config/coolify/config.json) + +curl -X POST https://cool2026.altweb.me/api/v1/applications/public \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "project_uuid": "u8wooo0wwk4k8wcw48ww8oo8", + "server_uuid": "locg048kwko4sws8wcggc0o4", + "environment_name": "production", + "git_repository": "https://forgejo-rko8sk40400wscowk4scko0w.altweb.me/spencer/raycer.git", + "git_branch": "main", + "build_pack": "dockercompose", + "docker_compose_location": "/docker-compose.coolify.yaml", + "name": "raycer", + "instant_deploy": false + }' +``` + +After creation, the FQDN env var was set: + +```bash +curl -X POST https://cool2026.altweb.me/api/v1/applications//envs \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"key":"SERVICE_FQDN_FRONTEND_80","value":"https://raycer.altweb.me","is_preview":false}' +``` + +Then deployed: + +```bash +curl -X POST "https://cool2026.altweb.me/api/v1/deploy?uuid=&force=false" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Re-deploy + +Pushing to `main` on Forgejo triggers an automatic redeploy via the +Coolify webhook (configured at app create time). To force a manual +redeploy: + +```bash +coolify deploy --context cool2026 +# or +curl -X POST "https://cool2026.altweb.me/api/v1/deploy?uuid=" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Verification + +```bash +curl https://raycer.altweb.me/api/health # {"ok":true,...} +curl https://raycer.altweb.me/api/goals # both seeded goals +coolify app list --context cool2026 --format json | jq '.[] | select(.name=="raycer")' +``` + +## Resource notes + +The `personal` server is 2 GB and tightly accounted for; `mem_limit` +values in [docker-compose.coolify.yaml](../docker-compose.coolify.yaml) +must be respected. Backend's `better-sqlite3` requires a brief native +compile during the image build (~10 s, ~100 MB peak); first deploy may +take a minute longer than subsequent ones. diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml new file mode 100644 index 0000000..0f588ec --- /dev/null +++ b/docker-compose.coolify.yaml @@ -0,0 +1,46 @@ +# Coolify deployment compose for cool2026 / personal server. +# The local-dev docker-compose.yml uses Traefik labels for raycer.test; +# this file lets Coolify generate its own Traefik labels via SERVICE_FQDN_*. + +services: + backend: + build: ./backend + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - DB_PATH=/data/raycer.sqlite + volumes: + - raycer-data:/data + deploy: + resources: + limits: + memory: 256M + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + environment: + - SERVICE_FQDN_FRONTEND_80 + deploy: + resources: + limits: + memory: 64M + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + raycer-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d826ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: ./backend + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - DB_PATH=/data/raycer.sqlite + volumes: + - raycer-data:/data + networks: + - default + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - backend + labels: + - "traefik.enable=true" + - "traefik.http.routers.raycer.rule=Host(`raycer.test`)" + - "traefik.http.routers.raycer.entrypoints=websecure" + - "traefik.http.routers.raycer.tls=true" + - "traefik.http.routers.raycer-http.rule=Host(`raycer.test`)" + - "traefik.http.routers.raycer-http.entrypoints=web" + - "traefik.http.routers.raycer-http.middlewares=redirect-to-https@file" + - "traefik.http.services.raycer.loadbalancer.server.port=80" + networks: + - proxy-net + - default + +networks: + proxy-net: + external: true + default: + +volumes: + raycer-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..76f0e56 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +README.md +icons/icon-source.svg +icons/icon-maskable-source.svg diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4285e45 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:alpine + +# strip the default site, install ours +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/raycer.conf +COPY public/ /usr/share/nginx/html/ + +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..535bbc0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Don't cache the entrypoints / SW / manifest so updates roll out + location = /index.html { + add_header Cache-Control "no-cache, must-revalidate" always; + } + location = /sw.js { + add_header Cache-Control "no-cache, must-revalidate" always; + add_header Service-Worker-Allowed "/" always; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache" always; + types { } default_type application/manifest+json; + } + + # Static assets: short cache (the SW does the heavy caching) + location /css/ { add_header Cache-Control "public, max-age=300"; try_files $uri =404; } + location /js/ { add_header Cache-Control "public, max-age=300"; try_files $uri =404; } + location /icons/ { add_header Cache-Control "public, max-age=86400"; try_files $uri =404; } + + # API: proxy to backend container + location /api/ { + proxy_pass http://backend:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } + + # SPA fallback: any other path → index.html (History API routing) + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/public/css/styles.css b/frontend/public/css/styles.css new file mode 100644 index 0000000..5356d56 --- /dev/null +++ b/frontend/public/css/styles.css @@ -0,0 +1,537 @@ +/* ========================================================= + raycer - monotone, thin-line aesthetic + colors driven by CSS custom properties; per-user accent + ========================================================= */ + +:root { + --bg: #fafafa; + --bg-elev: #ffffff; + --fg: #0a0a0a; + --fg-mute: #6b6b6b; + --fg-faint: #b8b8b8; + --line: #d8d8d8; + --line-strong: #1a1a1a; + + --accent-ray: #c47a1c; + --accent-cer: #1c8a8a; + --accent: var(--accent-ray); + + --hairline: 1px; + --radius: 2px; + --gap-1: 4px; + --gap-2: 8px; + --gap-3: 16px; + --gap-4: 24px; + --gap-5: 32px; + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, monospace; +} + +:root[data-theme="dark"], +:root:not([data-theme]) { + --bg: #0a0a0a; + --bg-elev: #131313; + --fg: #f3f3f3; + --fg-mute: #8a8a8a; + --fg-faint: #3a3a3a; + --line: #2a2a2a; + --line-strong: #e8e8e8; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg: #fafafa; + --bg-elev: #ffffff; + --fg: #0a0a0a; + --fg-mute: #6b6b6b; + --fg-faint: #b8b8b8; + --line: #d8d8d8; + --line-strong: #1a1a1a; + } +} + +:root[data-theme="light"] { + --bg: #fafafa; + --bg-elev: #ffffff; + --fg: #0a0a0a; + --fg-mute: #6b6b6b; + --fg-faint: #b8b8b8; + --line: #d8d8d8; + --line-strong: #1a1a1a; +} + +body[data-user="ray"] { --accent: var(--accent-ray); } +body[data-user="cer"] { --accent: var(--accent-cer); } + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +body { + min-height: 100dvh; + overscroll-behavior: none; +} + +a { color: inherit; text-decoration: none; } +button { font: inherit; color: inherit; } + +#app { min-height: 100dvh; display: flex; flex-direction: column; } + +/* ---- Landing ---- */ + +.landing { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--gap-4); + padding: var(--gap-5); +} + +.landing__sub { + color: var(--fg-mute); + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + margin: 0; +} + +.brand { + display: inline-flex; + align-items: baseline; + gap: var(--gap-2); + font-family: var(--font-mono); + font-weight: 300; + letter-spacing: -0.02em; + user-select: none; +} + +.brand--xl { font-size: clamp(48px, 14vw, 132px); } +.brand--sm { font-size: 18px; gap: 2px; } + +.brand__user { + display: inline-block; + padding: 0 0.18em; + border-bottom: var(--hairline) solid transparent; + transition: color 120ms ease, border-color 120ms ease; + color: var(--fg-mute); +} + +.brand--xl .brand__user { + padding: 0.1em 0.35em; + border: var(--hairline) solid var(--line); + color: var(--fg); +} + +.brand--xl .brand__user--ray:hover, +.brand--xl .brand__user--ray:focus-visible { + color: var(--accent-ray); + border-color: var(--accent-ray); + outline: none; +} +.brand--xl .brand__user--cer:hover, +.brand--xl .brand__user--cer:focus-visible { + color: var(--accent-cer); + border-color: var(--accent-cer); + outline: none; +} + +.brand__slash { + color: var(--fg-faint); + font-weight: 200; +} + +body[data-user="ray"] .brand__user[data-brand="ray"] { color: var(--accent-ray); } +body[data-user="cer"] .brand__user[data-brand="cer"] { color: var(--accent-cer); } + +/* ---- Topbar ---- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--gap-3); + border-bottom: var(--hairline) solid var(--line); +} + +.topbar__meta { + display: flex; + align-items: center; + gap: var(--gap-3); + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg-mute); + letter-spacing: 0.06em; +} + +.countdown { + text-transform: uppercase; +} + +.iconbtn { + background: transparent; + border: var(--hairline) solid var(--line); + border-radius: var(--radius); + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-mute); + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} +.iconbtn:hover { color: var(--fg); border-color: var(--fg-mute); } + +/* ---- Stats ---- */ + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0; + border-bottom: var(--hairline) solid var(--line); +} + +.stat { + padding: var(--gap-3); + border-right: var(--hairline) solid var(--line); + display: flex; + flex-direction: column; + gap: 2px; +} +.stat:last-child { border-right: none; } + +.stat__label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--fg-mute); +} + +.stat__row { + display: flex; + align-items: baseline; + gap: var(--gap-3); + font-family: var(--font-mono); +} + +.stat__user { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-size: 13px; +} + +.stat__user-tag { + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fg-mute); +} + +.stat__user--ray .stat__user-tag { color: var(--accent-ray); } +.stat__user--cer .stat__user-tag { color: var(--accent-cer); } + +.stat__num { font-size: 18px; color: var(--fg); } +.stat__num-sub { color: var(--fg-mute); font-size: 11px; } + +/* ---- Calendar ---- */ + +.calendar-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: var(--gap-3); + gap: var(--gap-2); +} + +.cal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--gap-2); +} + +.cal-title { + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg); +} + +.cal-nav { + display: flex; + gap: var(--gap-1); +} + +.cal-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: 0; + border: var(--hairline) solid var(--line); + min-height: 0; +} + +.cal-dow { + display: contents; +} + +.cal-dow__cell { + padding: 4px 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-mute); + border-bottom: var(--hairline) solid var(--line); + border-right: var(--hairline) solid var(--line); + background: var(--bg-elev); +} +.cal-dow__cell:nth-child(7n) { border-right: none; } + +.cal-cell { + position: relative; + padding: 4px 6px; + border-right: var(--hairline) solid var(--line); + border-top: var(--hairline) solid var(--line); + background: var(--bg); + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 56px; + transition: background 120ms ease; +} +.cal-cell:nth-child(7n) { border-right: none; } +.cal-cell:hover:not(.cal-cell--off):not(.cal-cell--future) { background: var(--bg-elev); } + +.cal-cell--off { background: transparent; cursor: default; opacity: 0.35; } +.cal-cell--future { cursor: not-allowed; } +.cal-cell--out-of-range { opacity: 0.45; } + +.cal-cell__date { + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg); + letter-spacing: 0.04em; +} +.cal-cell--future .cal-cell__date, +.cal-cell--out-of-range .cal-cell__date { color: var(--fg-mute); } + +.cal-cell--today .cal-cell__date { + color: var(--accent); +} +.cal-cell--today { + box-shadow: inset 0 0 0 var(--hairline) var(--accent); +} + +.cal-cell__grid { + align-self: flex-end; + display: grid; + grid-template-columns: 10px 10px; + grid-template-rows: 10px 10px; + gap: 2px; +} + +.cal-cell__dot { + width: 10px; + height: 10px; + border: var(--hairline) solid var(--fg-faint); + background: transparent; + border-radius: 1px; +} +.cal-cell__dot--on { + background: var(--fg); + border-color: var(--fg); +} +.cal-cell__dot--ray.cal-cell__dot--on { background: var(--accent-ray); border-color: var(--accent-ray); } +.cal-cell__dot--cer.cal-cell__dot--on { background: var(--accent-cer); border-color: var(--accent-cer); } + +/* ---- Action bar ---- */ + +.actionbar { + border-top: var(--hairline) solid var(--line); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0; + background: var(--bg-elev); + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.action { + position: relative; + padding: var(--gap-3) var(--gap-3); + border-right: var(--hairline) solid var(--line); + background: transparent; + border-top: 0; + border-bottom: 0; + border-left: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--gap-1); + cursor: pointer; + text-align: left; + transition: background 120ms ease; +} +.action:last-child { border-right: none; } +.action:hover { background: var(--bg); } + +.action__label { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-mute); +} +.action__title { + font-size: 16px; + color: var(--fg); +} +.action__state { + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-mute); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.action--done { + background: + linear-gradient(0deg, color-mix(in srgb, var(--accent) 8%, transparent), color-mix(in srgb, var(--accent) 8%, transparent)); +} +.action--done .action__title { color: var(--accent); } +.action--done .action__state { color: var(--accent); } + +.action--done::before { + content: ""; + position: absolute; + inset: 0; + border-bottom: 2px solid var(--accent); + pointer-events: none; +} + +/* ---- Popover ---- */ + +.popover-root { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--bg) 60%, transparent); + display: flex; + align-items: center; + justify-content: center; + padding: var(--gap-3); + z-index: 50; +} +.popover-root[hidden] { + display: none; +} + +.popover { + background: var(--bg-elev); + border: var(--hairline) solid var(--line); + border-radius: var(--radius); + padding: var(--gap-3); + width: min(360px, 100%); + display: flex; + flex-direction: column; + gap: var(--gap-3); +} + +.popover__head { + display: flex; + align-items: baseline; + justify-content: space-between; +} +.popover__title { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg); +} +.popover__close { + background: none; + border: none; + color: var(--fg-mute); + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; +} + +.popover__list { + display: flex; + flex-direction: column; + gap: var(--gap-2); +} + +.popover__row { + display: flex; + align-items: center; + justify-content: space-between; + border: var(--hairline) solid var(--line); + padding: var(--gap-2) var(--gap-3); + cursor: pointer; + background: transparent; + text-align: left; + font: inherit; + color: inherit; +} +.popover__row:hover { border-color: var(--fg-mute); } +.popover__row--on { border-color: var(--accent); color: var(--accent); } +.popover__row__name { font-size: 14px; } +.popover__row__state { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fg-mute); +} +.popover__row--on .popover__row__state { color: var(--accent); } + +.popover__hint { + font-family: var(--font-mono); + font-size: 10px; + color: var(--fg-mute); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- Misc ---- */ + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); + white-space: nowrap; border: 0; +} + +@media (max-width: 640px) { + .stats { grid-template-columns: 1fr 1fr; } + .stat { border-bottom: var(--hairline) solid var(--line); } + .stat:nth-child(2n) { border-right: none; } + .actionbar { grid-template-columns: 1fr; } + .action { border-right: none; border-bottom: var(--hairline) solid var(--line); } + .action:last-child { border-bottom: none; } + .cal-cell { min-height: 48px; } + .cal-cell__grid { grid-template-columns: 8px 8px; grid-template-rows: 8px 8px; } + .cal-cell__dot { width: 8px; height: 8px; } +} diff --git a/frontend/public/icons/favicon.svg b/frontend/public/icons/favicon.svg new file mode 100644 index 0000000..110fb9e --- /dev/null +++ b/frontend/public/icons/favicon.svg @@ -0,0 +1,9 @@ + + + + r + / + c + + + diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..09431dd Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..f2e36b6 Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/icon-maskable-source.svg b/frontend/public/icons/icon-maskable-source.svg new file mode 100644 index 0000000..2dd7fd8 --- /dev/null +++ b/frontend/public/icons/icon-maskable-source.svg @@ -0,0 +1,8 @@ + + + + r + / + c + + diff --git a/frontend/public/icons/icon-maskable.png b/frontend/public/icons/icon-maskable.png new file mode 100644 index 0000000..218d188 Binary files /dev/null and b/frontend/public/icons/icon-maskable.png differ diff --git a/frontend/public/icons/icon-source.svg b/frontend/public/icons/icon-source.svg new file mode 100644 index 0000000..81bffc6 --- /dev/null +++ b/frontend/public/icons/icon-source.svg @@ -0,0 +1,8 @@ + + + + r + / + c + + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..1a0b88c --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,64 @@ + + + + + + + + + ray/cer + + + + + + + +
+ + + + + + + diff --git a/frontend/public/js/api.js b/frontend/public/js/api.js new file mode 100644 index 0000000..a65cf7e --- /dev/null +++ b/frontend/public/js/api.js @@ -0,0 +1,22 @@ +const BASE = '/api'; + +async function req(path, opts = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { 'content-type': 'application/json' }, + ...opts, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} ${path}: ${text}`); + } + return res.json(); +} + +export const api = { + health: () => req('/health'), + listGoals: () => req('/goals'), + listCompletionsSince: (since = 0) => + req(`/completions?since=${encodeURIComponent(since)}`), + putCompletion: (payload) => + req('/completions', { method: 'PUT', body: JSON.stringify(payload) }), +}; diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js new file mode 100644 index 0000000..5a9fd62 --- /dev/null +++ b/frontend/public/js/app.js @@ -0,0 +1,380 @@ +import { + db, + hydrateFromServer, + startSync, + setCompletion, + onChange, +} from './db.js'; +import { initTheme, cycleTheme, currentTheme } from './theme.js'; +import { renderCalendar } from './calendar.js'; +import { + todayISO, + daysRemaining, + completedSet, + currentStreak, + longestStreak, + totalCompleted, +} from './stats.js'; + +const VALID_USERS = new Set(['ray', 'cer']); +const app = document.getElementById('app'); + +const state = { + route: '/', + user: '', + goals: [], + completions: [], + view: { year: new Date().getFullYear(), month: new Date().getMonth() }, +}; + +/* ---------- Router ---------- */ + +function parseRoute(pathname) { + if (pathname === '/' || pathname === '') return { route: '/', user: '' }; + if (pathname === '/ray') return { route: '/ray', user: 'ray' }; + if (pathname === '/cer') return { route: '/cer', user: 'cer' }; + return { route: '/', user: '' }; +} + +function navigate(path, { replace = false } = {}) { + if (replace) history.replaceState({}, '', path); + else history.pushState({}, '', path); + handleRoute(); +} + +function handleRoute() { + const { route, user } = parseRoute(location.pathname); + state.route = route; + state.user = user; + document.body.dataset.route = route; + document.body.dataset.user = user || ''; + render(); +} + +window.addEventListener('popstate', handleRoute); + +document.addEventListener('click', (e) => { + const link = e.target.closest('a[data-link]'); + if (!link) return; + const href = link.getAttribute('href'); + if (!href || href.startsWith('http') || link.target === '_blank') return; + e.preventDefault(); + navigate(href); +}); + +/* ---------- Data load ---------- */ + +async function loadFromDexie() { + const [goals, completions] = await Promise.all([ + db.goals.orderBy('start_date').toArray(), + db.completions.toArray(), + ]); + state.goals = goals; + state.completions = completions; +} + +/* ---------- Templates ---------- */ + +function tpl(id) { + const t = document.getElementById(id); + return t.content.cloneNode(true); +} + +/* ---------- Render: landing ---------- */ + +function renderLanding() { + app.innerHTML = ''; + app.appendChild(tpl('tpl-landing')); +} + +/* ---------- Render: route page ---------- */ + +async function renderRoute() { + app.innerHTML = ''; + app.appendChild(tpl('tpl-route')); + + // Topbar + const themeBtn = app.querySelector('[data-theme-toggle]'); + const themeIcon = app.querySelector('[data-theme-icon]'); + themeIcon.textContent = await currentTheme(); + themeBtn.addEventListener('click', async () => { + themeIcon.textContent = await cycleTheme(); + }); + + // Countdown - use earliest end_date among active goals + const cdEl = app.querySelector('[data-countdown]'); + const today = todayISO(); + const activeGoals = state.goals.filter((g) => g.end_date >= today); + if (activeGoals.length) { + const minEnd = activeGoals + .map((g) => g.end_date) + .sort()[0]; + const remain = daysRemaining(minEnd); + cdEl.textContent = remain === 0 ? 'last day' : `${remain}d left`; + } else { + cdEl.textContent = 'window closed'; + } + + renderStats(); + renderActionbar(); + renderCalendarRoot(); +} + +/* ---------- Render: stats row ---------- */ + +function renderStats() { + const root = app.querySelector('[data-stats]'); + if (!root) return; + root.innerHTML = ''; + + const today = todayISO(); + const orderedGoals = [...state.goals].sort((a, b) => + a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) + ); + + for (const g of orderedGoals) { + const card = document.createElement('div'); + card.className = 'stat'; + + const label = document.createElement('div'); + label.className = 'stat__label'; + label.textContent = g.name; + card.appendChild(label); + + for (const user of ['ray', 'cer']) { + const set = completedSet(state.completions, user, g.id); + const cur = currentStreak(set, g, today); + const long = longestStreak(set, g, today); + const total = totalCompleted(set, g, today); + const row = document.createElement('div'); + row.className = 'stat__row'; + row.innerHTML = ` + + ${user} + ${cur} + streak / ${long} best / ${total} total + + `; + card.appendChild(row); + } + + root.appendChild(card); + } +} + +/* ---------- Render: action bar ---------- */ + +function renderActionbar() { + const root = app.querySelector('[data-actionbar]'); + if (!root) return; + root.innerHTML = ''; + const today = todayISO(); + + const orderedGoals = [...state.goals].sort((a, b) => + a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) + ); + + for (const g of orderedGoals) { + const inWindow = today >= g.start_date && today <= g.end_date; + const existing = state.completions.find( + (c) => c.user === state.user && c.goal_id === g.id && c.date === today + ); + const done = !!existing?.completed; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'action' + (done ? ' action--done' : ''); + btn.disabled = !inWindow; + btn.innerHTML = ` + ${state.user} • today + ${g.name} + ${done ? 'completed — tap to undo' : inWindow ? 'tap to mark done' : 'outside goal window'} + `; + btn.addEventListener('click', async () => { + await setCompletion(state.user, g.id, today, !done); + // optimistic UI update via subscription, but also re-render now + await loadFromDexie(); + renderStats(); + renderActionbar(); + renderCalendarRoot(); + }); + root.appendChild(btn); + } +} + +/* ---------- Render: calendar ---------- */ + +function renderCalendarRoot() { + const root = app.querySelector('[data-calendar]'); + if (!root) return; + + renderCalendar(root, { + year: state.view.year, + month: state.view.month, + goals: state.goals, + completions: state.completions, + activeUser: state.user, + onPrev: () => { + const m = state.view.month - 1; + if (m < 0) { + state.view.month = 11; + state.view.year -= 1; + } else { + state.view.month = m; + } + renderCalendarRoot(); + }, + onNext: () => { + const m = state.view.month + 1; + if (m > 11) { + state.view.month = 0; + state.view.year += 1; + } else { + state.view.month = m; + } + renderCalendarRoot(); + }, + onJumpToday: (y, m) => { + state.view.year = y; + state.view.month = m; + renderCalendarRoot(); + }, + onDayClick: ({ iso }) => openDayPopover(iso), + }); +} + +/* ---------- Popover ---------- */ + +function openDayPopover(iso) { + const root = app.querySelector('[data-popover]'); + if (!root) return; + root.hidden = false; + root.innerHTML = ''; + + const card = document.createElement('div'); + card.className = 'popover'; + + const head = document.createElement('div'); + head.className = 'popover__head'; + head.innerHTML = ` +
${iso} • ${state.user}
+ + `; + card.appendChild(head); + + const list = document.createElement('div'); + list.className = 'popover__list'; + const orderedGoals = [...state.goals].sort((a, b) => + a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) + ); + for (const g of orderedGoals) { + const inWindow = iso >= g.start_date && iso <= g.end_date; + if (!inWindow) continue; + const existing = state.completions.find( + (c) => c.user === state.user && c.goal_id === g.id && c.date === iso + ); + const done = !!existing?.completed; + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'popover__row' + (done ? ' popover__row--on' : ''); + row.innerHTML = ` + ${g.name} + ${done ? 'done • tap to undo' : 'tap to mark done'} + `; + row.addEventListener('click', async () => { + await setCompletion(state.user, g.id, iso, !done); + await loadFromDexie(); + renderStats(); + renderActionbar(); + renderCalendarRoot(); + openDayPopover(iso); // refresh popover state + }); + list.appendChild(row); + } + + if (!list.children.length) { + const empty = document.createElement('div'); + empty.className = 'popover__hint'; + empty.textContent = 'no goals active on this date'; + list.appendChild(empty); + } + + card.appendChild(list); + + const hint = document.createElement('div'); + hint.className = 'popover__hint'; + hint.textContent = 'changes only affect the active user'; + card.appendChild(hint); + + root.appendChild(card); + + const close = () => { + root.hidden = true; + root.innerHTML = ''; + }; + head.querySelector('[data-close]').addEventListener('click', close); + root.addEventListener('click', (e) => { + if (e.target === root) close(); + }); + document.addEventListener( + 'keydown', + function onKey(e) { + if (e.key === 'Escape') { + close(); + document.removeEventListener('keydown', onKey); + } + }, + { once: true } + ); +} + +/* ---------- Top-level render ---------- */ + +function render() { + if (state.route === '/' || !VALID_USERS.has(state.user)) { + renderLanding(); + } else { + renderRoute(); + } +} + +/* ---------- Boot ---------- */ + +async function boot() { + await initTheme(); + await loadFromDexie(); + handleRoute(); + + // Hydrate from server (non-blocking) and start sync + hydrateFromServer().then(async () => { + await loadFromDexie(); + if (state.route !== '/') render(); + else if (state.goals.length) { + // landing has no data dependency, but we still re-render to no-op safely + } + }); + startSync(); + + onChange(async () => { + await loadFromDexie(); + if (state.route !== '/') { + renderStats(); + renderActionbar(); + renderCalendarRoot(); + } + }); + + // Register service worker + if ('serviceWorker' in navigator) { + try { + await navigator.serviceWorker.register('/sw.js'); + } catch (e) { + console.warn('[sw] register failed:', e); + } + } +} + +boot().catch((e) => { + console.error('[boot] failed:', e); + app.innerHTML = `
boot failed: ${e?.message || e}
`; +}); diff --git a/frontend/public/js/calendar.js b/frontend/public/js/calendar.js new file mode 100644 index 0000000..6460689 --- /dev/null +++ b/frontend/public/js/calendar.js @@ -0,0 +1,173 @@ +import { addDays, isoToDate, todayISO } from './stats.js'; + +const DOW = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + +function startOfMonth(year, month) { + return new Date(year, month, 1); +} +function endOfMonth(year, month) { + return new Date(year, month + 1, 0); +} +function isoOf(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}`; +} + +/** + * Render a month grid into `root`. + * + * @param {HTMLElement} root + * @param {object} opts + * @param {number} opts.year + * @param {number} opts.month 0-indexed (Jan = 0) + * @param {Array} opts.goals all goals + * @param {Array} opts.completions flat completions array + * @param {string} opts.activeUser 'ray' | 'cer' + * @param {(args: {iso: string}) => void} opts.onDayClick + * @param {() => void} opts.onPrev + * @param {() => void} opts.onNext + */ +export function renderCalendar(root, opts) { + const { year, month, goals, completions, activeUser, onDayClick, onPrev, onNext } = opts; + const today = todayISO(); + + const goalRange = goals.reduce( + (acc, g) => ({ + min: g.start_date < acc.min ? g.start_date : acc.min, + max: g.end_date > acc.max ? g.end_date : acc.max, + }), + { min: '9999-12-31', max: '0000-01-01' } + ); + + // Index completions by date for O(1) lookup + const byDate = new Map(); + for (const c of completions) { + if (!c.completed) continue; + if (!byDate.has(c.date)) byDate.set(c.date, []); + byDate.get(c.date).push(c); + } + + const first = startOfMonth(year, month); + const last = endOfMonth(year, month); + + // Use Mon as first column. JS getDay(): 0=Sun..6=Sat → mon-index = (d+6)%7 + const leadBlanks = (first.getDay() + 6) % 7; + const totalCells = leadBlanks + last.getDate(); + const trailBlanks = (7 - (totalCells % 7)) % 7; + + const monthName = first.toLocaleString(undefined, { month: 'long' }); + + // sort goals deterministically (sugar before social) + const orderedGoals = [...goals].sort((a, b) => { + if (a.start_date !== b.start_date) return a.start_date.localeCompare(b.start_date); + return a.id.localeCompare(b.id); + }); + + root.innerHTML = ''; + + const head = document.createElement('div'); + head.className = 'cal-head'; + head.innerHTML = ` +
${monthName} ${year}
+
+ + + +
+ `; + head.querySelector('[data-prev]').addEventListener('click', onPrev); + head.querySelector('[data-next]').addEventListener('click', onNext); + head.querySelector('[data-today]').addEventListener('click', () => { + const t = new Date(); + if (opts.onJumpToday) opts.onJumpToday(t.getFullYear(), t.getMonth()); + }); + root.appendChild(head); + + const grid = document.createElement('div'); + grid.className = 'cal-grid'; + + // DOW header row + const dow = document.createElement('div'); + dow.className = 'cal-dow'; + for (const name of DOW) { + const c = document.createElement('div'); + c.className = 'cal-dow__cell'; + c.textContent = name; + dow.appendChild(c); + } + grid.appendChild(dow); + + // Leading blanks + for (let i = 0; i < leadBlanks; i++) { + const c = document.createElement('div'); + c.className = 'cal-cell cal-cell--off'; + grid.appendChild(c); + } + + // Days + for (let day = 1; day <= last.getDate(); day++) { + const d = new Date(year, month, day); + const iso = isoOf(d); + const inRange = iso >= goalRange.min && iso <= goalRange.max; + const isFuture = iso > today; + + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'cal-cell'; + if (!inRange) cell.classList.add('cal-cell--out-of-range'); + if (isFuture) cell.classList.add('cal-cell--future'); + if (iso === today) cell.classList.add('cal-cell--today'); + cell.dataset.iso = iso; + + const dateEl = document.createElement('span'); + dateEl.className = 'cal-cell__date'; + dateEl.textContent = String(day); + cell.appendChild(dateEl); + + if (inRange) { + const dotsWrap = document.createElement('div'); + dotsWrap.className = 'cal-cell__grid'; + // 4 dots: rows = goals (orderedGoals[0], orderedGoals[1]); cols = users (ray, cer) + // Order in DOM: row1col1, row1col2, row2col1, row2col2 + const todays = byDate.get(iso) || []; + const has = (user, goalId) => + todays.some((c) => c.user === user && c.goal_id === goalId && c.completed); + + const layout = [ + ['ray', orderedGoals[0]?.id], + ['cer', orderedGoals[0]?.id], + ['ray', orderedGoals[1]?.id], + ['cer', orderedGoals[1]?.id], + ]; + for (const [user, goalId] of layout) { + const dot = document.createElement('span'); + dot.className = `cal-cell__dot cal-cell__dot--${user}`; + if (goalId && has(user, goalId)) dot.classList.add('cal-cell__dot--on'); + dotsWrap.appendChild(dot); + } + cell.appendChild(dotsWrap); + } + + if (inRange && !isFuture) { + cell.addEventListener('click', () => onDayClick({ iso })); + } else { + cell.disabled = true; + } + + grid.appendChild(cell); + } + + // Trailing blanks + for (let i = 0; i < trailBlanks; i++) { + const c = document.createElement('div'); + c.className = 'cal-cell cal-cell--off'; + grid.appendChild(c); + } + + root.appendChild(grid); +} + +/* convenient util re-exports */ +export { addDays, isoToDate, todayISO }; diff --git a/frontend/public/js/db.js b/frontend/public/js/db.js new file mode 100644 index 0000000..815668d --- /dev/null +++ b/frontend/public/js/db.js @@ -0,0 +1,162 @@ +import Dexie from 'dexie'; +import { api } from './api.js'; + +export const db = new Dexie('raycer'); + +db.version(1).stores({ + goals: 'id, name, start_date, end_date', + completions: '[user+goal_id+date], updated_at, goal_id, user, date', + outbox: '++id, created_at', + meta: 'key', +}); + +const META_LAST_SYNC = 'last_sync_ts'; +const META_THEME = 'theme'; + +const listeners = new Set(); +export function onChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} +function emit(kind) { + for (const fn of listeners) { + try { fn(kind); } catch (e) { console.error(e); } + } +} + +export async function getMeta(key, fallback = null) { + const row = await db.meta.get(key); + return row ? row.value : fallback; +} +export async function setMeta(key, value) { + await db.meta.put({ key, value }); +} + +export async function getTheme() { return getMeta(META_THEME, 'auto'); } +export async function setTheme(value) { + await setMeta(META_THEME, value); + emit('theme'); +} + +/* ---------- LWW upsert from server ---------- */ + +async function applyServerCompletions(rows) { + if (!rows?.length) return; + await db.transaction('rw', db.completions, async () => { + for (const r of rows) { + const key = [r.user, r.goal_id, r.date]; + const local = await db.completions.get(key); + if (!local || (r.updated_at ?? 0) > (local.updated_at ?? 0)) { + await db.completions.put({ + user: r.user, + goal_id: r.goal_id, + date: r.date, + completed: !!r.completed, + updated_at: r.updated_at, + }); + } + } + }); + emit('completions'); +} + +/* ---------- Local writes (optimistic + outbox) ---------- */ + +export async function setCompletion(user, goal_id, date, completed) { + const updated_at = Date.now(); + const payload = { user, goal_id, date, completed: !!completed, updated_at }; + await db.transaction('rw', db.completions, db.outbox, async () => { + await db.completions.put({ user, goal_id, date, completed: !!completed, updated_at }); + await db.outbox.add({ op: 'PUT', payload, created_at: updated_at }); + }); + emit('completions'); + flushOutbox().catch((e) => console.warn('[outbox] flush failed:', e)); + return payload; +} + +export async function getCompletion(user, goal_id, date) { + return db.completions.get([user, goal_id, date]); +} + +export async function listCompletions() { + return db.completions.toArray(); +} + +export async function listGoals() { + return db.goals.orderBy('start_date').toArray(); +} + +/* ---------- Outbox flusher ---------- */ + +let flushing = false; +export async function flushOutbox() { + if (flushing) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + flushing = true; + try { + const items = await db.outbox.orderBy('id').toArray(); + for (const item of items) { + try { + await api.putCompletion(item.payload); + await db.outbox.delete(item.id); + } catch (err) { + console.warn('[outbox] item failed, will retry later:', err.message); + break; + } + } + } finally { + flushing = false; + } +} + +/* ---------- Initial hydrate + polling ---------- */ + +let pollTimer = null; + +export async function hydrateFromServer() { + try { + const goalsRes = await api.listGoals(); + if (goalsRes?.goals?.length) { + await db.transaction('rw', db.goals, async () => { + for (const g of goalsRes.goals) await db.goals.put(g); + }); + emit('goals'); + } + } catch (err) { + console.warn('[hydrate] goals offline:', err.message); + } + + try { + const since = (await getMeta(META_LAST_SYNC, 0)) || 0; + const compRes = await api.listCompletionsSince(since); + if (compRes?.completions?.length) { + await applyServerCompletions(compRes.completions); + } + if (compRes?.server_now) await setMeta(META_LAST_SYNC, compRes.server_now); + } catch (err) { + console.warn('[hydrate] completions offline:', err.message); + } +} + +export function startSync({ pollMs = 30_000, flushMs = 5_000 } = {}) { + flushOutbox().catch(() => {}); + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(async () => { + await flushOutbox().catch(() => {}); + try { + const since = (await getMeta(META_LAST_SYNC, 0)) || 0; + const res = await api.listCompletionsSince(since); + if (res?.completions?.length) await applyServerCompletions(res.completions); + if (res?.server_now) await setMeta(META_LAST_SYNC, res.server_now); + } catch { + /* offline - will retry */ + } + }, pollMs); + + setInterval(() => flushOutbox().catch(() => {}), flushMs); + + if (typeof window !== 'undefined') { + window.addEventListener('online', () => flushOutbox().catch(() => {})); + window.addEventListener('focus', () => flushOutbox().catch(() => {})); + } +} diff --git a/frontend/public/js/stats.js b/frontend/public/js/stats.js new file mode 100644 index 0000000..97bf442 --- /dev/null +++ b/frontend/public/js/stats.js @@ -0,0 +1,101 @@ +/* Pure helpers for streaks, totals, and the goal-window countdown. */ + +const DAY_MS = 86_400_000; + +export function todayISO(d = new Date()) { + 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}`; +} + +export function isoToDate(iso) { + const [y, m, d] = iso.split('-').map(Number); + return new Date(y, m - 1, d); +} + +export function addDays(iso, n) { + const d = isoToDate(iso); + d.setDate(d.getDate() + n); + return todayISO(d); +} + +export function eachDay(startISO, endISO) { + const out = []; + let cur = startISO; + while (cur <= endISO) { + out.push(cur); + cur = addDays(cur, 1); + } + return out; +} + +export function clampWindowEnd(goal, refISO = todayISO()) { + return goal.end_date < refISO ? goal.end_date : refISO; +} + +export function daysRemaining(endISO, refISO = todayISO()) { + if (endISO < refISO) return 0; + return Math.round((isoToDate(endISO) - isoToDate(refISO)) / DAY_MS); +} + +/** + * Build a Set of completed ISO dates for (user, goal) from a flat completions array. + */ +export function completedSet(completions, user, goalId) { + const set = new Set(); + for (const c of completions) { + if (c.user === user && c.goal_id === goalId && c.completed) set.add(c.date); + } + return set; +} + +/** + * Current streak ending at (or just before) `refISO`, walking back day-by-day + * while completions are present, never crossing the goal start. + */ +export function currentStreak(completedSetForUser, goal, refISO = todayISO()) { + const start = goal.start_date; + const end = clampWindowEnd(goal, refISO); + if (refISO < start) return 0; + + let cur = end; + let n = 0; + while (cur >= start && completedSetForUser.has(cur)) { + n += 1; + cur = addDays(cur, -1); + } + return n; +} + +export function longestStreak(completedSetForUser, goal, refISO = todayISO()) { + const start = goal.start_date; + const end = clampWindowEnd(goal, refISO); + if (end < start) return 0; + let best = 0; + let run = 0; + for (const d of eachDay(start, end)) { + if (completedSetForUser.has(d)) { + run += 1; + if (run > best) best = run; + } else { + run = 0; + } + } + return best; +} + +export function totalCompleted(completedSetForUser, goal, refISO = todayISO()) { + const start = goal.start_date; + const end = clampWindowEnd(goal, refISO); + if (end < start) return 0; + let n = 0; + for (const d of eachDay(start, end)) { + if (completedSetForUser.has(d)) n += 1; + } + return n; +} + +export function windowLength(goal) { + return eachDay(goal.start_date, goal.end_date).length; +} diff --git a/frontend/public/js/theme.js b/frontend/public/js/theme.js new file mode 100644 index 0000000..57c351c --- /dev/null +++ b/frontend/public/js/theme.js @@ -0,0 +1,26 @@ +import { getTheme, setTheme } from './db.js'; + +const THEMES = ['auto', 'dark', 'light']; + +function applyTheme(value) { + const root = document.documentElement; + if (value === 'auto') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', value); +} + +export async function initTheme() { + const t = await getTheme(); + applyTheme(t || 'auto'); +} + +export async function cycleTheme() { + const cur = (await getTheme()) || 'auto'; + const next = THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length]; + await setTheme(next); + applyTheme(next); + return next; +} + +export async function currentTheme() { + return (await getTheme()) || 'auto'; +} diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..eaf4778 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "raycer", + "short_name": "ray/cer", + "description": "A minimal habit-accountability app for ray and cer.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..591d64a --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,127 @@ +/* raycer service worker + * - app shell: cache-first + * - /api/*: network-first with no cache fallback (data lives in Dexie) + * - dexie CDN module: cache-first (so the app boots offline) + */ + +const VERSION = 'raycer-v1'; +const SHELL_CACHE = `${VERSION}-shell`; +const RUNTIME_CACHE = `${VERSION}-runtime`; + +const SHELL = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/css/styles.css', + '/js/app.js', + '/js/api.js', + '/js/db.js', + '/js/theme.js', + '/js/stats.js', + '/js/calendar.js', + '/icons/favicon.svg', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(SHELL_CACHE); + await cache.addAll(SHELL); + self.skipWaiting(); + })() + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => !k.startsWith(VERSION)) + .map((k) => caches.delete(k)) + ); + await self.clients.claim(); + })() + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + + const url = new URL(req.url); + + // API: network-first, never cache + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(req).catch( + () => new Response(JSON.stringify({ offline: true }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + return; + } + + // Same-origin SPA navigations: serve cached shell, fall back to network + if (req.mode === 'navigate') { + event.respondWith( + (async () => { + try { + const fresh = await fetch(req); + const cache = await caches.open(SHELL_CACHE); + cache.put('/index.html', fresh.clone()).catch(() => {}); + return fresh; + } catch { + const cached = await caches.match('/index.html'); + if (cached) return cached; + return new Response('offline', { status: 503 }); + } + })() + ); + return; + } + + // Same-origin assets: cache-first + if (url.origin === self.location.origin) { + event.respondWith( + (async () => { + const cached = await caches.match(req); + if (cached) return cached; + try { + const fresh = await fetch(req); + if (fresh.ok) { + const cache = await caches.open(RUNTIME_CACHE); + cache.put(req, fresh.clone()).catch(() => {}); + } + return fresh; + } catch { + return new Response('offline', { status: 503 }); + } + })() + ); + return; + } + + // Cross-origin (e.g. Dexie CDN): cache-first + event.respondWith( + (async () => { + const cached = await caches.match(req); + if (cached) return cached; + try { + const fresh = await fetch(req); + if (fresh.ok) { + const cache = await caches.open(RUNTIME_CACHE); + cache.put(req, fresh.clone()).catch(() => {}); + } + return fresh; + } catch { + return cached || new Response('offline', { status: 503 }); + } + })() + ); +});