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.
This commit is contained in:
Spencer Flagg 2026-04-23 16:45:06 +02:00
commit ed3a0d3ea3
33 changed files with 2205 additions and 0 deletions

12
.editorconfig Normal file
View file

@ -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

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules/
dist/
build/
.env
.env.local
*.log
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
.DS_Store
.vscode/
.idea/
data/

28
README.md Normal file
View file

@ -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

6
backend/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
data
*.sqlite*
.env
.git
README.md

30
backend/Dockerfile Normal file
View file

@ -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"]

16
backend/package.json Normal file
View file

@ -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"
}
}

33
backend/src/db.js Normal file
View file

@ -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);
`);

View file

@ -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 });
});

View file

@ -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 });
});

32
backend/src/seed.js Normal file
View file

@ -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}`);

23
backend/src/server.js Normal file
View file

@ -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}`);
});

131
deploy/COOLIFY.md Normal file
View file

@ -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 <coolify-prefixed-name> # find via: docker volume ls | grep raycer
docker run --rm -v <volume>:/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/<APP_UUID>/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=<APP_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 <APP_UUID> --context cool2026
# or
curl -X POST "https://cool2026.altweb.me/api/v1/deploy?uuid=<APP_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.

View file

@ -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:

38
docker-compose.yml Normal file
View file

@ -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:

5
frontend/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
node_modules
README.md
icons/icon-source.svg
icons/icon-maskable-source.svg

8
frontend/Dockerfile Normal file
View file

@ -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

41
frontend/nginx.conf Normal file
View file

@ -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;
}
}

View file

@ -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; }
}

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="raycer">
<rect width="64" height="64" fill="#0a0a0a"/>
<g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="300" font-size="22" letter-spacing="-1">
<text x="8" y="40" fill="#c47a1c">r</text>
<text x="22" y="40" fill="#3a3a3a">/</text>
<text x="36" y="40" fill="#1c8a8a">c</text>
</g>
<rect x="0" y="0" width="64" height="64" fill="none" stroke="#2a2a2a" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="raycer">
<rect width="512" height="512" fill="#0a0a0a"/>
<g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="300" font-size="120" letter-spacing="-3" text-anchor="middle">
<text x="184" y="290" fill="#c47a1c">r</text>
<text x="256" y="290" fill="#3a3a3a">/</text>
<text x="328" y="290" fill="#1c8a8a">c</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="raycer">
<rect width="512" height="512" fill="#0a0a0a"/>
<g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="300" font-size="180" letter-spacing="-4" text-anchor="middle">
<text x="148" y="320" fill="#c47a1c">r</text>
<text x="256" y="320" fill="#3a3a3a">/</text>
<text x="364" y="320" fill="#1c8a8a">c</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 450 B

View file

@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="dark light" />
<title>ray/cer</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="stylesheet" href="/css/styles.css" />
<script type="importmap">
{
"imports": {
"dexie": "https://cdn.jsdelivr.net/npm/dexie@4.4.2/dist/modern/dexie.mjs"
}
}
</script>
</head>
<body data-route="/" data-user="">
<div id="app" aria-live="polite"></div>
<template id="tpl-landing">
<main class="landing">
<div class="brand brand--xl" role="group" aria-label="Choose user">
<a href="/ray" class="brand__user brand__user--ray" data-link>ray</a>
<span class="brand__slash" aria-hidden="true">/</span>
<a href="/cer" class="brand__user brand__user--cer" data-link>cer</a>
</div>
<p class="landing__sub">choose to continue</p>
</main>
</template>
<template id="tpl-route">
<header class="topbar">
<a href="/" class="brand brand--sm" data-link aria-label="raycer home">
<span class="brand__user brand__user--ray" data-brand="ray">ray</span><span
class="brand__slash"
>/</span
><span class="brand__user brand__user--cer" data-brand="cer">cer</span>
</a>
<div class="topbar__meta">
<span class="countdown" data-countdown></span>
<button class="iconbtn" data-theme-toggle title="Toggle dark/light" aria-label="Toggle theme">
<span data-theme-icon>auto</span>
</button>
</div>
</header>
<section class="stats" data-stats></section>
<main class="calendar-wrap">
<div class="calendar" data-calendar></div>
</main>
<footer class="actionbar" data-actionbar></footer>
<div class="popover-root" data-popover hidden></div>
</template>
<script type="module" src="/js/app.js"></script>
</body>
</html>

22
frontend/public/js/api.js Normal file
View file

@ -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) }),
};

380
frontend/public/js/app.js Normal file
View file

@ -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 = `
<span class="stat__user stat__user--${user}">
<span class="stat__user-tag">${user}</span>
<span class="stat__num">${cur}</span>
<span class="stat__num-sub">streak / ${long} best / ${total} total</span>
</span>
`;
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 = `
<span class="action__label">${state.user} today</span>
<span class="action__title">${g.name}</span>
<span class="action__state">${done ? 'completed — tap to undo' : inWindow ? 'tap to mark done' : 'outside goal window'}</span>
`;
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 = `
<div class="popover__title">${iso} ${state.user}</div>
<button class="popover__close" data-close aria-label="Close">close</button>
`;
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 = `
<span class="popover__row__name">${g.name}</span>
<span class="popover__row__state">${done ? 'done • tap to undo' : 'tap to mark done'}</span>
`;
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 = `<pre style="padding:24px;color:var(--fg)">boot failed: ${e?.message || e}</pre>`;
});

View file

@ -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 = `
<div class="cal-title">${monthName} ${year}</div>
<div class="cal-nav">
<button class="iconbtn" data-prev aria-label="Previous month">&lt;</button>
<button class="iconbtn" data-today aria-label="Today">today</button>
<button class="iconbtn" data-next aria-label="Next month">&gt;</button>
</div>
`;
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 };

162
frontend/public/js/db.js Normal file
View file

@ -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(() => {}));
}
}

101
frontend/public/js/stats.js Normal file
View file

@ -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;
}

View file

@ -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';
}

View file

@ -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" }
]
}

127
frontend/public/sw.js Normal file
View file

@ -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 });
}
})()
);
});