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:
commit
ed3a0d3ea3
33 changed files with 2205 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
14
.gitignore
vendored
Normal 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
28
README.md
Normal 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
6
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
data
|
||||
*.sqlite*
|
||||
.env
|
||||
.git
|
||||
README.md
|
||||
30
backend/Dockerfile
Normal file
30
backend/Dockerfile
Normal 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
16
backend/package.json
Normal 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
33
backend/src/db.js
Normal 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);
|
||||
`);
|
||||
94
backend/src/routes/completions.js
Normal file
94
backend/src/routes/completions.js
Normal 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 });
|
||||
});
|
||||
15
backend/src/routes/goals.js
Normal file
15
backend/src/routes/goals.js
Normal 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
32
backend/src/seed.js
Normal 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
23
backend/src/server.js
Normal 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
131
deploy/COOLIFY.md
Normal 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.
|
||||
46
docker-compose.coolify.yaml
Normal file
46
docker-compose.coolify.yaml
Normal 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
38
docker-compose.yml
Normal 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
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.git
|
||||
node_modules
|
||||
README.md
|
||||
icons/icon-source.svg
|
||||
icons/icon-maskable-source.svg
|
||||
8
frontend/Dockerfile
Normal file
8
frontend/Dockerfile
Normal 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
41
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
537
frontend/public/css/styles.css
Normal file
537
frontend/public/css/styles.css
Normal 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; }
|
||||
}
|
||||
9
frontend/public/icons/favicon.svg
Normal file
9
frontend/public/icons/favicon.svg
Normal 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 |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
8
frontend/public/icons/icon-maskable-source.svg
Normal file
8
frontend/public/icons/icon-maskable-source.svg
Normal 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 |
BIN
frontend/public/icons/icon-maskable.png
Normal file
BIN
frontend/public/icons/icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
8
frontend/public/icons/icon-source.svg
Normal file
8
frontend/public/icons/icon-source.svg
Normal 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 |
64
frontend/public/index.html
Normal file
64
frontend/public/index.html
Normal 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
22
frontend/public/js/api.js
Normal 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
380
frontend/public/js/app.js
Normal 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>`;
|
||||
});
|
||||
173
frontend/public/js/calendar.js
Normal file
173
frontend/public/js/calendar.js
Normal 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"><</button>
|
||||
<button class="iconbtn" data-today aria-label="Today">today</button>
|
||||
<button class="iconbtn" data-next aria-label="Next month">></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
162
frontend/public/js/db.js
Normal 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
101
frontend/public/js/stats.js
Normal 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;
|
||||
}
|
||||
26
frontend/public/js/theme.js
Normal file
26
frontend/public/js/theme.js
Normal 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';
|
||||
}
|
||||
16
frontend/public/manifest.webmanifest
Normal file
16
frontend/public/manifest.webmanifest
Normal 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
127
frontend/public/sw.js
Normal 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 });
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue