Author SHA1 Message Date
snowspeeder 5a780cefa8 Convert every remaining °C reference to the temp-unit toggle
The Machine Plan chart's axis ticks and first-crack band caption, the
print worksheet's axis title, and its "Your logged band" reference
column were all still hardcoded in °C. They're now data-driven
(data-tempc / data-band-lo / data-band-hi) and update alongside every
other temperature field when the unit is switched.
2026-07-30 16:57:30 -04:00
snowspeeder 306d256832 Add a °C/°F display toggle for Machine Plan and Roast Log temps
Plans are always stored in canonical °C; the unit is a client-side
display preference (localStorage) that converts Machine Plan planned
temps, Roast Log actual temps, and actuator expected BT on the fly,
including band-marker positioning. Toggle lives in Settings & sync.
2026-07-30 16:51:01 -04:00
snowspeeder 5ef82748ea Merge branch 'feature/commercial-redesign'
Commercial redesign: landing page, session-cookie auth (signup/login/
forgot/reset), side-nav app shell, full account and admin pages, and
a teaching-layer UI overhaul. Adds green-bean inventory management
and SCA-style cupping scoring ported from hope_roaster.
2026-07-30 15:41:33 -04:00
snowspeeder 59645e59b5 Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from
hope_roaster: lot tracking with audit-logged consumption, cupping
sessions with server-authoritative scoring and a live radar chart,
and links from the planner (draw-from-lot, open-cupping-session).

Also fixes the Blend/Single-origin toggle layout, replaces tooltips
with an in-context "why" teaching layer, and stages the planner UI
into pre-roast vs. post-roast phases.
2026-07-30 14:47:15 -04:00
snowspeeder 9f0a3dbc74 Fallback to Browserless for rate-limited prefills 2026-07-30 08:16:22 -04:00
snowspeeder f3d026a109 Fix planner review findings 2026-07-30 08:11:08 -04:00
snowspeeder 7416898bec Elevate planner UX and offline resilience 2026-07-30 07:03:35 -04:00
snowspeeder 057d68156b Polish planner styling 2026-07-30 06:37:41 -04:00
snowspeeder 99d7e8f121 fix: make account menu actions reliable 2026-07-29 23:29:01 -04:00
snowspeeder 8f82000e31 feat: replace account actions with menu 2026-07-29 23:25:49 -04:00
snowspeeder 089ee6916f fix: refresh installed clients and hide admin control by role 2026-07-29 23:08:32 -04:00
snowspeeder 122427d30d fix: redirect authenticated users to planner 2026-07-29 22:56:06 -04:00
snowspeeder f56dc8efa9 feat: allow initial admin setup from landing page 2026-07-29 22:12:37 -04:00
snowspeeder 22e6e721a7 chore: format auth code and update runtime 2026-07-29 22:11:08 -04:00
snowspeeder 892479dceb feat: harden authenticated deployment 2026-07-29 22:04:40 -04:00
snowspeeder 432dd2176f feat: add secure auth, admin and postgres persistence 2026-07-29 21:52:22 -04:00
55 changed files with 13631 additions and 1817 deletions
+1
View File
@@ -5,3 +5,4 @@ node_modules
npm-debug.log* npm-debug.log*
Dockerfile Dockerfile
README.md README.md
appdata/
+8
View File
@@ -0,0 +1,8 @@
# Generate each with: openssl rand -base64 48
POSTGRES_PASSWORD=
# One-use secret to create [email protected]; leave blank/remove after bootstrap.
BOOTSTRAP_SETUP_TOKEN=
# Optional host path mounted read-only at /home/node/.pi/agent (never commit it).
PI_AGENT_CONFIG_DIR=./appdata/pi-agent
# Optional explicit reverse-proxy IP/CIDR; leave blank if no trusted proxy is present.
TRUST_PROXY=
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules
appdata//
data/ data/
*.log *.log
.DS_Store .DS_Store
dev-harness.mjs
+7
View File
@@ -0,0 +1,7 @@
# Development and tests
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
+4
View File
@@ -9,7 +9,11 @@ RUN npm ci --omit=dev
COPY public ./public COPY public ./public
COPY server ./server COPY server ./server
COPY shared ./shared COPY shared ./shared
COPY db/migrations ./db/migrations
# The optional Pi agent configuration is mounted read-only here at runtime.
ENV HOME=/home/node
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
USER node USER node
EXPOSE 8090 EXPOSE 8090
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]
+25 -3
View File
@@ -21,10 +21,33 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
## Run it ## Run it
```bash ```bash
cp .env.example .env # set strong secrets
npm install npm install
npm start # http://localhost:8090 (set PORT to override) npm test
# local PostgreSQL stack (database is not published to the host)
docker compose --env-file .env up --build
``` ```
The public landing page is at `/`; plans require an account at `/app`. Production is configured for `https://roast.srmr.xyz`: retain `APP_ORIGIN=https://roast.srmr.xyz` and `COOKIE_SECURE=true` behind its HTTPS proxy. Database migrations in `db/migrations/` run at application startup exactly once.
### First administrator
Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in the deployment environment, then call `POST /api/auth/bootstrap` with that token, `[email protected]`, and a 12+ character password. The endpoint can create that account only once. Remove the setup token after success; it is optional thereafter and no administrator password is stored in source control.
### Pi agent configuration in Docker
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/home/node/.pi/agent`, the non-root Node user's Pi configuration directory. This lets `/api/prefill` use the same configured model at runtime without baking credentials into the image. The directory is ignored by Git and Docker build context; do not commit its contents.
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
```bash
mkdir -p appdata/pi-agent
rsync -a --delete ~/.pi/agent/ appdata/pi-agent/
docker compose --env-file .env up --build
```
Set `PI_AGENT_CONFIG_DIR` to another protected host directory instead if preferred. Restrict access to that directory because it can contain provider credentials. The mount is read-only, so Pi cannot alter the host configuration. If deployed behind a reverse proxy, set `TRUST_PROXY` only to that proxy's specific IP/CIDR (or keep it blank when the app is directly exposed).
## Mobile and PWA use ## Mobile and PWA use
The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone. The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone.
@@ -52,8 +75,7 @@ If those tables change in the paper worksheet, port the change here too.
## Known gaps (v1) ## Known gaps (v1)
- No automated test suite yet (the ledger math and `.alog` parser were verified manually - Offline drafts are intentionally scoped to the authenticated browser account and are cleared on logout; account-backed plans remain the authoritative copy.
against the worksheet's worked examples and all 14 logs in `ref/roasts/`, respectively).
- Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed, - Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed,
Cloudflare-clearing browser and the operator's own credentials. Use the `.alog` file picker, Cloudflare-clearing browser and the operator's own credentials. Use the `.alog` file picker,
or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files. or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files.
+7
View File
@@ -0,0 +1,7 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, password_hash text NOT NULL, role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE sessions (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash text NOT NULL, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE roast_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, plan jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE app_settings (key text PRIMARY KEY, value text NOT NULL);
INSERT INTO app_settings(key,value) VALUES ('signup_enabled','true');
CREATE INDEX roast_plans_user_updated ON roast_plans(user_id, updated_at DESC);
+8
View File
@@ -0,0 +1,8 @@
-- Additive only: existing users/sessions/roast_plans rows and columns are untouched.
CREATE TABLE password_reset_tokens (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
ALTER TABLE users ADD COLUMN disabled_at timestamptz;
ALTER TABLE sessions ADD COLUMN user_agent text, ADD COLUMN ip text, ADD COLUMN last_seen_at timestamptz;
CREATE TABLE audit_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, action text NOT NULL, target text, created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX audit_events_created ON audit_events(created_at DESC);
CREATE INDEX sessions_user_id ON sessions(user_id);
CREATE INDEX password_reset_tokens_user_id ON password_reset_tokens(user_id);
+45
View File
@@ -0,0 +1,45 @@
-- Additive only: existing tables, rows, and columns are untouched.
CREATE TABLE green_bean_lots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
origin text NOT NULL,
variety text NOT NULL DEFAULT '',
process text NOT NULL DEFAULT '',
producer text NOT NULL DEFAULT '',
purchase_date date,
initial_weight_g numeric NOT NULL,
remaining_weight_g numeric NOT NULL,
cost_total numeric,
moisture_pct numeric,
density_g_l numeric,
notes text NOT NULL DEFAULT '',
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE bean_consumption (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
weight_g numeric NOT NULL CHECK (weight_g > 0),
created_at timestamptz NOT NULL DEFAULT now()
);
-- One draw-down per roast plan, ever (NULLs are distinct in Postgres, so plan-less manual
-- entries remain unlimited). This is the server-side backstop that makes the client's
-- "Draw from lot" button idempotent even if clicked twice or from two devices.
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
data jsonb NOT NULL,
total_score numeric NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX green_bean_lots_user ON green_bean_lots(user_id, archived, purchase_date DESC);
CREATE INDEX bean_consumption_lot ON bean_consumption(lot_id, created_at DESC);
CREATE INDEX bean_consumption_user ON bean_consumption(user_id);
CREATE INDEX cupping_sessions_user ON cupping_sessions(user_id, updated_at DESC);
CREATE INDEX cupping_sessions_plan ON cupping_sessions(roast_plan_id);
+40
View File
@@ -0,0 +1,40 @@
services:
app:
build: .
environment:
NODE_ENV: production
DATABASE_URL: postgresql://roast:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/roast
APP_ORIGIN: https://roast.srmr.xyz
COOKIE_SECURE: "true"
# Optional after the one-time administrator bootstrap has completed.
BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-}
# Leave unset unless a known reverse-proxy address/CIDR is configured.
TRUST_PROXY: ${TRUST_PROXY:-}
# Browser-rendered fallback for storefronts that rate-limit server fetches.
BROWSERLESS_URL: ${BROWSERLESS_URL:-http://host.docker.internal:9085}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
depends_on:
db:
condition: service_healthy
# Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
ports: ["8090:8090"]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: roast
POSTGRES_USER: roast
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes: [postgres-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roast -d roast"]
interval: 5s
timeout: 3s
retries: 12
restart: unless-stopped
volumes:
postgres-data:
+750 -21
View File
@@ -8,20 +8,27 @@
"name": "roast-planner-webapp", "name": "roast-planner-webapp",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.82.1", "@earendil-works/pi-coding-agent": "^0.83.0",
"express": "^5.0.1" "bcryptjs": "^3.0.3",
"express": "^5.0.1",
"nodemailer": "^9.0.3",
"pg": "^8.22.0"
},
"devDependencies": {
"pg-mem": "^3.0.14",
"supertest": "^7.2.2"
} }
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz",
"integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==", "integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==",
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.82.1", "@earendil-works/pi-agent-core": "^0.83.0",
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"@earendil-works/pi-tui": "^0.82.1", "@earendil-works/pi-tui": "^0.83.0",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -34,7 +41,7 @@
"minimatch": "10.2.5", "minimatch": "10.2.5",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"semver": "7.8.0", "semver": "7.8.0",
"typebox": "1.1.38", "typebox": "1.3.7",
"undici": "8.5.0", "undici": "8.5.0",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
@@ -484,14 +491,14 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.3.7",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
"engines": { "engines": {
@@ -499,8 +506,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -513,7 +520,7 @@
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"openai": "6.26.0", "openai": "6.26.0",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"typebox": "1.1.38" "typebox": "1.3.7"
}, },
"bin": { "bin": {
"pi-ai": "dist/cli.js" "pi-ai": "dist/cli.js"
@@ -523,8 +530,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
@@ -1718,9 +1725,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": {
"version": "1.1.38", "version": "1.3.7",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
@@ -1831,6 +1838,29 @@
"zod": "^3.25.28 || ^4" "zod": "^3.25.28 || ^4"
} }
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1844,6 +1874,29 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1890,6 +1943,25 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1919,6 +1991,36 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1959,6 +2061,13 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1976,6 +2085,34 @@
} }
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1985,6 +2122,24 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2044,6 +2199,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2102,6 +2273,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2123,6 +2301,64 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2150,6 +2386,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"dev": true,
"license": "MIT"
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2199,6 +2442,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2211,6 +2467,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2259,6 +2531,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/immutable": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
"integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2280,6 +2559,56 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2314,6 +2643,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2339,12 +2691,52 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moo": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^2.19.0",
"moo": "^0.5.0",
"railroad-diagrams": "^1.0.0",
"randexp": "0.4.6"
},
"bin": {
"nearley-railroad": "bin/nearley-railroad.js",
"nearley-test": "bin/nearley-test.js",
"nearley-unparse": "bin/nearley-unparse.js",
"nearleyc": "bin/nearleyc.js"
},
"funding": {
"type": "individual",
"url": "https://nearley.js.org/#give-to-nearley"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -2354,6 +2746,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2366,6 +2777,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2406,6 +2827,204 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-mem": {
"version": "3.0.14",
"resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz",
"integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"functional-red-black-tree": "^1.0.1",
"immutable": "^4.3.4",
"json-stable-stringify": "^1.0.1",
"lru-cache": "^6.0.0",
"moment": "^2.27.0",
"object-hash": "^2.0.3",
"pgsql-ast-parser": "^12.0.2"
},
"peerDependencies": {
"@mikro-orm/core": ">=4.5.3",
"@mikro-orm/postgresql": ">=4.5.3",
"knex": ">=0.20",
"kysely": ">=0.26",
"pg-promise": ">=10.8.7",
"pg-server": "^0.1.5",
"postgres": "^3.4.4",
"slonik": ">=23.0.1",
"typeorm": ">=0.2.29"
},
"peerDependenciesMeta": {
"@mikro-orm/core": {
"optional": true
},
"@mikro-orm/postgresql": {
"optional": true
},
"knex": {
"optional": true
},
"kysely": {
"optional": true
},
"mikro-orm": {
"optional": true
},
"pg-promise": {
"optional": true
},
"pg-server": {
"optional": true
},
"postgres": {
"optional": true
},
"slonik": {
"optional": true
},
"typeorm": {
"optional": true
}
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pgsql-ast-parser": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz",
"integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"moo": "^0.5.1",
"nearley": "^2.19.5"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -2435,6 +3054,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"discontinuous-range": "1.0.0",
"ret": "~0.1.10"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -2463,6 +3103,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12"
}
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2530,6 +3180,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2608,6 +3276,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2617,6 +3294,42 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2680,6 +3393,22 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
} }
} }
} }
+10 -2
View File
@@ -6,10 +6,18 @@
"description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.", "description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.",
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch server/index.js" "dev": "node --watch server/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.83.0",
"bcryptjs": "^3.0.3",
"express": "^5.0.1", "express": "^5.0.1",
"@earendil-works/pi-coding-agent": "^0.82.1" "nodemailer": "^9.0.3",
"pg": "^8.22.0"
},
"devDependencies": {
"pg-mem": "^3.0.14",
"supertest": "^7.2.2"
} }
} }
+259
View File
@@ -0,0 +1,259 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Account — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Account</h1>
<p class="brand-sub">Profile, security, plans, and devices</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="profile">
<div class="panel-head">
<h2>Profile</h2>
</div>
<div class="panel-body">
<p>
<strong id="profile-email"></strong>
<span class="badge badge-admin hidden" id="profile-role-admin"
>Admin</span
>
</p>
<p class="muted" id="profile-since"></p>
<p class="subhead">Change email</p>
<form id="email-form" class="inline-form">
<label class="field"
><span class="field-label">New email</span
><input
class="field-input"
type="email"
name="email"
required
autocomplete="email"
/></label>
<label class="field"
><span class="field-label">Current password</span
><input
class="field-input"
type="password"
name="password"
required
autocomplete="current-password"
/></label>
<button class="primary-btn" type="submit">Update email</button>
</form>
</div>
</section>
<section class="panel-card" id="security">
<div class="panel-head">
<h2>Security</h2>
</div>
<div class="panel-body">
<p class="subhead">Change password</p>
<form id="password-form" class="inline-form">
<label class="field"
><span class="field-label">Current password</span
><input
class="field-input"
type="password"
name="currentPassword"
required
autocomplete="current-password"
/></label>
<label class="field"
><span class="field-label">New password</span
><input
class="field-input"
type="password"
name="newPassword"
required
minlength="12"
autocomplete="new-password"
/></label>
<label class="field"
><span class="field-label">Confirm new password</span
><input
class="field-input"
type="password"
name="confirmPassword"
required
minlength="12"
autocomplete="new-password"
/></label>
<button class="primary-btn" type="submit">
Update password
</button>
</form>
<p class="subhead">Active sessions</p>
<div class="table-wrap">
<table class="data-table" id="sessions-table">
<thead>
<tr>
<th>Device</th>
<th>Last seen</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody id="sessions-body"></tbody>
</table>
</div>
<button class="ghost-btn" type="button" id="btn-revoke-others">
Sign out everywhere else
</button>
</div>
</section>
<section class="panel-card" id="your-plans">
<div class="panel-head">
<h2>Your plans</h2>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="plans-table">
<thead>
<tr>
<th>Title</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="plans-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="app">
<div class="panel-head">
<h2>App</h2>
</div>
<div class="panel-body">
<p class="muted">
Install Roast Planner for a focused, offline-capable
workspace. Updates wait for your confirmation so an
in-progress plan is never discarded.
</p>
<button id="btn-install" type="button" class="primary-btn hidden">
Install Roast Planner
</button>
<button id="btn-refresh" type="button" class="ghost-btn hidden">
Update ready — refresh safely
</button>
</div>
</section>
<section class="panel-card danger-zone" id="danger">
<div class="panel-head">
<h2>Danger zone</h2>
</div>
<div class="panel-body">
<p class="muted">
Deleting your account permanently removes your plans and
cannot be undone.
</p>
<form id="delete-form" class="inline-form">
<label class="field"
><span class="field-label"
>Type your email to confirm</span
><input class="field-input" type="email" name="confirmEmail" required
/></label>
<label class="field"
><span class="field-label">Password</span
><input
class="field-input"
type="password"
name="password"
required
autocomplete="current-password"
/></label>
<button class="ghost-btn" type="submit" style="border-color:var(--fail);color:var(--fail)">
Delete my account
</button>
</form>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/account.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Admin — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="#plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item" href="/admin" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Administration</h1>
<p class="brand-sub">Users, plans, and app settings</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content wide">
<section class="stat-grid" id="metrics" aria-label="Metrics"></section>
<section class="panel-card" id="reset-links-card" hidden>
<div class="panel-head"><h2>Pending password resets</h2></div>
<div class="panel-body">
<p class="muted">
No outgoing email is configured for this deployment
(<code>SMTP_URL</code>). Hand these links to the user
directly.
</p>
<div class="table-wrap">
<table class="data-table" id="resets-table">
<thead>
<tr>
<th>Email</th>
<th>Link</th>
<th>Expires</th>
</tr>
</thead>
<tbody id="resets-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card">
<div class="panel-head"><h2>Signups</h2></div>
<div class="panel-body">
<label
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
><span class="switch"
><input type="checkbox" id="signup-toggle" /><span
class="switch-track"
></span></span
>Allow new signups</label
>
</div>
</section>
<section class="panel-card" id="users">
<div class="panel-head">
<h2>Users</h2>
<input
class="text-input"
id="user-search"
placeholder="Search by email…"
style="max-width:220px"
/>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="users-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th class="num">Plans</th>
<th>Joined</th>
<th></th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="plans">
<div class="panel-head">
<h2>Roast plans</h2>
<button class="ghost-btn small hidden" id="clear-plan-filter">
Clear filter
</button>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="plans-table">
<thead>
<tr>
<th>Owner</th>
<th>Title</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="plans-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="activity">
<div class="panel-head"><h2>Recent activity</h2></div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="audit-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody id="audit-body"></tbody>
</table>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/admin.js"></script>
</body>
</html>
+2502 -368
View File
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Cupping — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="/account#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/inventory"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1 id="cupping-title">Cupping</h1>
<p class="brand-sub" id="cupping-subtitle">
Tasting and scoring sessions
</p>
</div>
</div>
</div>
<div class="header-actions hidden" id="session-autosave-wrap">
<span class="autosave-chip" id="cupping-autosave-status"
><span class="dot"></span
><span class="autosave-text">Not saved yet</span></span
>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content wide" id="cupping-list-view">
<section class="panel-card" id="cupping-sessions-list">
<div class="panel-head"><h2>Cupping sessions</h2></div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="sessions-table">
<thead>
<tr>
<th>Coffee</th>
<th class="num">Score</th>
<th class="num">Cups</th>
<th>Flavors</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="sessions-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="new-session-card">
<div class="panel-head"><h2>New session</h2></div>
<div class="panel-body">
<form id="new-session-form" class="inline-form">
<label class="field"
><span class="field-label">Roast (optional)</span>
<select class="field-input" id="new-session-plan">
<option value="">— no linked roast —</option>
</select></label
>
<label class="field"
><span class="field-label">Cups</span
><input
class="field-input"
type="number"
id="new-session-cups"
min="1"
max="12"
value="5"
/></label>
<button class="primary-btn" type="submit">
Start cupping
</button>
</form>
</div>
</section>
</main>
<main class="page-content wide hidden" id="cupping-session-view">
<div class="workspace-grid">
<div class="form-col">
<section class="panel-card" id="cup-scores">
<div class="panel-head">
<h2>Scores</h2>
<label class="field" style="margin:0">
<span class="field-label">Cups</span>
<input
class="field-input sm"
type="number"
id="cup-count"
min="1"
max="12"
/>
</label>
</div>
<div class="panel-body">
<details class="why-panel" data-why="cup-scores" open>
<summary>Why the scale starts at 6</summary>
<p>
The 610 range is a specialty-grading convention:
anything below specialty grade simply isn't scored, so
6 is the floor of the scale, not "zero". Score in
quarter-point steps and leave an attribute unscored
(—) rather than guessing — an unscored attribute
contributes nothing, which is honest; a guessed 7.5
pollutes every comparison you make later. The tick
rows count cups, not quality: five clean cups out of
five is full marks even if the coffee is merely good.
</p>
</details>
<div id="cup-score-rows"></div>
<div id="cup-tick-rows"></div>
<details class="cupping-defects">
<summary>Defects</summary>
<div id="cup-defect-rows"></div>
</details>
</div>
</section>
<section class="panel-card" id="cup-flavors">
<div class="panel-head"><h2>Flavors</h2></div>
<div class="panel-body">
<details class="why-panel" data-why="cup-flavors" open>
<summary>Tag what you can point at</summary>
<p>
A tag is only useful if you could defend it to
another taster with the cup in front of you. Two or
three confident descriptors beat a dozen hopeful ones
— the list is capped at 32, but a session that needs
10 is already suspect.
</p>
</details>
<p class="field-note hidden" id="flavor-limit-note">
32-tag limit reached.
</p>
<div id="flavor-families"></div>
</div>
</section>
<section class="panel-card" id="cup-notes">
<div class="panel-head"><h2>Notes</h2></div>
<div class="panel-body">
<details class="why-panel" data-why="cup-notes" open>
<summary>Notes outlive scores</summary>
<p>
Numbers compare roasts; sentences explain them. Write
what you'd want to read before roasting this coffee
again — texture, where it fell apart as it cooled,
what you'd change.
</p>
</details>
<textarea
class="field-input"
id="cup-notes-text"
maxlength="4000"
rows="5"
aria-label="Cupping notes"
></textarea>
<p class="field-note"><span id="cup-notes-count">0</span>/4000</p>
</div>
</section>
</div>
<aside class="dock">
<div class="dock-card">
<div class="dock-card-head"><h2>Score</h2></div>
<div class="stat-tile" style="box-shadow:none;border:none;padding:0">
<div class="stat-tile-label">Total score</div>
<div class="stat-tile-value" id="cup-total">0.00</div>
</div>
<p class="dock-note">Recomputed by the server on every save.</p>
<div class="chip-group" id="flavor-chips"></div>
</div>
<div class="dock-card">
<div class="dock-card-head"><h2>Radar</h2></div>
<svg
id="cup-radar"
viewBox="0 0 240 240"
role="img"
aria-label="Cupping score radar chart"
></svg>
</div>
</aside>
</div>
</main>
</div>
</div>
<script>
// Runs synchronously at parse time (before the deferred module script below) so opening
// /cupping?session=… never flashes the empty sessions list first while cupping.js's own
// async init (which awaits the auth check) is still getting underway.
if (new URLSearchParams(location.search).get("session")) {
document.getElementById("cupping-list-view").classList.add("hidden");
document.getElementById("cupping-session-view").classList.remove("hidden");
}
</script>
<script type="module" src="/js/cupping.js"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Reset your password — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong>Roast Planner</strong>
</div>
<h1>Forgot your password?</h1>
<p class="lede">
Enter your account email and we'll send a link to reset your
password.
</p>
<div id="message" class="auth-message" role="alert"></div>
<form id="forgot-form">
<label
>Email
<input
class="field-input"
required
type="email"
name="email"
autocomplete="email"
/></label>
<button class="primary-btn" type="submit">Send reset link</button>
</form>
<div class="auth-links">
<a href="/login">Back to log in</a>
</div>
</section>
</main>
<script type="module" src="/js/forgot.js"></script>
</body>
</html>
+2147 -415
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inventory — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div class="nav-group">
<a class="nav-item" href="/app"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Planner</span></a
>
<a class="nav-item" href="/account#your-plans"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Plans</span></a
>
<a class="nav-item" href="/inventory" aria-current="page"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Inventory</span></a
>
<a class="nav-item" href="/cupping"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Cupping</span></a
>
<a class="nav-item" href="/account"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Account</span></a
>
<a class="nav-item hidden" id="nav-admin" href="/admin"
><span class="nav-icon" aria-hidden="true"></span
><span class="nav-label">Admin</span></a
>
</div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Inventory</h1>
<p class="brand-sub">Green bean lots and remaining stock</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="lots">
<div class="panel-head">
<h2>Green bean lots</h2>
<label
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
><span class="switch"
><input type="checkbox" id="show-archived" /><span
class="switch-track"
></span></span
>Show archived</label
>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="lots-table">
<thead>
<tr>
<th>Lot</th>
<th>Process</th>
<th>Remaining</th>
<th>Purchased</th>
<th></th>
</tr>
</thead>
<tbody id="lots-body"></tbody>
</table>
</div>
</div>
</section>
<section class="panel-card" id="lot-form-card">
<div class="panel-head"><h2 id="lot-form-title">Add a lot</h2></div>
<div class="panel-body">
<form id="lot-form">
<input type="hidden" name="id" />
<div class="field-grid">
<label class="field"
><span class="field-label">Origin *</span
><input
class="field-input"
name="origin"
required
placeholder="e.g. Huila, Colombia"
/></label>
<label class="field"
><span class="field-label">Variety</span
><input class="field-input" name="variety"
/></label>
<label class="field"
><span class="field-label">Process</span
><input class="field-input" name="process"
/></label>
<label class="field"
><span class="field-label">Producer</span
><input class="field-input" name="producer"
/></label>
<label class="field"
><span class="field-label">Purchase date</span
><input class="field-input" type="date" name="purchaseDate"
/></label>
<label class="field"
><span class="field-label">Initial weight *</span>
<div class="unit-input">
<input
class="field-input"
type="number"
name="initialWeightG"
inputmode="decimal"
min="0"
step="any"
required
/><span class="unit">g</span>
</div></label
>
<label class="field"
><span class="field-label">Cost</span
><input
class="field-input"
type="number"
name="costTotal"
inputmode="decimal"
min="0"
step="any"
/></label>
<label class="field"
><span class="field-label">Moisture</span>
<div class="unit-input">
<input
class="field-input"
type="number"
name="moisturePct"
inputmode="decimal"
min="0"
step="any"
/><span class="unit">%</span>
</div></label
>
<label class="field"
><span class="field-label">Density</span>
<div class="unit-input">
<input
class="field-input"
type="number"
name="densityGL"
inputmode="decimal"
min="0"
step="any"
/><span class="unit">g/L</span>
</div></label
>
</div>
<label class="field"
><span class="field-label">Notes</span
><input class="field-input" name="notes"
/></label>
<p class="field-note hidden" id="lot-remaining-note"></p>
<button class="primary-btn" type="submit" id="lot-form-submit">
Add lot
</button>
<button
class="ghost-btn hidden"
type="button"
id="lot-form-cancel"
>
Cancel edit
</button>
</form>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/inventory.js"></script>
</body>
</html>
+351
View File
@@ -0,0 +1,351 @@
import { api, protectedFetch } from "./api.js";
import { initSideNav, loadNavUser } from "./nav.js";
import { showToast } from "./toast.js";
function fmtDate(value) {
return value ? new Date(value).toLocaleString() : "—";
}
function friendlyDevice(userAgent) {
if (!userAgent) return "Unknown device";
const browser = /Edg\//.test(userAgent)
? "Edge"
: /OPR\//.test(userAgent)
? "Opera"
: /Chrome\//.test(userAgent)
? "Chrome"
: /CriOS\//.test(userAgent)
? "Chrome"
: /Firefox\//.test(userAgent)
? "Firefox"
: /Safari\//.test(userAgent)
? "Safari"
: "Browser";
const os = /Windows/.test(userAgent)
? "Windows"
: /iPhone|iPad/.test(userAgent)
? "iOS"
: /Mac OS X/.test(userAgent)
? "macOS"
: /Android/.test(userAgent)
? "Android"
: /Linux/.test(userAgent)
? "Linux"
: "";
return os ? `${browser} on ${os}` : browser;
}
async function loadProfile(user) {
document.getElementById("profile-email").textContent = user.email;
document
.getElementById("profile-role-admin")
.classList.toggle("hidden", user.role !== "admin");
document.getElementById("email-form").email.value = user.email;
}
async function loadSessions() {
const body = document.getElementById("sessions-body");
try {
const { sessions } = await api("/api/account/sessions");
if (!sessions.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No active sessions.</td></tr>`;
return;
}
body.replaceChildren(
...sessions.map((session) => {
const tr = document.createElement("tr");
const device = document.createElement("td");
const label = document.createElement("span");
label.className = "session-device-label";
label.textContent = friendlyDevice(session.userAgent);
if (session.userAgent) label.title = session.userAgent;
device.append(label);
if (session.current) {
const badge = document.createElement("span");
badge.className = "badge badge-current";
badge.textContent = "This device";
badge.style.marginLeft = "8px";
device.append(badge);
}
const lastSeen = document.createElement("td");
lastSeen.textContent = fmtDate(session.lastSeenAt || session.createdAt);
const expires = document.createElement("td");
expires.textContent = fmtDate(session.expiresAt);
const actions = document.createElement("td");
const revoke = document.createElement("button");
revoke.className = "ghost-btn small";
revoke.type = "button";
revoke.textContent = session.current ? "Sign out" : "Revoke";
revoke.addEventListener("click", async () => {
try {
await api(`/api/account/sessions/${session.id}`, {
method: "DELETE",
});
if (session.current) return location.assign("/login");
showToast("Session revoked.");
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(revoke);
tr.append(device, lastSeen, expires, actions);
return tr;
}),
);
} catch (error) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load sessions.</td></tr>`;
}
}
async function loadPlans() {
const body = document.getElementById("plans-body");
try {
const response = await fetch("/api/plans");
if (!response.ok) throw new Error("could not load plans");
const { plans } = await response.json();
if (!plans.length) {
body.innerHTML = `<tr><td colspan="3" class="empty-state">No saved plans yet.</td></tr>`;
return;
}
body.replaceChildren(
...plans.map((plan) => {
const tr = document.createElement("tr");
const title = document.createElement("td");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("td");
updated.textContent = fmtDate(plan.updated_at);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const open = document.createElement("a");
open.className = "ghost-btn small";
open.href = `/app?plan=${encodeURIComponent(plan.id)}`;
open.textContent = "Open";
const duplicate = document.createElement("button");
duplicate.className = "ghost-btn small";
duplicate.type = "button";
duplicate.textContent = "Duplicate";
duplicate.addEventListener("click", async () => {
try {
await api("/api/plans", {
method: "POST",
body: JSON.stringify({ plan: plan.plan }),
});
showToast("Plan duplicated.");
loadPlans();
} catch (error) {
showToast(error.message, "fail");
}
});
const download = document.createElement("button");
download.className = "ghost-btn small";
download.type = "button";
download.textContent = "Download";
download.addEventListener("click", () => {
const blob = new Blob([JSON.stringify(plan.plan, null, 2)], {
type: "application/json",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
const cup = document.createElement("button");
cup.className = "ghost-btn small";
cup.type = "button";
cup.textContent = "Cup";
cup.addEventListener("click", async () => {
cup.disabled = true;
try {
const existing = await api(`/api/cupping?plan=${encodeURIComponent(plan.id)}`);
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
}
const created = await api("/api/cupping", {
method: "POST",
body: JSON.stringify({ roastPlanId: plan.id }),
});
location.assign(`/cupping?session=${created.session.id}`);
} catch (error) {
showToast(error.message, "fail");
cup.disabled = false;
}
});
const del = document.createElement("button");
del.className = "ghost-btn small";
del.type = "button";
del.textContent = "Delete";
del.addEventListener("click", async () => {
if (!confirm("Delete this plan? This cannot be undone.")) return;
try {
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
showToast("Plan deleted.");
loadPlans();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(open, duplicate, download, cup, del);
tr.append(title, updated, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
}
}
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
async function guarded(button, run) {
if (button.disabled) return;
button.disabled = true;
try {
await run();
} finally {
button.disabled = false;
}
}
function wireForms(user) {
document.getElementById("email-form").addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account/email", { method: "PUT", body: JSON.stringify(data) });
showToast("Email updated.");
document.getElementById("profile-email").textContent = data.email;
event.target.password.value = "";
} catch (error) {
showToast(
error.code === "email_exists"
? "That email is already in use."
: error.message,
"fail",
);
}
});
});
document
.getElementById("password-form")
.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
if (data.newPassword !== data.confirmPassword) {
showToast("New passwords do not match.", "fail");
return;
}
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account/password", {
method: "PUT",
body: JSON.stringify(data),
});
showToast("Password updated. Other sessions were signed out.");
event.target.reset();
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document
.getElementById("btn-revoke-others")
.addEventListener("click", (event) => {
guarded(event.currentTarget, async () => {
try {
await api("/api/account/sessions/revoke-others", { method: "POST" });
showToast("Other sessions signed out.");
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document.getElementById("delete-form").addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
if (data.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
showToast("Type your email exactly to confirm.", "fail");
return;
}
if (!confirm("This permanently deletes your account and plans. Continue?"))
return;
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account", {
method: "DELETE",
body: JSON.stringify({ password: data.password }),
});
location.assign("/");
} catch (error) {
showToast(
error.code === "last_admin"
? "You are the only administrator — promote another admin first."
: error.message,
"fail",
);
}
});
});
}
function wirePwaButtons() {
let deferredInstallPrompt = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
if ("serviceWorker" in navigator) {
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration?.waiting)
document.getElementById("btn-refresh").classList.remove("hidden");
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (hadController) location.reload();
});
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
document.getElementById("profile-since").textContent =
`Member since ${fmtDate(user.createdAt)}`;
await loadProfile(user);
wireForms(user);
wirePwaButtons();
await Promise.all([loadSessions(), loadPlans()]);
}
init();
+329
View File
@@ -0,0 +1,329 @@
import { api, protectedFetch } from "./api.js";
import { initSideNav, loadNavUser } from "./nav.js";
import { showToast } from "./toast.js";
let currentUsers = [];
let planFilterUserId = null;
let currentUserId = null;
function fmtDate(value) {
return value ? new Date(value).toLocaleString() : "—";
}
async function loadMetrics() {
const grid = document.getElementById("metrics");
try {
const { metrics } = await api("/api/admin/metrics");
const tiles = [
["Users", metrics.totalUsers],
["Plans", metrics.totalPlans],
["Plans updated (7d)", metrics.plansUpdatedLast7Days],
["Active sessions", metrics.activeSessions],
];
grid.replaceChildren(
...tiles.map(([label, value]) => {
const tile = document.createElement("div");
tile.className = "stat-tile";
tile.innerHTML = `<div class="stat-tile-label"></div><div class="stat-tile-value"></div>`;
tile.querySelector(".stat-tile-label").textContent = label;
tile.querySelector(".stat-tile-value").textContent = value;
return tile;
}),
);
} catch {
grid.textContent = "";
}
}
async function loadResetLinks() {
try {
const { links } = await api("/api/admin/password-resets");
const card = document.getElementById("reset-links-card");
if (!links.length) {
card.hidden = true;
return;
}
card.hidden = false;
document.getElementById("resets-body").replaceChildren(
...links.map((link) => {
const tr = document.createElement("tr");
const email = document.createElement("td");
email.textContent = link.email;
const url = document.createElement("td");
// Not a clickable <a>: an admin's own session immediately 302s /reset to /app,
// so the link is only useful copied out and handed to the actual user.
const copyBtn = document.createElement("button");
copyBtn.className = "ghost-btn small";
copyBtn.type = "button";
copyBtn.textContent = "Copy link";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(link.url);
copyBtn.textContent = "Copied!";
} catch {
copyBtn.textContent = link.url;
}
setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
});
url.append(copyBtn);
const expires = document.createElement("td");
expires.textContent = fmtDate(link.expiresAt);
tr.append(email, url, expires);
return tr;
}),
);
} catch {
/* optional feature; ignore failures */
}
}
function wireSignupToggle() {
const toggle = document.getElementById("signup-toggle");
toggle.addEventListener("change", async () => {
try {
await api("/api/admin/signup-enabled", {
method: "PUT",
body: JSON.stringify({ enabled: toggle.checked }),
});
showToast(toggle.checked ? "Signups enabled." : "Signups disabled.");
} catch (error) {
toggle.checked = !toggle.checked;
showToast(error.message, "fail");
}
});
}
function roleBadge(user) {
return user.role === "admin"
? `<span class="badge badge-admin">Admin</span>`
: `<span class="badge badge-user">User</span>`;
}
function statusBadge(user) {
return user.disabled_at
? `<span class="badge badge-disabled">Disabled</span>`
: `<span class="badge badge-current">Active</span>`;
}
async function loadUsers() {
const body = document.getElementById("users-body");
try {
const { users, signupEnabled } = await api("/api/admin/users");
currentUsers = users;
document.getElementById("signup-toggle").checked = signupEnabled;
renderUsers();
} catch {
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load users.</td></tr>`;
}
}
function renderUsers() {
const body = document.getElementById("users-body");
const query = document
.getElementById("user-search")
.value.trim()
.toLowerCase();
const filtered = query
? currentUsers.filter((u) => u.email.toLowerCase().includes(query))
: currentUsers;
if (!filtered.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No matching users.</td></tr>`;
return;
}
body.replaceChildren(
...filtered.map((user) => {
const tr = document.createElement("tr");
const email = document.createElement("td");
email.textContent = user.email;
const role = document.createElement("td");
role.innerHTML = roleBadge(user);
const status = document.createElement("td");
status.innerHTML = statusBadge(user);
const count = document.createElement("td");
count.className = "num";
count.textContent = user.plan_count;
const joined = document.createElement("td");
joined.textContent = fmtDate(user.created_at);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const isBootstrapAdmin = user.email === "[email protected]";
const isSelf = user.id === currentUserId;
const viewPlans = document.createElement("button");
viewPlans.className = "ghost-btn small";
viewPlans.type = "button";
viewPlans.textContent = "View plans";
viewPlans.addEventListener("click", () => filterPlansByUser(user));
actions.append(viewPlans);
// The server refuses role/disable/delete on the bootstrap admin and on your own
// account (so an admin can never lock themselves out) — don't offer buttons the
// server will always reject.
if (!isBootstrapAdmin && !isSelf) {
const roleBtn = document.createElement("button");
roleBtn.className = "ghost-btn small";
roleBtn.type = "button";
const promoting = user.role !== "admin";
roleBtn.textContent = promoting ? "Promote" : "Demote";
roleBtn.addEventListener("click", async () => {
if (
promoting &&
!confirm(
`Grant ${user.email} full admin access, including user management and password-reset links?`,
)
)
return;
try {
await api(`/api/admin/users/${user.id}/role`, {
method: "PUT",
body: JSON.stringify({
role: promoting ? "admin" : "user",
}),
});
showToast("Role updated.");
loadUsers();
} catch (error) {
showToast(error.message, "fail");
}
});
const disableBtn = document.createElement("button");
disableBtn.className = "ghost-btn small";
disableBtn.type = "button";
const disabling = !user.disabled_at;
disableBtn.textContent = disabling ? "Disable" : "Enable";
disableBtn.addEventListener("click", async () => {
if (
disabling &&
!confirm(`Disable ${user.email}? This signs them out everywhere immediately.`)
)
return;
try {
await api(`/api/admin/users/${user.id}/disabled`, {
method: "PUT",
body: JSON.stringify({ disabled: disabling }),
});
showToast(disabling ? "User disabled." : "User enabled.");
loadUsers();
} catch (error) {
showToast(error.message, "fail");
}
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async () => {
if (
!confirm(
`Permanently delete ${user.email} and all of their plans?`,
)
)
return;
try {
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
showToast("User deleted.");
loadUsers();
loadMetrics();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(roleBtn, disableBtn, deleteBtn);
}
tr.append(email, role, status, count, joined, actions);
return tr;
}),
);
}
async function loadPlans() {
const body = document.getElementById("plans-body");
try {
const url = planFilterUserId
? `/api/admin/plans?user=${encodeURIComponent(planFilterUserId)}`
: "/api/admin/plans";
const { plans } = await api(url);
if (!plans.length) {
body.innerHTML = `<tr><td colspan="3" class="empty-state">No plans.</td></tr>`;
return;
}
body.replaceChildren(
...plans.map((plan) => {
const tr = document.createElement("tr");
const owner = document.createElement("td");
owner.textContent = plan.email;
const title = document.createElement("td");
title.textContent = plan.title || "Untitled plan";
const updated = document.createElement("td");
updated.textContent = fmtDate(plan.updated_at);
tr.append(owner, title, updated);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
}
}
function filterPlansByUser(user) {
planFilterUserId = user.id;
document.getElementById("clear-plan-filter").classList.remove("hidden");
document.getElementById("plans").scrollIntoView({ behavior: "smooth" });
loadPlans();
}
async function loadAudit() {
const body = document.getElementById("audit-body");
try {
const { events } = await api("/api/admin/audit");
if (!events.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No activity yet.</td></tr>`;
return;
}
body.replaceChildren(
...events.map((event) => {
const tr = document.createElement("tr");
const when = document.createElement("td");
when.textContent = fmtDate(event.created_at);
const actor = document.createElement("td");
actor.textContent = event.actor_email || "—";
const action = document.createElement("td");
action.textContent = event.action.replaceAll("_", " ");
const target = document.createElement("td");
target.textContent = event.target || "—";
tr.append(when, actor, action, target);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load activity.</td></tr>`;
}
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
document.getElementById("user-search").addEventListener("input", renderUsers);
document.getElementById("clear-plan-filter").addEventListener("click", () => {
planFilterUserId = null;
document.getElementById("clear-plan-filter").classList.add("hidden");
loadPlans();
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
currentUserId = user.id;
wireSignupToggle();
await Promise.all([
loadMetrics(),
loadResetLinks(),
loadUsers(),
loadPlans(),
loadAudit(),
]);
}
init();
+59 -46
View File
@@ -1,79 +1,92 @@
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side const csrf = () =>
// library directory (e.g. wherever the roastetta skill already downloaded logs). document.cookie
.split("; ")
.find((v) => v.startsWith("rp_csrf="))
?.split("=")[1] || "";
const setText = (el, text, error = false) => {
const node = document.createElement("p");
node.textContent = text;
if (error) node.style.color = "#a8371a";
el.replaceChildren(node);
};
export function initAlogPanel({ state, recompute }) { export function initAlogPanel({ state, recompute }) {
const fileInput = document.getElementById("alog-file"); const fileInput = document.getElementById("alog-file"),
const libraryBtn = document.getElementById("alog-library-refresh"); libraryBtn = document.getElementById("alog-library-refresh"),
const libraryList = document.getElementById("alog-library-list"); libraryList = document.getElementById("alog-library-list"),
const resultEl = document.getElementById("alog-result"); resultEl = document.getElementById("alog-result");
fileInput.addEventListener("change", async (e) => { fileInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
resultEl.innerHTML = "<p>Parsing…</p>"; setText(resultEl, "Parsing…");
try { try {
const content = await file.text();
const res = await fetch("/api/alog", { const res = await fetch("/api/alog", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "x-csrf-token": csrf() },
body: JSON.stringify({ filename: file.name, content }), body: JSON.stringify({
filename: file.name,
content: await file.text(),
}),
}); });
const body = await res.json(); applyResult(await res.json());
applyResult(body);
} catch (err) { } catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`; setText(resultEl, err.message, true);
} }
}); });
libraryBtn.addEventListener("click", async () => { libraryBtn.addEventListener("click", async () => {
libraryList.classList.remove("hidden"); libraryList.classList.remove("hidden");
libraryList.innerHTML = "<li>Loading…</li>"; libraryList.replaceChildren(
Object.assign(document.createElement("li"), { textContent: "Loading…" }),
);
try { try {
const res = await fetch("/api/alog/library"); const body = await (await fetch("/api/alog/library")).json();
const body = await res.json(); if (!body.ok || !body.files.length) {
if (!body.ok || body.files.length === 0) { libraryList.replaceChildren(
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>"; Object.assign(document.createElement("li"), {
textContent:
"No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.",
}),
);
return; return;
} }
libraryList.innerHTML = ""; libraryList.replaceChildren(
for (const f of body.files) { ...body.files.map((f) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`; li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
li.addEventListener("click", async () => { li.onclick = async () => {
resultEl.innerHTML = "<p>Loading…</p>"; setText(resultEl, "Loading…");
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`); applyResult(
applyResult(await r.json()); await (
}); await fetch(
libraryList.appendChild(li); `/api/alog/library/${encodeURIComponent(f.filename)}`,
} )
).json(),
);
};
return li;
}),
);
} catch (err) { } catch (err) {
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`; const li = document.createElement("li");
li.textContent = err.message;
li.style.color = "#a8371a";
libraryList.replaceChildren(li);
} }
}); });
function applyResult(body) { function applyResult(body) {
if (!body.ok) { if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`; setText(resultEl, body.error ?? body.code, true);
return; return;
} }
state.plan.reference = body; state.plan.reference = body;
recompute(); recompute();
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join(""); setText(
resultEl.innerHTML = ` resultEl,
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} · `${body.roast.title}${body.roast.roastDate || "no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct ?? "—"}%.`,
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} · );
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
} }
} }
function fmt(seconds) { function fmt(seconds) {
if (seconds === null || seconds === undefined) return "—"; if (seconds == null) return "—";
const s = Math.round(seconds); const s = Math.round(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
} }
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+35
View File
@@ -0,0 +1,35 @@
export const csrfToken = () =>
document.cookie
.split("; ")
.find((v) => v.startsWith("rp_csrf="))
?.split("=")[1] || "";
export const protectedFetch = (url, options = {}) =>
fetch(url, {
...options,
headers: { ...options.headers, "x-csrf-token": csrfToken() },
});
export async function api(url, options = {}) {
const response = await protectedFetch(url, {
...options,
headers: { "content-type": "application/json", ...options.headers },
});
const body = await response.json().catch(() => ({}));
if (response.status === 401 && body.code === "unauthorized") {
// This is specifically requireAuth's code for "no valid session" — the session expired
// or was revoked mid-page (account/admin pages otherwise show a permanent "could not
// load" error with no indication the user was signed out). A 401 with any other code
// (e.g. a wrong current password on an account form) is a normal request failure, not a
// sign-out, and must not redirect the user away mid-form.
location.assign("/login");
return new Promise(() => {}); // navigation is already underway; never resolve
}
if (!response.ok) {
const error = new Error(body.error || body.code || "request_failed");
error.code = body.code;
error.status = response.status;
throw error;
}
return body;
}
+586
View File
@@ -0,0 +1,586 @@
import { api, protectedFetch } from "./api.js";
import { initSideNav, loadNavUser } from "./nav.js";
import { showToast } from "./toast.js";
import { wireWhyPanels } from "./why-panels.js";
import {
SCORE_ATTRS,
SCORE_LABELS,
TICK_ATTRS,
TICK_LABELS,
FLAVOR_TAXONOMY,
MAX_FLAVOR_TAGS,
MAX_CUP_COUNT,
computeTotalScore,
} from "/shared/cupping.js";
const sessionId = new URLSearchParams(location.search).get("session");
function fmtDate(value) {
return value ? new Date(value).toLocaleString() : "—";
}
/** Disables `el` for the duration of `run()` so a slow request can't be double-submitted. */
async function guarded(el, run) {
if (!el || el.disabled) return;
el.disabled = true;
try {
await run();
} finally {
el.disabled = false;
}
}
// ─── List view ───────────────────────────────────────────────────────────
async function loadSessions() {
const body = document.getElementById("sessions-body");
try {
const { sessions } = await api("/api/cupping");
if (!sessions.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No cupping sessions yet.</td></tr>`;
return;
}
body.replaceChildren(
...sessions.map((s) => {
const tr = document.createElement("tr");
const coffee = document.createElement("td");
coffee.textContent = s.planTitle || "Untitled";
const score = document.createElement("td");
score.className = "num";
const badge = document.createElement("span");
badge.className = "badge badge-current";
badge.textContent = s.totalScore.toFixed(2);
score.append(badge);
const cups = document.createElement("td");
cups.className = "num";
cups.textContent = s.cupCount;
const flavors = document.createElement("td");
const shown = s.flavorTags.slice(0, 3).map((t) => t.split(".").pop());
flavors.textContent =
shown.join(", ") + (s.flavorTags.length > 3 ? ` +${s.flavorTags.length - 3}` : "");
const updated = document.createElement("td");
updated.textContent = fmtDate(s.updatedAt);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const open = document.createElement("a");
open.className = "ghost-btn small";
open.href = `/cupping?session=${s.id}`;
open.textContent = "Open";
const del = document.createElement("button");
del.className = "ghost-btn small";
del.type = "button";
del.textContent = "Delete";
del.addEventListener("click", (event) => {
if (!confirm("Delete this cupping session?")) return;
guarded(event.currentTarget, async () => {
try {
await api(`/api/cupping/${s.id}`, { method: "DELETE" });
showToast("Session deleted.");
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
});
actions.append(open, del);
tr.append(coffee, score, cups, flavors, updated, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load sessions.</td></tr>`;
}
}
async function loadPlanOptions() {
const select = document.getElementById("new-session-plan");
try {
const { plans } = await api("/api/plans");
select.append(
...plans.map((p) => {
const option = document.createElement("option");
option.value = p.id;
option.textContent = p.plan?.fields?.["0.1"] || "Untitled plan";
return option;
}),
);
} catch {
/* the plan-less option still works */
}
}
function wireNewSessionForm() {
const form = document.getElementById("new-session-form");
form.addEventListener("submit", (event) => {
event.preventDefault();
const roastPlanId = document.getElementById("new-session-plan").value || undefined;
const cupCount = Number(document.getElementById("new-session-cups").value) || 5;
guarded(form.querySelector("button[type=submit]"), async () => {
try {
const { session } = await api("/api/cupping", {
method: "POST",
body: JSON.stringify({ roastPlanId, cupCount }),
});
location.assign(`/cupping?session=${session.id}`);
} catch (error) {
showToast(error.message, "fail");
}
});
});
}
// ─── Session view ────────────────────────────────────────────────────────
let data = null; // the coerced session document (snake_case, matches shared/cupping.js)
let saveTimer = null;
function setAutosaveStatus(status) {
const chip = document.getElementById("cupping-autosave-status");
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saving", "saved", "failed");
chip.classList.add(status);
text.textContent =
{ saving: "Saving…", saved: "Synced", failed: "Sync failed" }[status] || "Not saved yet";
}
function scheduleSave() {
setAutosaveStatus("saving");
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 500);
}
async function save() {
try {
const body = await api(`/api/cupping/${sessionId}`, {
method: "PUT",
body: JSON.stringify({ data }),
});
document.getElementById("cup-total").textContent = body.session.totalScore.toFixed(2);
setAutosaveStatus("saved");
} catch (error) {
setAutosaveStatus("failed");
showToast(error.message, "fail");
}
}
function liveTotal() {
return computeTotalScore(
data.scores,
data.ticks,
data.taint_cups,
data.fault_cups,
data.cup_count,
);
}
function renderTotal() {
document.getElementById("cup-total").textContent = liveTotal().toFixed(2);
}
// ── Score sliders ──
function renderScoreRows() {
const wrap = document.getElementById("cup-score-rows");
wrap.replaceChildren(
...SCORE_ATTRS.map((attr) => {
const row = document.createElement("div");
row.className = "cup-score-row";
row.dataset.attr = attr;
const unscored = !(data.scores[attr] > 0);
row.classList.toggle("unscored", unscored);
const label = document.createElement("span");
label.className = "cup-score-label";
label.textContent = SCORE_LABELS[attr];
const slider = document.createElement("input");
slider.type = "range";
slider.min = "6";
slider.max = "10";
slider.step = "0.25";
slider.value = unscored ? "6" : data.scores[attr];
slider.setAttribute("aria-label", `${SCORE_LABELS[attr]} score`);
const output = document.createElement("output");
output.className = "cup-score-value";
output.textContent = unscored ? "—" : data.scores[attr].toFixed(2);
const clearBtn = document.createElement("button");
clearBtn.type = "button";
clearBtn.className = "ghost-btn small cup-score-clear";
clearBtn.hidden = unscored;
clearBtn.textContent = "✕";
clearBtn.setAttribute("aria-label", `Clear ${SCORE_LABELS[attr]} score`);
slider.addEventListener("input", () => {
data.scores[attr] = Number(slider.value);
row.classList.remove("unscored");
output.textContent = data.scores[attr].toFixed(2);
clearBtn.hidden = false;
renderTotal();
renderRadar();
scheduleSave();
});
clearBtn.addEventListener("click", () => {
data.scores[attr] = 0;
row.classList.add("unscored");
slider.value = "6";
output.textContent = "—";
clearBtn.hidden = true;
renderTotal();
renderRadar();
scheduleSave();
});
row.append(label, slider, output, clearBtn);
return row;
}),
);
}
// ── Tick attributes (fill-up-to cup cells) ──
function renderTickRows() {
const wrap = document.getElementById("cup-tick-rows");
wrap.replaceChildren(
...TICK_ATTRS.map((attr) => {
const row = document.createElement("div");
row.className = "cup-tick-row";
row.dataset.tick = attr;
const label = document.createElement("span");
label.className = "cup-score-label";
label.textContent = TICK_LABELS[attr];
const cells = document.createElement("div");
cells.className = "cup-tick-cells";
cells.setAttribute("role", "group");
cells.setAttribute("aria-label", `${TICK_LABELS[attr]} — cups passed`);
const output = document.createElement("output");
function renderCells() {
const count = data.ticks[attr] || 0;
cells.replaceChildren(
...Array.from({ length: data.cup_count }, (_, i) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cup-tick";
btn.setAttribute("aria-label", `Cup ${i + 1}`);
const pressed = i < count;
btn.setAttribute("aria-pressed", String(pressed));
btn.addEventListener("click", () => {
data.ticks[attr] = i >= (data.ticks[attr] || 0) ? i + 1 : i;
renderCells();
renderTotal();
scheduleSave();
});
return btn;
}),
);
output.textContent = `${count}/${data.cup_count}`;
}
renderCells();
row._renderCells = renderCells;
row.append(label, cells, output);
return row;
}),
);
}
// ── Defects (taint/fault steppers) ──
function renderDefectRows() {
const wrap = document.getElementById("cup-defect-rows");
const specs = [
{ key: "taint_cups", label: "Taint cups", note: "2 pts each" },
{ key: "fault_cups", label: "Fault cups", note: "4 pts each" },
];
wrap.replaceChildren(
...specs.map(({ key, label, note }) => {
const row = document.createElement("div");
row.className = "cup-stepper-row";
const labelEl = document.createElement("span");
labelEl.className = "cup-score-label";
labelEl.textContent = `${label} (${note})`;
const minus = document.createElement("button");
minus.type = "button";
minus.className = "ghost-btn small";
minus.textContent = "";
minus.setAttribute("aria-label", `Decrease ${label}`);
const count = document.createElement("output");
const plus = document.createElement("button");
plus.type = "button";
plus.className = "ghost-btn small";
plus.textContent = "+";
plus.setAttribute("aria-label", `Increase ${label}`);
function update() {
count.textContent = data[key];
}
minus.addEventListener("click", () => {
data[key] = Math.max(0, data[key] - 1);
update();
renderTotal();
scheduleSave();
});
plus.addEventListener("click", () => {
data[key] = Math.min(data.cup_count, data[key] + 1);
update();
renderTotal();
scheduleSave();
});
update();
row.append(labelEl, minus, count, plus);
return row;
}),
);
}
function reclampForCupCount() {
for (const attr of TICK_ATTRS) data.ticks[attr] = Math.min(data.ticks[attr] || 0, data.cup_count);
data.taint_cups = Math.min(data.taint_cups, data.cup_count);
data.fault_cups = Math.min(data.fault_cups, data.cup_count);
}
function wireCupCount() {
const input = document.getElementById("cup-count");
input.min = "1";
input.max = String(MAX_CUP_COUNT);
input.value = data.cup_count;
input.addEventListener("change", () => {
const next = Math.max(1, Math.min(MAX_CUP_COUNT, Number(input.value) || 1));
data.cup_count = next;
input.value = next;
reclampForCupCount();
renderTickRows();
renderDefectRows();
renderTotal();
scheduleSave();
});
}
// ── Flavor checklist ──
function renderFlavorChips() {
const chips = document.getElementById("flavor-chips");
chips.replaceChildren(
...data.flavor_tags.map((tag) => {
// A dedicated class, not .chip-opt (a checkbox-option style whose CSS uses a
// descendant `span` selector — reusing it here with a nested span for the remove
// button doubled-up borders/padding onto that inner span too).
const chip = document.createElement("span");
chip.className = "flavor-chip";
const text = document.createElement("span");
text.textContent = tag.split(".").pop().replaceAll("_", " ");
const remove = document.createElement("button");
remove.type = "button";
remove.className = "flavor-chip-remove";
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
remove.textContent = "✕";
remove.addEventListener("click", () => toggleFlavorTag(tag, false));
chip.append(text, remove);
return chip;
}),
);
document
.getElementById("flavor-limit-note")
.classList.toggle("hidden", data.flavor_tags.length < MAX_FLAVOR_TAGS);
}
function toggleFlavorTag(tag, checked) {
if (checked) {
if (data.flavor_tags.length >= MAX_FLAVOR_TAGS || data.flavor_tags.includes(tag)) return;
data.flavor_tags.push(tag);
} else {
data.flavor_tags = data.flavor_tags.filter((t) => t !== tag);
}
renderFlavorFamilies();
renderFlavorChips();
scheduleSave();
}
function renderFlavorFamilies() {
const wrap = document.getElementById("flavor-families");
const atLimit = data.flavor_tags.length >= MAX_FLAVOR_TAGS;
wrap.replaceChildren(
...Object.entries(FLAVOR_TAXONOMY).map(([familyId, family]) => {
const selectedCount = data.flavor_tags.filter((t) => t.startsWith(`${familyId}.`)).length;
const details = document.createElement("details");
details.className = "flavor-family";
const summary = document.createElement("summary");
summary.textContent = family.label + (selectedCount ? ` (${selectedCount})` : "");
details.append(summary);
for (const [subId, descriptors] of Object.entries(family.subgroups)) {
const subhead = document.createElement("p");
subhead.className = "subhead";
subhead.textContent = subId.replaceAll("_", " ");
const group = document.createElement("div");
group.className = "chip-group";
for (const descriptor of descriptors) {
const tag = `${familyId}.${subId}.${descriptor}`;
const label = document.createElement("label");
label.className = "chip-opt";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = data.flavor_tags.includes(tag);
input.disabled = atLimit && !input.checked;
input.addEventListener("change", () => toggleFlavorTag(tag, input.checked));
const span = document.createElement("span");
span.textContent = descriptor.replaceAll("_", " ");
label.append(input, span);
group.append(label);
}
details.append(subhead, group);
}
return details;
}),
);
}
// ── Radar chart (pure SVG, no dependency) ──
const RADAR_CENTER = 120;
const RADAR_RADIUS = 90;
function radarPoints() {
const n = SCORE_ATTRS.length;
return SCORE_ATTRS.map((attr, i) => {
const raw = data.scores[attr] || 0;
const frac = raw > 0 ? Math.max(0, Math.min(1, (raw - 6) / 4)) : 0;
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
return {
x: RADAR_CENTER + frac * RADAR_RADIUS * Math.cos(angle),
y: RADAR_CENTER + frac * RADAR_RADIUS * Math.sin(angle),
};
});
}
function svgEl(tag, attrs) {
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
function initRadarStatic() {
const svg = document.getElementById("cup-radar");
svg.replaceChildren();
// Rings at scores 7/8/9/10 (6 is the center — an unscored/floor axis point).
for (const score of [7, 8, 9, 10]) {
const r = ((score - 6) / 4) * RADAR_RADIUS;
svg.append(
svgEl("circle", {
cx: RADAR_CENTER,
cy: RADAR_CENTER,
r,
fill: "none",
stroke: "var(--line)",
"stroke-width": "1",
}),
);
}
const n = SCORE_ATTRS.length;
SCORE_ATTRS.forEach((attr, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
const x2 = RADAR_CENTER + RADAR_RADIUS * Math.cos(angle);
const y2 = RADAR_CENTER + RADAR_RADIUS * Math.sin(angle);
svg.append(
svgEl("line", {
x1: RADAR_CENTER,
y1: RADAR_CENTER,
x2,
y2,
stroke: "var(--line)",
"stroke-width": "1",
}),
);
const lx = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.cos(angle);
const ly = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.sin(angle);
const label = svgEl("text", {
x: lx,
y: ly,
"text-anchor": "middle",
"dominant-baseline": "middle",
"font-size": "9",
fill: "var(--ink-2)",
});
label.textContent = SCORE_LABELS[attr].split("/")[0];
svg.append(label);
});
const shape = svgEl("polygon", {
id: "radar-shape",
fill: "var(--ember)",
"fill-opacity": "0.25",
stroke: "var(--ember)",
"stroke-width": "1.5",
});
svg.append(shape);
}
function renderRadar() {
const shape = document.getElementById("radar-shape");
if (!shape) return;
shape.setAttribute("points", radarPoints().map((p) => `${p.x},${p.y}`).join(" "));
}
function wireNotes() {
const textarea = document.getElementById("cup-notes-text");
textarea.value = data.notes;
document.getElementById("cup-notes-count").textContent = data.notes.length;
textarea.addEventListener("input", () => {
data.notes = textarea.value;
document.getElementById("cup-notes-count").textContent = data.notes.length;
scheduleSave();
});
}
async function initSessionView(user) {
document.getElementById("cupping-list-view").classList.add("hidden");
document.getElementById("cupping-session-view").classList.remove("hidden");
document.getElementById("session-autosave-wrap").classList.remove("hidden");
setAutosaveStatus("saved");
let body;
try {
body = await api(`/api/cupping/${sessionId}`);
} catch (error) {
showToast(error.message || "Could not load this session.", "fail");
location.assign("/cupping");
return;
}
data = body.session.data;
document.getElementById("cupping-title").textContent =
body.session.planTitle || "Cupping session";
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
wireCupCount();
renderScoreRows();
renderTickRows();
renderDefectRows();
renderFlavorFamilies();
renderFlavorChips();
wireNotes();
initRadarStatic();
renderRadar();
renderTotal();
wireWhyPanels();
void user;
}
async function initListView() {
document.getElementById("cupping-list-view").classList.remove("hidden");
document.getElementById("cupping-session-view").classList.add("hidden");
wireNewSessionForm();
await Promise.all([loadSessions(), loadPlanOptions()]);
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
if (sessionId) await initSessionView(user);
else await initListView();
}
init();
+30
View File
@@ -0,0 +1,30 @@
const form = document.querySelector("#forgot-form");
const message = document.querySelector("#message");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const submitButton = form.querySelector("button[type=submit]");
submitButton.disabled = true;
try {
const response = await fetch("/api/auth/forgot", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
if (response.status === 429) {
message.textContent = "Too many requests. Try again in a few minutes.";
message.className = "auth-message error";
return;
}
// Always show the same message, whether or not the account exists.
message.textContent =
"If that email has an account, a reset link is on its way.";
message.className = "auth-message info";
form.reset();
} catch {
message.textContent = "Could not reach the server. Try again shortly.";
message.className = "auth-message error";
} finally {
submitButton.disabled = false;
}
});
+265
View File
@@ -0,0 +1,265 @@
import { api, protectedFetch } from "./api.js";
import { initSideNav, loadNavUser } from "./nav.js";
import { showToast } from "./toast.js";
let lots = [];
let showArchived = false;
let editingId = null;
function fmtDate(value) {
return value ? new Date(value).toLocaleDateString() : "—";
}
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
async function guarded(button, run) {
if (!button || button.disabled) return;
button.disabled = true;
try {
await run();
} finally {
button.disabled = false;
}
}
function renderLots() {
const body = document.getElementById("lots-body");
const visible = showArchived ? lots : lots.filter((l) => !l.archived);
if (!visible.length) {
body.innerHTML = `<tr><td colspan="5" class="empty-state">No lots yet.</td></tr>`;
return;
}
body.replaceChildren(
...visible.map((lot) => {
const tr = document.createElement("tr");
if (lot.archived) tr.className = "archived";
const lotCell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = lot.origin;
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = [lot.variety, lot.producer].filter(Boolean).join(" · ") || "—";
lotCell.append(strong, sub);
const processCell = document.createElement("td");
processCell.textContent = lot.process || "—";
const remainingCell = document.createElement("td");
const pct = lot.initialWeightG > 0
? Math.max(0, Math.min(100, (lot.remainingWeightG / lot.initialWeightG) * 100))
: 0;
const wrap = document.createElement("div");
wrap.className = "lot-remaining";
const bar = document.createElement("div");
bar.className = "blend-total-bar";
const fill = document.createElement("div");
fill.className = "blend-total-fill";
if (lot.remainingWeightG < 0) fill.classList.add("over");
fill.style.width = `${lot.remainingWeightG < 0 ? 0 : pct}%`;
bar.append(fill);
const label = document.createElement("span");
label.className = "blend-total-label";
label.textContent = `${Math.round(lot.remainingWeightG)} g of ${Math.round(lot.initialWeightG)} g`;
wrap.append(bar, label);
remainingCell.append(wrap);
const purchasedCell = document.createElement("td");
purchasedCell.textContent = fmtDate(lot.purchaseDate);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => startEdit(lot));
const archiveBtn = document.createElement("button");
archiveBtn.className = "ghost-btn small";
archiveBtn.type = "button";
archiveBtn.textContent = lot.archived ? "Unarchive" : "Archive";
archiveBtn.addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
try {
await api(`/api/inventory/${lot.id}`, {
method: "PUT",
body: JSON.stringify({ archived: !lot.archived }),
});
showToast(lot.archived ? "Lot unarchived." : "Lot archived.");
await loadLots();
} catch (error) {
showToast(error.message, "fail");
}
}),
);
const logBtn = document.createElement("button");
logBtn.className = "ghost-btn small";
logBtn.type = "button";
logBtn.textContent = "Log";
logBtn.addEventListener("click", () => toggleLog(lot, tr, logBtn));
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
if (!confirm(`Delete the ${lot.origin} lot? This cannot be undone.`))
return;
try {
await api(`/api/inventory/${lot.id}`, { method: "DELETE" });
showToast("Lot deleted.");
if (editingId === lot.id) cancelEdit();
await loadLots();
} catch (error) {
showToast(error.message, "fail");
}
}),
);
actions.append(editBtn, archiveBtn, logBtn, deleteBtn);
tr.append(lotCell, processCell, remainingCell, purchasedCell, actions);
return tr;
}),
);
}
async function toggleLog(lot, row, button) {
const existing = row.nextElementSibling;
if (existing?.classList.contains("lot-log-row")) {
existing.remove();
return;
}
guarded(button, async () => {
try {
const { log } = await api(`/api/inventory/${lot.id}`);
const tr = document.createElement("tr");
tr.className = "lot-log-row";
const td = document.createElement("td");
td.colSpan = 5;
if (!log.length) {
td.className = "empty-state";
td.textContent = "No consumption recorded yet.";
} else {
const ul = document.createElement("ul");
ul.style.margin = "0";
ul.style.paddingLeft = "18px";
for (const entry of log) {
const li = document.createElement("li");
li.style.fontSize = "12.5px";
li.textContent = `${Math.round(entry.weightG)} g — ${entry.planTitle || "manual"}${fmtDate(entry.createdAt)}`;
ul.append(li);
}
td.append(ul);
}
tr.append(td);
row.after(tr);
} catch (error) {
showToast(error.message, "fail");
}
});
}
async function loadLots() {
try {
const body = await api("/api/inventory");
lots = body.lots;
renderLots();
} catch (error) {
document.getElementById("lots-body").innerHTML =
`<tr><td colspan="5" class="empty-state">Could not load lots.</td></tr>`;
}
}
function startEdit(lot) {
editingId = lot.id;
const form = document.getElementById("lot-form");
form.id.value = lot.id;
form.origin.value = lot.origin;
form.variety.value = lot.variety;
form.process.value = lot.process;
form.producer.value = lot.producer;
form.purchaseDate.value = lot.purchaseDate ? lot.purchaseDate.slice(0, 10) : "";
form.initialWeightG.value = lot.initialWeightG;
form.costTotal.value = lot.costTotal ?? "";
form.moisturePct.value = lot.moisturePct ?? "";
form.densityGL.value = lot.densityGL ?? "";
form.notes.value = lot.notes;
document.getElementById("lot-form-title").textContent = "Edit lot";
document.getElementById("lot-form-submit").textContent = "Save changes";
document.getElementById("lot-form-cancel").classList.remove("hidden");
const note = document.getElementById("lot-remaining-note");
note.classList.remove("hidden");
note.textContent = `Remaining: ${Math.round(lot.remainingWeightG)} g — remaining weight is only changed by roasts drawing from the lot. Correct the initial weight and the remaining shifts with it.`;
document.getElementById("lot-form-card").scrollIntoView({ behavior: "smooth" });
}
function cancelEdit() {
editingId = null;
const form = document.getElementById("lot-form");
form.reset();
form.id.value = "";
document.getElementById("lot-form-title").textContent = "Add a lot";
document.getElementById("lot-form-submit").textContent = "Add lot";
document.getElementById("lot-form-cancel").classList.add("hidden");
document.getElementById("lot-remaining-note").classList.add("hidden");
}
document.getElementById("lot-form-cancel").addEventListener("click", cancelEdit);
document.getElementById("show-archived").addEventListener("change", (event) => {
showArchived = event.target.checked;
renderLots();
});
document.getElementById("lot-form").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.target;
const data = Object.fromEntries(new FormData(form));
const submitBtn = document.getElementById("lot-form-submit");
guarded(submitBtn, async () => {
try {
const payload = {
origin: data.origin,
variety: data.variety,
process: data.process,
producer: data.producer,
purchaseDate: data.purchaseDate || null,
initialWeightG: Number(data.initialWeightG),
costTotal: data.costTotal === "" ? null : Number(data.costTotal),
moisturePct: data.moisturePct === "" ? null : Number(data.moisturePct),
densityGL: data.densityGL === "" ? null : Number(data.densityGL),
notes: data.notes,
};
if (editingId) {
await api(`/api/inventory/${editingId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
showToast("Lot updated.");
} else {
await api("/api/inventory", { method: "POST", body: JSON.stringify(payload) });
showToast("Lot added.");
}
cancelEdit();
await loadLots();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
await loadLots();
}
init();
+35
View File
@@ -0,0 +1,35 @@
const form = document.querySelector("#login-form");
const message = document.querySelector("#message");
const submitButton = form.querySelector("button[type=submit]");
function showError(text) {
message.textContent = text;
message.className = "auth-message error";
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
submitButton.disabled = true;
message.textContent = "";
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const body = await response.json();
if (!response.ok) {
showError(
body.code === "too_many_attempts"
? "Too many attempts. Try again in a few minutes."
: body.error || "Could not log in. Check your email and password.",
);
return;
}
location.assign("/app");
} catch {
showError("Could not reach the server. Check your connection and try again.");
} finally {
submitButton.disabled = false;
}
});
+151
View File
@@ -0,0 +1,151 @@
import { api, protectedFetch } from "./api.js";
import { showToast } from "./toast.js";
let lots = [];
function findLot(id) {
return lots.find((l) => l.id === id);
}
/** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action
* (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's
* remotePlanId is module-local and can change after a sync. */
export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId }) {
const select = document.getElementById("lot-select");
const note = document.getElementById("lot-picker-note");
const consumeRow = document.getElementById("inventory-consume");
const consumeLabel = document.getElementById("inventory-consume-label");
const consumeBtn = document.getElementById("btn-consume");
function renderOptions() {
const current = state.plan.inventory.lotId;
select.replaceChildren(
Object.assign(document.createElement("option"), { value: "", textContent: "— none —" }),
...lots
.filter((l) => !l.archived)
.map((l) =>
Object.assign(document.createElement("option"), {
value: l.id,
textContent: `${l.origin}${l.variety ? `${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
}),
),
);
if (current && !findLot(current)) {
// The plan references a lot that's now archived/deleted — keep it selectable so the
// stored value still displays instead of silently reverting to "— none —".
select.append(
Object.assign(document.createElement("option"), {
value: current,
textContent: state.plan.inventory.lotLabel || "Unavailable lot",
disabled: true,
}),
);
}
select.value = current || "";
}
async function loadLots() {
try {
const body = await api("/api/inventory");
lots = body.lots;
} catch {
lots = [];
}
renderOptions();
updateConsumeRow();
}
function updateConsumeRow() {
const lotId = state.plan.inventory.lotId;
if (!lotId) {
consumeRow.classList.add("hidden");
return;
}
consumeRow.classList.remove("hidden");
const consumed = state.plan.inventory.consumed;
if (consumed) {
consumeLabel.textContent = `${Math.round(consumed.weightG)} g drawn from ${consumed.lotLabel} · ${new Date(consumed.atIso).toLocaleDateString()}`;
consumeBtn.classList.add("hidden");
return;
}
consumeBtn.classList.remove("hidden");
const weightG = Number(state.plan.fields["0.4"]);
const lot = findLot(lotId);
const lotLabel = lot ? lot.origin : state.plan.inventory.lotLabel || "this lot";
if (Number.isFinite(weightG) && weightG > 0) {
consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`;
consumeBtn.disabled = false;
consumeBtn.removeAttribute("title");
} else {
consumeLabel.textContent = `Set "Green in" (field 0.4) to draw weight from ${lotLabel}.`;
consumeBtn.disabled = true;
consumeBtn.title = "Enter a Green in weight on The Coffee first";
}
}
select.addEventListener("change", () => {
const lotId = select.value;
state.plan.inventory.lotId = lotId;
const lot = findLot(lotId);
state.plan.inventory.lotLabel = lot
? `${lot.origin}${lot.variety ? `${lot.variety}` : ""}`
: "";
if (lot) {
note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`;
const nameInput = document.querySelector('[name="0.1"]');
if (nameInput && !nameInput.value) {
nameInput.value = lot.origin;
state.plan.fields["0.1"] = lot.origin;
}
} else {
note.textContent =
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
}
updateConsumeRow();
recompute();
});
consumeBtn.addEventListener("click", async () => {
consumeBtn.disabled = true;
try {
if (!(await flushCurrentPlan())) {
showToast("Could not sync the plan. Try again.", "fail");
return;
}
const roastPlanId = getRemotePlanId();
if (!roastPlanId) {
showToast("Sync the plan first, then draw from the lot.", "fail");
return;
}
const weightG = Number(state.plan.fields["0.4"]);
const response = await protectedFetch(
`/api/inventory/${state.plan.inventory.lotId}/consume`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ weightG, roastPlanId }),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok && body.code !== "already_consumed") {
showToast(body.error || "Could not draw from the lot.", "fail");
return;
}
state.plan.inventory.consumed = {
lotId: state.plan.inventory.lotId,
lotLabel: state.plan.inventory.lotLabel,
weightG,
atIso: new Date().toISOString(),
};
updateConsumeRow();
recompute();
showToast(`${weightG} g drawn from lot.`);
await loadLots();
} finally {
consumeBtn.disabled = false;
}
});
loadLots();
return { updateConsumeRow, renderOptions };
}
+624 -86
View File
@@ -1,17 +1,56 @@
import { FIELD_IDS, blankPlan } from "/shared/fields.js"; import { FIELD_IDS, blankPlan } from "/shared/fields.js";
import { computeLedger } from "/shared/ledger.js"; import { computeLedger } from "/shared/ledger.js";
import { formatDuration, formatSigned, parseRangeMidpoint } from "/shared/time.js"; import {
formatDuration,
formatSigned,
parseRangeMidpoint,
} from "/shared/time.js";
import { CULTIVARS, findCultivar } from "/shared/reference-data.js"; import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js"; import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
import { initPrefillPanel } from "./prefill-ui.js"; import { initPrefillPanel } from "./prefill-ui.js";
import { initAlogPanel } from "./alog-ui.js"; import { initAlogPanel } from "./alog-ui.js";
import { initPrint } from "./print.js"; import { initPrint } from "./print.js";
import { initLotPicker } from "./lot-picker.js";
import { protectedFetch, csrfToken } from "./api.js";
import { initSideNav } from "./nav.js";
import { wireWhyPanels } from "./why-panels.js";
const FIELD_ID_SET = new Set(FIELD_IDS); const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1"; const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let draftSyncedAt = null;
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
let lotPicker = null;
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
export const state = { plan: loadFromStorage() ?? blankPlan() }; // Temperature fields are always stored in state.plan as canonical °C — this is purely a
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
function isTempField(name) {
return TEMP_FIELD_RE.test(name);
}
// Canonical °C string -> what the field should display right now.
function tempToDisplay(canonical) {
const n = Number.parseFloat(canonical);
if (!Number.isFinite(n)) return canonical ?? "";
return String(Math.round(tempUnit === "F" ? cToF(n) : n));
}
// What the user typed (in the current display unit) -> canonical °C string.
function tempToCanonical(display) {
const n = Number.parseFloat(display);
if (!Number.isFinite(n)) return display;
return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10);
}
export const state = { plan: blankPlan() };
const form = document.getElementById("plan-form"); const form = document.getElementById("plan-form");
@@ -33,7 +72,8 @@ function setPath(obj, path, value) {
const parts = path.split("."); const parts = path.split(".");
let node = obj; let node = obj;
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
if (node[parts[i]] === undefined || node[parts[i]] === null) node[parts[i]] = {}; if (node[parts[i]] === undefined || node[parts[i]] === null)
node[parts[i]] = {};
node = node[parts[i]]; node = node[parts[i]];
} }
node[parts[parts.length - 1]] = value; node[parts[parts.length - 1]] = value;
@@ -59,34 +99,38 @@ function setValueForName(name, value) {
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper. // which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() { function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows"); const tbody = document.getElementById("blend-rows");
tbody.innerHTML = ""; tbody.replaceChildren();
state.plan.blendComponents.forEach((_, i) => { state.plan.blendComponents.forEach((_, i) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.append(
document.createRange().createContextualFragment(`
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td> <td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td> <td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td> <td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
`; `),
);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} }
function renderBlendCards() { function renderBlendCards() {
const wrap = document.getElementById("blend-cards"); const wrap = document.getElementById("blend-cards");
wrap.innerHTML = ""; wrap.replaceChildren();
state.plan.blendComponents.forEach((_, i) => { state.plan.blendComponents.forEach((_, i) => {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "blend-card"; card.className = "blend-card";
card.innerHTML = ` card.append(
document.createRange().createContextualFragment(`
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label> <label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label> <label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label> <label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label> <label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label> <label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button> <button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`; `),
);
wrap.appendChild(card); wrap.appendChild(card);
}); });
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) { for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
@@ -102,7 +146,10 @@ function renderBlendCards() {
} }
function updateBlendTotal() { function updateBlendTotal() {
const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0); const total = state.plan.blendComponents.reduce(
(sum, c) => sum + (Number.parseFloat(c.sharePct) || 0),
0,
);
const fill = document.getElementById("blend-total-fill"); const fill = document.getElementById("blend-total-fill");
const label = document.getElementById("blend-total-label"); const label = document.getElementById("blend-total-label");
if (!fill || !label) return; if (!fill || !label) return;
@@ -113,7 +160,9 @@ function updateBlendTotal() {
} }
function updateBlendVisibility(mode) { function updateBlendVisibility(mode) {
document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend"); document
.getElementById("blend-body")
.classList.toggle("collapsed", mode !== "blend");
} }
function renderBlend() { function renderBlend() {
@@ -124,27 +173,30 @@ function renderBlend() {
function renderActuatorPrintRows() { function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows"); const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = ""; tbody.replaceChildren();
state.plan.actuators.forEach((_, i) => { state.plan.actuators.forEach((_, i) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.append(
document.createRange().createContextualFragment(`
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td> <td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td> <td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`; `),
);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} }
function renderActuatorTimeline() { function renderActuatorTimeline() {
const wrap = document.getElementById("actuator-timeline"); const wrap = document.getElementById("actuator-timeline");
wrap.innerHTML = ""; wrap.replaceChildren();
state.plan.actuators.forEach((_, i) => { state.plan.actuators.forEach((_, i) => {
const step = document.createElement("div"); const step = document.createElement("div");
step.className = "actuator-step"; step.className = "actuator-step";
step.innerHTML = ` step.append(
document.createRange().createContextualFragment(`
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div> <div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card"> <div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label> <label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
@@ -154,7 +206,8 @@ function renderActuatorTimeline() {
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label> <label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label> <label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div> </div>
`; `),
);
wrap.appendChild(step); wrap.appendChild(step);
}); });
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) { for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
@@ -172,6 +225,7 @@ function renderActuatorTimeline() {
function renderActuators() { function renderActuators() {
renderActuatorPrintRows(); renderActuatorPrintRows();
renderActuatorTimeline(); renderActuatorTimeline();
updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
} }
// ---- populate every control in the form from state.plan // ---- populate every control in the form from state.plan
@@ -185,15 +239,17 @@ export function renderFormFromPlan() {
} else if (el.type === "checkbox") { } else if (el.type === "checkbox") {
el.checked = Boolean(value); el.checked = Boolean(value);
} else { } else {
el.value = value ?? ""; el.value = isTempField(name) ? tempToDisplay(value) : (value ?? "");
} }
} }
updateBlendVisibility(state.plan.fields["2.1"]); updateBlendVisibility(state.plan.fields["2.1"]);
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan"; document.getElementById("header-coffee-name").textContent =
state.plan.fields["0.1"] || "New plan";
} }
function fmtOut(id, text) { function fmtOut(id, text) {
for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—"; for (const el of document.querySelectorAll(`[data-out="${id}"]`))
el.textContent = text ?? "—";
} }
function renderLedger() { function renderLedger() {
@@ -219,10 +275,22 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct)); fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS)); fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}),
);
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) { for (const [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown"); cell.classList.remove("pass", "fail", "unknown");
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail"); cell.classList.add(
check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
);
} }
} }
@@ -237,8 +305,10 @@ function renderLedger() {
fmtOut("pa-fc", d(ledger.A)); fmtOut("pa-fc", d(ledger.A));
fmtOut("pa-drop", d(ledger.D)); fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || ""; document.getElementById("back-coffee-name").textContent =
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan"; state.plan.fields["0.1"] || "";
document.getElementById("header-coffee-name").textContent =
state.plan.fields["0.1"] || "New plan";
return ledger; return ledger;
} }
@@ -265,7 +335,8 @@ function renderBandMarkers() {
const input = row.querySelector('input[name$=".tempC"]'); const input = row.querySelector('input[name$=".tempC"]');
const marker = row.querySelector(".band-marker"); const marker = row.querySelector(".band-marker");
if (!input || !marker) continue; if (!input || !marker) continue;
const v = Number.parseFloat(input.value); const raw = Number.parseFloat(input.value);
const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN;
if (!Number.isFinite(v)) { if (!Number.isFinite(v)) {
marker.style.display = "none"; marker.style.display = "none";
continue; continue;
@@ -279,7 +350,7 @@ function renderBandMarkers() {
} }
function paintCurveInto(planGroup, refGroup, planPoints, ref) { function paintCurveInto(planGroup, refGroup, planPoints, ref) {
planGroup.innerHTML = ""; planGroup.replaceChildren();
if (planPoints.length > 0) { if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints)); path.setAttribute("d", pointsToPathD(planPoints));
@@ -289,7 +360,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
path.style.color = "var(--ink, #1a1512)"; path.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(path); planGroup.appendChild(path);
for (const p of planPoints) { for (const p of planPoints) {
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); const c = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
c.setAttribute("cx", tToX(p.timeS).toFixed(1)); c.setAttribute("cx", tToX(p.timeS).toFixed(1));
c.setAttribute("cy", tempToY(p.tempC).toFixed(1)); c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
c.setAttribute("r", "3.2"); c.setAttribute("r", "3.2");
@@ -299,11 +373,14 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
} }
} }
refGroup.innerHTML = ""; refGroup.replaceChildren();
if (ref?.curve?.length) { if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve const d = ref.curve
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`) .map(
(p, i) =>
`${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`,
)
.join(" "); .join(" ");
path.setAttribute("d", d); path.setAttribute("d", d);
path.setAttribute("fill", "none"); path.setAttribute("fill", "none");
@@ -315,7 +392,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
refGroup.appendChild(path); refGroup.appendChild(path);
for (const m of ref.milestones ?? []) { for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue; if (m.tempC === null) continue;
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); const c = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
c.setAttribute("cx", tToX(m.timeS).toFixed(1)); c.setAttribute("cx", tToX(m.timeS).toFixed(1));
c.setAttribute("cy", tempToY(m.tempC).toFixed(1)); c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6"); c.setAttribute("r", "2.6");
@@ -346,51 +426,167 @@ function renderCurve(ledger) {
); );
} }
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
// is "0:00", since charge is always t=0) — compare against those defaults, not against "",
// so a fresh plan doesn't read as already having roast-day data.
const BLANK_PLAN = blankPlan();
// True once any value in `obj` differs from the same path in `blank` — used to lift the
// "phase-later" muting off Roast Log / After the Roast the moment the user has actually started
// using them, without ever hiding the plan column they exist to compare against.
function hasVal(obj, blank) {
if (obj == null) return false;
return Object.keys(obj).some((key) => {
const v = obj[key];
return typeof v === "object" && v !== null
? hasVal(v, blank?.[key])
: v !== "" && v != null && v !== blank?.[key];
});
}
export function recompute() { export function recompute() {
const ledger = renderLedger(); const ledger = renderLedger();
renderCurve(ledger); renderCurve(ledger);
renderBandMarkers(); renderBandMarkers();
document
.getElementById("sec-roastlog")
?.classList.toggle(
"has-data",
hasVal(state.plan.planActual, BLANK_PLAN.planActual),
);
document
.getElementById("sec-after")
?.classList.toggle(
"has-data",
hasVal(state.plan.afterRoast, BLANK_PLAN.afterRoast),
);
lotPicker?.updateConsumeRow();
autosave(); autosave();
} }
let autosaveAgeTimer = null; function setSaveStatus(status) {
function autosave() {
const chip = document.getElementById("autosave-status"); const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text"); const text = chip.querySelector(".autosave-text");
chip.classList.remove("saved"); chip.classList.remove("saving", "saved", "failed");
chip.classList.add("saving"); chip.classList.add(status);
text.textContent = "Saving…"; text.textContent =
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
// must check remotePlanId themselves afterward, which they already do.
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve(true);
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
remotePlanId = body.plan.id;
draftSyncedAt = body.plan.updated_at;
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
await loadPlans();
return true;
} catch {
setSaveStatus("failed");
return false;
}
});
return syncQueue;
}
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
} catch {
setSaveStatus("failed");
return false;
}
return await syncPlan(snapshot);
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t); clearTimeout(autosave._t);
autosave._t = setTimeout(() => { autosave._t = setTimeout(() => {
try { flushCurrentPlan();
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
const savedAt = Date.now();
chip.classList.remove("saving");
chip.classList.add("saved");
const tick = () => {
const secs = Math.round((Date.now() - savedAt) / 1000);
text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`;
};
tick();
clearInterval(autosaveAgeTimer);
autosaveAgeTimer = setInterval(tick, 5000);
} catch {
chip.classList.remove("saving", "saved");
text.textContent = "Save unavailable";
}
}, 400); }, 400);
} }
function loadFromStorage() { function loadFromStorage(userId) {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); storageKey = `${STORAGE_PREFIX}:${userId}`;
return raw ? JSON.parse(raw) : null; localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
// A shared browser must never retain a previous account's local-only draft.
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1"
)
localStorage.removeItem(key);
}
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const draft = JSON.parse(raw);
if (draft?.plan) {
remotePlanId = draft.remotePlanId || null;
draftSyncedAt = draft.syncedAt || null;
return draft.plan;
}
return draft; // legacy v2 draft: retain it once, then upgrade on next save
} catch { } catch {
return null; return null;
} }
} }
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
} catch {
/* unavailable storage */
}
storageKey = null;
remotePlanId = null;
draftSyncedAt = null;
}
function cultivarAutofill(name) { function cultivarAutofill(name) {
const row = findCultivar(name); const row = findCultivar(name);
if (!row) return; if (!row) return;
@@ -403,7 +599,13 @@ function cultivarAutofill(name) {
function wireCultivarDatalist() { function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list"); const list = document.getElementById("cultivar-list");
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join(""); list.replaceChildren(
...CULTIVARS.map((c) => {
const option = document.createElement("option");
option.value = c.name;
return option;
}),
);
} }
function wireForm() { function wireForm() {
@@ -411,7 +613,10 @@ function wireForm() {
const el = e.target; const el = e.target;
if (!el.name) return; if (!el.name) return;
if (el.type === "radio" && !el.checked) return; if (el.type === "radio" && !el.checked) return;
setValueForName(el.name, el.value); setValueForName(
el.name,
isTempField(el.name) ? tempToCanonical(el.value) : el.value,
);
if (el.name === "1.1") cultivarAutofill(el.value); if (el.name === "1.1") cultivarAutofill(el.value);
if (el.name === "2.1") updateBlendVisibility(el.value); if (el.name === "2.1") updateBlendVisibility(el.value);
if (el.name.startsWith("blendComponents.")) updateBlendTotal(); if (el.name.startsWith("blendComponents.")) updateBlendTotal();
@@ -419,35 +624,176 @@ function wireForm() {
}); });
} }
// Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after
// tempUnit changes. Field *values* are handled separately by renderFormFromPlan().
function updateTempUnitUI() {
const label = tempUnit === "F" ? "°F" : "°C";
for (const input of document.querySelectorAll(
'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]',
))
input.placeholder = label;
for (const span of document.querySelectorAll("#sec-machine .unit-input .unit"))
span.textContent = label;
const printHeader = document.getElementById("print-machine-temp-th");
if (printHeader) printHeader.textContent = label;
for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]'))
input.checked = input.value === tempUnit;
// Static reference numbers that live outside the plan (chart axes, band captions, the
// print worksheet's "Your logged band" column) — these are fixed constants, not plan
// data, so they're driven off data-tempc/data-band-lo/hi rather than valueForName().
const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
for (const el of document.querySelectorAll("[data-tempc]"))
el.textContent = String(conv(Number(el.dataset.tempc)));
for (const el of document.querySelectorAll("[data-band-lo]")) {
const lo = conv(Number(el.dataset.bandLo));
const hi = conv(Number(el.dataset.bandHi));
el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
}
const axisTitle = document.getElementById("print-curve-axis-title");
if (axisTitle) axisTitle.textContent = `BEAN TEMP ${label}`;
}
function setTempUnit(unit) {
tempUnit = unit === "F" ? "F" : "C";
localStorage.setItem(TEMP_UNIT_KEY, tempUnit);
updateTempUnitUI();
renderFormFromPlan();
recompute();
}
function wireTempUnitToggle() {
const toggle = document.getElementById("temp-unit-toggle");
if (!toggle) return;
toggle.addEventListener("change", (e) => {
if (e.target.name === "tempUnit") setTempUnit(e.target.value);
});
}
function wireDrawers() { function wireDrawers() {
const overlay = document.getElementById("drawer-overlay"); const overlay = document.getElementById("drawer-overlay");
const prefill = document.getElementById("panel-prefill"); const drawers = [...document.querySelectorAll(".drawer")];
const alog = document.getElementById("panel-alog");
function open(panel) {
for (const p of [prefill, alog]) p.classList.add("hidden");
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
}
function closeAll() { function closeAll() {
for (const p of [prefill, alog]) { for (const panel of drawers) {
panel.classList.add("hidden");
panel.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
function open(panel, opener) {
lastDrawerOpener = opener;
for (const p of drawers) {
p.classList.add("hidden"); p.classList.add("hidden");
p.setAttribute("aria-hidden", "true"); p.setAttribute("aria-hidden", "true");
} }
overlay.classList.add("hidden"); panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
panel.querySelector("button, input, [href]")?.focus();
} }
for (const [buttonId, panelId] of [
document.getElementById("btn-toggle-prefill").addEventListener("click", () => { ["nav-prefill", "panel-prefill"],
prefill.classList.contains("hidden") ? open(prefill) : closeAll(); ["nav-alog", "panel-alog"],
}); ["nav-plans", "panel-plans"],
document.getElementById("btn-toggle-alog").addEventListener("click", () => { ["nav-settings", "panel-settings"],
alog.classList.contains("hidden") ? open(alog) : closeAll(); ["nav-import-export", "panel-import-export"],
])
document
.getElementById(buttonId)
.addEventListener("click", (event) => {
document.getElementById("side-nav")?.classList.remove("mobile-open");
open(document.getElementById(panelId), event.currentTarget);
}); });
overlay.addEventListener("click", closeAll); overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) { for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll); btn.addEventListener("click", closeAll);
document.addEventListener("keydown", (event) => {
const activeDrawer = drawers.find(
(drawer) => !drawer.classList.contains("hidden"),
);
if (!activeDrawer) return;
if (event.key === "Escape") {
closeAll();
return;
} }
if (event.key !== "Tab") return;
const focusable = [
...activeDrawer.querySelectorAll(
"button:not([disabled]), input:not([disabled]), [href]",
),
];
const first = focusable[0],
last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
});
return { open, closeAll };
}
async function loadPlans() {
if (!navigator.onLine) return;
const response = await fetch("/api/plans");
if (!response.ok) return;
plans = (await response.json()).plans || [];
const list = document.getElementById("plan-list");
list.replaceChildren(
...plans.map((plan) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
item.append(button);
return item;
}),
);
}
async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
draftSyncedAt = plan.updated_at;
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
// already in the DOM — re-render the picker's own option list against the newly-loaded
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
// leave the dropdown showing the previous plan's selection or a blank state.
lotPicker?.renderOptions();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
remotePlanId = null;
draftSyncedAt = null;
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
lotPicker?.renderOptions();
} }
function wireToolbar() { function wireToolbar() {
@@ -457,14 +803,20 @@ function wireToolbar() {
}); });
document.getElementById("btn-save").addEventListener("click", () => { document.getElementById("btn-save").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(state.plan, null, 2)], {
type: "application/json",
});
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`; a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}); });
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-load").click()); document
.getElementById("btn-load")
.addEventListener("click", () =>
document.getElementById("file-load").click(),
);
document.getElementById("file-load").addEventListener("change", async (e) => { document.getElementById("file-load").addEventListener("change", async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -480,14 +832,35 @@ function wireToolbar() {
} }
}); });
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("btn-add-blend").addEventListener("click", () => { document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" }); state.plan.blendComponents.push({
cultivar: "",
group: "",
process: "",
sharePct: "",
fcAnchor: "",
});
renderBlend(); renderBlend();
renderFormFromPlan(); renderFormFromPlan();
recompute(); recompute();
}); });
document.getElementById("btn-logout").addEventListener("click", async () => {
try {
await protectedFetch("/api/auth/logout", { method: "POST" });
} finally {
clearDraft();
location.replace("/");
}
});
document.getElementById("btn-add-actuator").addEventListener("click", () => { document.getElementById("btn-add-actuator").addEventListener("click", () => {
state.plan.actuators.push({ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" }); state.plan.actuators.push({
time: "",
heatPct: "",
fanPct: "",
expectedBt: "",
why: "",
});
renderActuators(); renderActuators();
renderFormFromPlan(); renderFormFromPlan();
recompute(); recompute();
@@ -499,20 +872,62 @@ function wirePwa() {
const renderConnection = () => { const renderConnection = () => {
const offline = !navigator.onLine; const offline = !navigator.onLine;
status.classList.toggle("hidden", !offline); status.classList.toggle("hidden", !offline);
status.textContent = offline ? "Offline — changes continue saving on this device." : ""; status.textContent = offline
? "Offline — changes continue saving on this device."
: "";
}; };
window.addEventListener("online", renderConnection); window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection); window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
renderConnection(); renderConnection();
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
// A brand-new visitor has no controller yet, so the *first* activation firing
// "controllerchange" is not an update — only a page that was already controlled by a
// previous service worker should reload when a new one takes over.
const hadController = !!navigator.serviceWorker.controller;
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => { navigator.serviceWorker
console.warn("Service worker registration failed:", error); .register("/sw.js")
.then((registration) => {
const showUpdate = () =>
document.getElementById("btn-refresh").classList.remove("hidden");
if (registration.waiting) showUpdate();
registration.addEventListener("updatefound", () =>
registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller)
showUpdate();
}),
);
})
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
}); });
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (hadController) location.reload();
}); });
} }
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
} }
function wireSectionNav() { function wireSectionNav() {
@@ -527,7 +942,11 @@ function wireSectionNav() {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue; if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`; const id = `#${entry.target.id}`;
for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id); for (const a of links) {
const active = a.getAttribute("href") === id;
a.classList.toggle("active", active);
a.toggleAttribute("aria-current", active);
}
} }
}, },
{ rootMargin: "-20% 0px -70% 0px" }, { rootMargin: "-20% 0px -70% 0px" },
@@ -535,21 +954,140 @@ function wireSectionNav() {
for (const s of sections) observer.observe(s); for (const s of sections) observer.observe(s);
} }
function init() { async function init() {
try {
const meResponse = await fetch("/api/auth/me");
if (!meResponse.ok) {
location.replace("/");
return;
}
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("nav-user-avatar").textContent = user.email
.charAt(0)
.toUpperCase();
if (user.role === "admin")
document.getElementById("nav-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
// synced at or after the server's own updated_at — otherwise another device's newer
// edit (or a draft from before sync tracking existed) would be silently discarded.
const draftIsCurrent =
selected &&
draftRemoteId === selected.id &&
draftSyncedAtAtLoad &&
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected && !draftIsCurrent) {
state.plan = { ...blankPlan(), ...selected.plan };
remotePlanId = selected.id;
draftSyncedAt = selected.updated_at;
} else if (selected) {
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = { ...blankPlan(), ...plans[0].plan };
remotePlanId = plans[0].id;
draftSyncedAt = plans[0].updated_at;
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.
try {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken())
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch {
/* no local draft is available */
}
}
renderBlend(); renderBlend();
renderActuators(); renderActuators();
wireCultivarDatalist(); wireCultivarDatalist();
wireWhyPanels();
renderBandRanges(); renderBandRanges();
renderFormFromPlan(); renderFormFromPlan();
wireForm(); wireForm();
initSideNav();
wireDrawers(); wireDrawers();
wireToolbar(); wireToolbar();
wireTempUnitToggle();
updateTempUnitUI();
wirePwa(); wirePwa();
wireSectionNav(); wireSectionNav();
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute }); initPrefillPanel({
state,
renderBlendRows: renderBlend,
renderActuatorRows: renderActuators,
renderFormFromPlan,
recompute,
});
initAlogPanel({ state, recompute }); initAlogPanel({ state, recompute });
initPrint({ beforePrint: renderFormFromPlan }); initPrint({ beforePrint: renderFormFromPlan });
lotPicker = initLotPicker({
state,
recompute,
flushCurrentPlan,
getRemotePlanId: () => remotePlanId,
});
wireCuppingLink();
recompute(); recompute();
} }
function wireCuppingLink() {
const button = document.getElementById("btn-open-cupping");
const note = document.getElementById("cupping-link-note");
button.addEventListener("click", async () => {
button.disabled = true;
try {
if (!(await flushCurrentPlan())) {
note.textContent = "Could not sync the plan. Try again.";
return;
}
if (!remotePlanId) {
note.textContent = "Sync the plan first, then open a cupping session.";
return;
}
const existing = await protectedFetch(
`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`,
)
.then((r) => (r.ok ? r.json() : { sessions: [] }))
.catch(() => ({ sessions: [] }));
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
}
let created;
try {
created = await protectedFetch("/api/cupping", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ roastPlanId: remotePlanId }),
}).then((r) => r.json());
} catch {
note.textContent = "Could not reach the server. Try again.";
return;
}
if (created.session) location.assign(`/cupping?session=${created.session.id}`);
else note.textContent = "Could not start a cupping session.";
} finally {
button.disabled = false;
}
});
// Non-blocking: show whether a session already exists without forcing a sync first.
if (remotePlanId)
protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body?.sessions?.length)
note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`;
})
.catch(() => {});
}
init(); init();
+62
View File
@@ -0,0 +1,62 @@
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
// (planner, account, admin) so each page's own script doesn't reimplement it.
export function initSideNav() {
const nav = document.getElementById("side-nav");
const hamburger = document.getElementById("nav-hamburger");
const collapseBtn = document.getElementById("nav-collapse");
const workspace = document.getElementById("app-workspace");
const overlay = document.getElementById("drawer-overlay");
const COLLAPSE_KEY = "roastPlannerNavCollapsed";
function closeMobileNav() {
nav.classList.remove("mobile-open");
hamburger?.setAttribute("aria-expanded", "false");
if (!document.querySelector(".drawer:not(.hidden)"))
overlay?.classList.add("hidden");
}
hamburger?.addEventListener("click", () => {
const open = nav.classList.toggle("mobile-open");
hamburger.setAttribute("aria-expanded", String(open));
overlay?.classList.toggle("hidden", !open);
});
overlay?.addEventListener("click", closeMobileNav);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMobileNav();
});
function applyCollapsed(collapsed) {
nav.classList.toggle("collapsed", collapsed);
workspace?.classList.toggle("nav-collapsed", collapsed);
if (collapseBtn) {
collapseBtn.textContent = collapsed ? "»" : "«";
collapseBtn.setAttribute(
"aria-label",
collapsed ? "Expand navigation" : "Collapse navigation",
);
}
}
applyCollapsed(localStorage.getItem(COLLAPSE_KEY) === "true");
collapseBtn?.addEventListener("click", () => {
const next = !nav.classList.contains("collapsed");
applyCollapsed(next);
localStorage.setItem(COLLAPSE_KEY, String(next));
});
return { closeMobileNav };
}
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
export async function loadNavUser() {
const response = await fetch("/api/auth/me");
if (!response.ok) {
location.replace("/login");
return null;
}
const { user } = await response.json();
const emailEl = document.getElementById("account-email");
if (emailEl) emailEl.textContent = user.email;
const avatarEl = document.getElementById("nav-user-avatar");
if (avatarEl) avatarEl.textContent = user.email.charAt(0).toUpperCase();
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
return user;
}
+58 -47
View File
@@ -1,89 +1,100 @@
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty // URL prefill UI; output is always built with DOM nodes so remote text never becomes markup.
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches const csrf = () =>
// the form on any error. document.cookie
.split("; ")
.find((v) => v.startsWith("rp_csrf="))
?.split("=")[1] || "";
function message(target, text, error = false) {
const p = document.createElement("p");
p.textContent = text;
if (error) p.style.color = "#a8371a";
target.replaceChildren(p);
}
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) { export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
const urlInput = document.getElementById("prefill-url"); const urlInput = document.getElementById("prefill-url"),
const overwriteBox = document.getElementById("prefill-overwrite"); overwriteBox = document.getElementById("prefill-overwrite"),
const goBtn = document.getElementById("prefill-go"); goBtn = document.getElementById("prefill-go"),
const undoBtn = document.getElementById("prefill-undo"); undoBtn = document.getElementById("prefill-undo"),
const resultEl = document.getElementById("prefill-result"); resultEl = document.getElementById("prefill-result");
let snapshot = null; let snapshot = null;
goBtn.addEventListener("click", async () => { goBtn.addEventListener("click", async () => {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) return; if (!url) return;
goBtn.disabled = true; goBtn.disabled = true;
resultEl.innerHTML = `<p>Fetching…</p>`; message(resultEl, "Fetching…");
try { try {
const res = await fetch("/api/prefill", { const res = await fetch("/api/prefill", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "x-csrf-token": csrf() },
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });
const body = await res.json(); const body = await res.json();
if (!body.ok) { if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`; message(resultEl, body.error ?? body.code, true);
return; return;
} }
snapshot = structuredClone(state.plan);
snapshot = JSON.parse(JSON.stringify(state.plan));
const overwrite = overwriteBox.checked;
let applied = 0; let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) { for (const [id, value] of Object.entries(body.fields ?? {})) {
const current = state.plan.fields[id] ?? ""; if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "")
if (!overwrite && current !== "") continue; continue;
state.plan.fields[id] = value; state.plan.fields[id] = value;
applied++; applied++;
} }
renderFormFromPlan(); renderFormFromPlan();
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {}); markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
recompute(); recompute();
undoBtn.disabled = false; undoBtn.disabled = false;
const p = document.createElement("p"),
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join(""); link = document.createElement("a");
resultEl.innerHTML = ` link.href = body.source.finalUrl;
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p> link.target = "_blank";
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""} link.rel = "noopener";
`; link.textContent = body.source.finalUrl;
p.append(
`Applied ${applied} field${applied === 1 ? "" : "s"} from `,
link,
".",
);
const children = [p];
if (body.warnings?.length) {
const ul = document.createElement("ul");
ul.className = "warnings";
for (const warning of body.warnings) {
const li = document.createElement("li");
li.textContent = warning;
ul.append(li);
}
children.push(ul);
}
resultEl.replaceChildren(...children);
} catch (err) { } catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`; message(resultEl, err.message, true);
} finally { } finally {
goBtn.disabled = false; goBtn.disabled = false;
} }
}); });
undoBtn.addEventListener("click", () => { undoBtn.addEventListener("click", () => {
if (!snapshot) return; if (!snapshot) return;
state.plan = snapshot; state.plan = snapshot;
snapshot = null; snapshot = null;
undoBtn.disabled = true; undoBtn.disabled = true;
renderFormFromPlan(); renderFormFromPlan();
clearPrefilledMarks();
recompute();
resultEl.innerHTML = "<p>Prefill undone.</p>";
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add("prefilled");
const prov = provenance[id];
if (prov) el.title = `from: ${prov}`;
}
}
function clearPrefilledMarks() {
for (const el of document.querySelectorAll(".prefilled")) { for (const el of document.querySelectorAll(".prefilled")) {
el.classList.remove("prefilled"); el.classList.remove("prefilled");
el.removeAttribute("title"); el.removeAttribute("title");
} }
recompute();
message(resultEl, "Prefill undone.");
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (el) {
el.classList.add("prefilled");
if (provenance[id]) el.title = `from: ${provenance[id]}`;
}
}
} }
} }
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+51
View File
@@ -0,0 +1,51 @@
const form = document.querySelector("#reset-form");
const message = document.querySelector("#message");
const resetToken = new URLSearchParams(location.search).get("token") || "";
function showError(text) {
message.textContent = text;
message.className = "auth-message error";
}
if (!resetToken) {
form.classList.add("hidden");
showError(
"This reset link is missing its token. Request a new one from the forgot-password page.",
);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
if (data.password !== data.confirmPassword) {
showError("Passwords do not match.");
return;
}
const submitButton = form.querySelector("button[type=submit]");
submitButton.disabled = true;
message.textContent = "";
try {
const response = await fetch("/api/auth/reset", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: resetToken, password: data.password }),
});
const body = await response.json();
if (!response.ok) {
showError(
{
invalid_token:
"This reset link is invalid or has expired. Request a new one.",
account_disabled:
"Your password was updated, but this account is disabled. Contact an administrator.",
}[body.code] || body.error || "Could not reset the password.",
);
return;
}
location.assign("/app");
} catch {
showError("Could not reach the server. Check your connection and try again.");
} finally {
submitButton.disabled = false;
}
});
+37
View File
@@ -0,0 +1,37 @@
const form = document.querySelector("#setup-form");
const message = document.querySelector("#message");
function showError(text) {
message.textContent = text;
message.className = "auth-message error";
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const submitButton = form.querySelector("button[type=submit]");
submitButton.disabled = true;
message.textContent = "";
try {
const response = await fetch("/api/auth/bootstrap", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const body = await response.json();
if (!response.ok) {
showError(
{
bootstrap_used: "The administrator account already exists.",
invalid_setup_token: "That setup token is incorrect.",
bootstrap_unavailable: "Setup is not available in this deployment.",
}[body.code] || body.error || "Could not create the administrator.",
);
return;
}
location.assign("/app");
} catch {
showError("Could not reach the server. Check your connection and try again.");
} finally {
submitButton.disabled = false;
}
});
+69
View File
@@ -0,0 +1,69 @@
const form = document.querySelector("#signup-form");
const message = document.querySelector("#message");
const title = document.querySelector("#signup-title");
function showError(text) {
message.textContent = text;
message.className = "auth-message error";
}
function showInfo(text) {
message.textContent = text;
message.className = "auth-message info";
}
(async function checkSignupEnabled() {
try {
const response = await fetch("/api/auth/signup-enabled");
const body = await response.json();
if (body.enabled) {
form.classList.remove("hidden");
} else {
title.textContent = "Invite only";
showInfo(
"New signups are currently disabled. Ask an administrator for an invitation.",
);
}
} catch {
// Don't strand a new customer on a dead page over one failed probe — show the form and
// let the real submit enforce the signup-enabled rule if it turns out to matter.
form.classList.remove("hidden");
showError(
"Could not confirm signups are open. You can still try creating an account below.",
);
}
})();
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
if (data.password !== data.confirmPassword) {
showError("Passwords do not match.");
return;
}
const submitButton = form.querySelector("button[type=submit]");
submitButton.disabled = true;
message.textContent = "";
try {
const response = await fetch("/api/auth/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: data.email, password: data.password }),
});
const body = await response.json();
if (!response.ok) {
showError(
{
email_exists: "An account with that email already exists.",
signup_disabled:
"New signups are currently disabled. Ask an administrator for an invitation.",
}[body.code] || body.error || "Could not create the account.",
);
return;
}
location.assign("/app");
} catch {
showError("Could not reach the server. Check your connection and try again.");
} finally {
submitButton.disabled = false;
}
});
+18
View File
@@ -0,0 +1,18 @@
function stack() {
let el = document.querySelector(".toast-stack");
if (!el) {
el = document.createElement("div");
el.className = "toast-stack";
el.setAttribute("aria-live", "polite");
document.body.append(el);
}
return el;
}
export function showToast(text, type = "") {
const toast = document.createElement("div");
toast.className = `toast ${type}`.trim();
toast.textContent = text;
stack().append(toast);
setTimeout(() => toast.remove(), 4500);
}
+25
View File
@@ -0,0 +1,25 @@
// Shared teaching-layer collapse memory for every page that uses `.why-panel` (`<details>`
// with a `data-why` id): the planner and the cupping session view. One localStorage key across
// both so a user's dismissals carry over between pages.
const WHY_COLLAPSED_KEY = "roastPlannerWhyCollapsed.v1";
export function wireWhyPanels() {
let collapsed;
try {
collapsed = new Set(JSON.parse(localStorage.getItem(WHY_COLLAPSED_KEY)) || []);
} catch {
collapsed = new Set();
}
for (const panel of document.querySelectorAll(".why-panel")) {
if (collapsed.has(panel.dataset.why)) panel.open = false;
panel.addEventListener("toggle", () => {
if (panel.open) collapsed.delete(panel.dataset.why);
else collapsed.add(panel.dataset.why);
try {
localStorage.setItem(WHY_COLLAPSED_KEY, JSON.stringify([...collapsed]));
} catch {
/* unavailable storage */
}
});
}
}
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Roast Planner — Plan the roast before you light the burner</title>
<meta
name="description"
content="A structured worksheet that computes first crack, development, and drop from your bean — then keeps the plan next to what actually happened."
/>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#2A1D16" />
</head>
<body>
<header class="marketing-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<span>Roast Planner</span>
</div>
<nav>
<a class="ghost-btn" href="/login">Log in</a>
<a class="primary-btn" href="/signup">Get started</a>
</nav>
</header>
<section class="hero">
<div class="hero-inner">
<div>
<h1>Plan the roast before you light the burner.</h1>
<p class="lede">
A structured worksheet that computes first crack, development, and
drop from your bean — then keeps the plan next to what actually
happened.
</p>
<div class="hero-ctas">
<a class="primary-btn" href="/signup">Start planning — free</a>
<a class="ghost-btn" href="#how-it-works">See how it works</a>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="dock-card-head">
<h2>Time Ledger</h2>
</div>
<div class="ledger-row">
<span class="l-fid">1.4</span
><span class="l-label">First crack anchor</span
><span class="l-val">9:30</span>
</div>
<div class="ledger-row">
<span class="l-fid">5.6</span
><span class="l-label">Bean-condition refinement</span
><span class="l-val">+0:10</span>
</div>
<div class="ledger-row">
<span class="l-fid">4.3</span
><span class="l-label">Development base</span
><span class="l-val">2:15</span>
</div>
<div class="ledger-total drop">
<span>Drop time</span><output>11:55</output>
</div>
</div>
</div>
</section>
<section class="marketing-section" id="how-it-works">
<h2>Built around the worksheet, not a generic form</h2>
<p class="section-lede">
Every field ties back to the same time-ledger math professional
roasters already use on paper — now it computes itself.
</p>
<div class="feature-grid">
<div class="feature-card">
<div class="feature-icon" aria-hidden="true"></div>
<h3>The Time Ledger</h3>
<p>
Anchor first crack by cultivar, then let refinements, bean
condition, and batch size sum into a plan you can defend.
</p>
</div>
<div class="feature-card">
<div class="feature-icon" aria-hidden="true"></div>
<h3>Sanity checks</h3>
<p>
Drying, Maillard, and development ratios validated against known
bands before you roast.
</p>
</div>
<div class="feature-card">
<div class="feature-icon" aria-hidden="true"></div>
<h3>Plan vs. actual</h3>
<p>
Log milestones at the machine, attach an Artisan .alog reference
curve, and see plan against reality.
</p>
</div>
</div>
<div class="feature-row">
<div class="feature-row-item">
<span class="feature-icon" aria-hidden="true"></span
><span>Prefill straight from a bean product page URL</span>
</div>
<div class="feature-row-item">
<span class="feature-icon" aria-hidden="true">🖶</span
><span>Printable two-page worksheet, faithful to the original</span>
</div>
<div class="feature-row-item">
<span class="feature-icon" aria-hidden="true"></span
><span>Installable PWA that keeps working offline</span>
</div>
<div class="feature-row-item">
<span class="feature-icon" aria-hidden="true"></span
><span>Autosave with sync the moment you're back online</span>
</div>
</div>
</section>
<section class="marketing-section">
<h2>Three steps, every roast</h2>
<div class="steps-row">
<div class="step-item">
<h3>Describe the coffee</h3>
<p>
Cultivar, process, bean condition — the worksheet pulls in the
right reference numbers automatically.
</p>
</div>
<div class="step-item">
<h3>Review ledger &amp; checks</h3>
<p>
Watch first crack, development, and drop compute live, with every
ratio checked against known-good bands.
</p>
</div>
<div class="step-item">
<h3>Roast and record</h3>
<p>
Log milestones as you go, then compare against an Artisan
reference curve after the roast.
</p>
</div>
</div>
</section>
<section class="cta-band">
<h2>Plan your next roast properly.</h2>
<a class="primary-btn" href="/signup">Get started — it's free</a>
</section>
<footer class="marketing-footer">
<span>© Roast Planner</span>
<span
><a href="/login">Log in</a><a href="/signup">Sign up</a></span
>
</footer>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Log in — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong>Roast Planner</strong>
</div>
<h1>Log in</h1>
<div id="message" class="auth-message" role="alert"></div>
<form id="login-form">
<label
>Email
<input
class="field-input"
required
type="email"
name="email"
autocomplete="email"
/></label>
<label
>Password
<input
class="field-input"
required
type="password"
name="password"
autocomplete="current-password"
/></label>
<button class="primary-btn" type="submit">Log in</button>
</form>
<div class="auth-links">
<a href="/forgot">Forgot password?</a>
<a href="/signup">Create an account</a>
</div>
</section>
</main>
<script type="module" src="/js/login.js"></script>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "Roast Planner", "name": "Roast Planner",
"short_name": "Roast Planner", "short_name": "Roast Planner",
"description": "Plan, record, and compare coffee roasts.", "description": "Plan, record, and compare coffee roasts.",
"start_url": "/", "start_url": "/app",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#FBF8F4", "background_color": "#FBF8F4",
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Set a new password — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong>Roast Planner</strong>
</div>
<h1>Set a new password</h1>
<div id="message" class="auth-message" role="alert"></div>
<form id="reset-form">
<label
>New password
<input
class="field-input"
required
type="password"
minlength="12"
name="password"
autocomplete="new-password"
/></label>
<label
>Confirm password
<input
class="field-input"
required
type="password"
minlength="12"
name="confirmPassword"
autocomplete="new-password"
/></label>
<p class="muted">Use at least 12 characters.</p>
<button class="primary-btn" type="submit">Set password</button>
</form>
<div class="auth-links">
<a href="/login">Back to log in</a>
</div>
</section>
</main>
<script type="module" src="/js/reset.js"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Administrator setup — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong>Roast Planner</strong>
</div>
<h1>Create the administrator account</h1>
<p class="lede">
This runs once, before anyone else can sign up. You'll need the
setup token from the deployment environment.
</p>
<div id="message" class="auth-message" role="alert"></div>
<form id="setup-form">
<label
>Administrator email
<input
class="field-input"
required
type="email"
name="email"
value="[email protected]"
readonly
/></label>
<label
>Password
<input
class="field-input"
required
type="password"
minlength="12"
name="password"
autocomplete="new-password"
/></label>
<label
>Setup token
<input
class="field-input"
required
type="password"
name="setupToken"
autocomplete="off"
/></label>
<button class="primary-btn" type="submit">Create administrator</button>
</form>
<div class="auth-links">
<a href="/login">Back to log in</a>
</div>
</section>
</main>
<script type="module" src="/js/setup.js"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Create an account — Roast Planner</title>
<link rel="stylesheet" href="/app.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong>Roast Planner</strong>
</div>
<h1 id="signup-title">Create an account</h1>
<div id="message" class="auth-message" role="alert"></div>
<form id="signup-form" class="hidden">
<label
>Email
<input
class="field-input"
required
type="email"
name="email"
autocomplete="email"
/></label>
<label
>Password
<input
class="field-input"
required
type="password"
minlength="12"
name="password"
autocomplete="new-password"
/></label>
<label
>Confirm password
<input
class="field-input"
required
type="password"
minlength="12"
name="confirmPassword"
autocomplete="new-password"
/></label>
<p class="muted">Use at least 12 characters.</p>
<button class="primary-btn" type="submit">Create account</button>
</form>
<div class="auth-links">
<a href="/login">Already have an account?</a>
</div>
</section>
</main>
<script type="module" src="/js/signup.js"></script>
</body>
</html>
+53 -14
View File
@@ -1,12 +1,22 @@
const CACHE_NAME = "roast-planner-static-v1"; const CACHE_NAME = "roast-planner-static-v6";
// Only /app is precached as a navigable page: it is a data-free authenticated shell (draft
// data lives only in account-scoped local storage, and logout clears that namespace), so it is
// the one page that is both safe and useful to relaunch offline. The marketing page, auth
// pages, account, admin, inventory, and cupping all depend on the network (auth checks, live
// data) and are deliberately left off this list rather than cached in a way that could show
// stale content.
const APP_SHELL = [ const APP_SHELL = [
"/", "/app",
"/index.html",
"/app.css", "/app.css",
"/worksheet.css", "/worksheet.css",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon.svg", "/icon.svg",
"/js/main.js", "/js/main.js",
"/js/api.js",
"/js/nav.js",
"/js/toast.js",
"/js/why-panels.js",
"/js/lot-picker.js",
"/js/prefill-ui.js", "/js/prefill-ui.js",
"/js/alog-ui.js", "/js/alog-ui.js",
"/js/print.js", "/js/print.js",
@@ -15,16 +25,26 @@ const APP_SHELL = [
"/shared/time.js", "/shared/time.js",
"/shared/reference-data.js", "/shared/reference-data.js",
"/shared/curve.js", "/shared/curve.js",
"/shared/cupping.js",
]; ];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); event.waitUntil(
self.skipWaiting(); caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
),
); );
self.clients.claim(); self.clients.claim();
}); });
@@ -37,22 +57,41 @@ self.addEventListener("fetch", (event) => {
} catch { } catch {
return; return;
} }
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return; if (
request.method !== "GET" ||
url.origin !== self.location.origin ||
url.pathname.startsWith("/api/")
)
return;
if (request.mode === "navigate") { if (request.mode === "navigate") {
event.respondWith(fetch(request).catch(() => caches.match("/index.html"))); // Only /app has a meaningful offline shell (see APP_SHELL comment above); every other
// page requires the network anyway, so let those navigations fail normally when offline
// rather than risk serving stale auth/account/admin content.
if (url.pathname !== "/app") return;
event.respondWith(
fetch(request).catch(
async () => (await caches.match("/app")) || Response.error(),
),
);
return; return;
} }
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
// immediately; use the cache only when offline.
event.respondWith( event.respondWith(
caches.match(request).then((cached) => { fetch(request)
const update = fetch(request)
.then((response) => { .then((response) => {
if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone())); if (response.ok)
caches
.open(CACHE_NAME)
.then((cache) => cache.put(request, response.clone()));
return response; return response;
}) })
.catch(() => cached); .catch(() => caches.match(request)),
return cached ?? update;
}),
); );
}); });
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") self.skipWaiting();
});
+1635
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import pg from "pg";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function createDb(connectionString = process.env.DATABASE_URL) {
if (!connectionString) throw new Error("DATABASE_URL is required");
const pool = new pg.Pool({
connectionString,
max: 10,
ssl:
process.env.DATABASE_SSL === "true"
? { rejectUnauthorized: true }
: undefined,
});
return {
query: (...args) => pool.query(...args),
connect: () => pool.connect(),
close: () => pool.end(),
};
}
/** Apply each versioned SQL file once; failed migrations are not recorded. */
export async function migrate(db) {
await db.query(
"CREATE TABLE IF NOT EXISTS schema_migrations (filename text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())",
);
const directory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"db",
"migrations",
);
const files = (await fs.readdir(directory))
.filter((file) => file.endsWith(".sql"))
.sort();
for (const filename of files) {
if (
(
await db.query("SELECT 1 FROM schema_migrations WHERE filename=$1", [
filename,
])
).rowCount
)
continue;
const sql = await fs.readFile(path.join(directory, filename), "utf8");
await db.query("BEGIN");
try {
await db.query(sql);
await db.query("INSERT INTO schema_migrations(filename) VALUES($1)", [
filename,
]);
await db.query("COMMIT");
} catch (error) {
await db.query("ROLLBACK");
throw error;
}
}
}
+31
View File
@@ -13,12 +13,17 @@ const MAX_REDIRECTS = 5;
const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"]; const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"];
const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)"; const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)";
const MAX_EXTRACTED_CHARS = 40_000; const MAX_EXTRACTED_CHARS = 40_000;
const BROWSERLESS_URL = process.env.BROWSERLESS_URL?.replace(/\/$/, "");
function normalizeToUrl(input) { function normalizeToUrl(input) {
const trimmed = input.trim(); const trimmed = input.trim();
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed); const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
const candidate = hasScheme ? trimmed : `https://${trimmed}`; const candidate = hasScheme ? trimmed : `https://${trimmed}`;
try {
return new URL(candidate); return new URL(candidate);
} catch {
return null;
}
} }
function contentTypeOf(header) { function contentTypeOf(header) {
@@ -70,6 +75,9 @@ export async function fetchPageText(rawUrl) {
} catch { } catch {
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url"); throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
} }
if (!url) {
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
}
if (url.protocol !== "https:") { if (url.protocol !== "https:") {
throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected"); throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected");
} }
@@ -107,7 +115,15 @@ export async function fetchPageText(rawUrl) {
currentUrl = next; currentUrl = next;
continue; continue;
} }
// Some storefronts (including Shopify sites protected by Cloudflare) rate-limit
// server-to-server requests while allowing a normal browser. Use the operator's
// local Browserless service only as a narrow fallback; all existing size and
// extraction limits still apply below.
if (res.status === 429 && BROWSERLESS_URL) {
response = await fetchWithBrowserless(currentUrl, controller.signal);
} else {
response = res; response = res;
}
break; break;
} }
} finally { } finally {
@@ -136,6 +152,21 @@ export async function fetchPageText(rawUrl) {
}; };
} }
async function fetchWithBrowserless(url, signal) {
try {
const response = await fetch(`${BROWSERLESS_URL}/content`, {
method: "POST",
signal,
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: url.toString(), gotoOptions: { waitUntil: "networkidle2", timeout: TIMEOUT_MS } }),
});
if (!response.ok) throw new Error(`Browserless returned HTTP ${response.status}.`);
return response;
} catch (error) {
throw withCode(new Error(`Browser fallback failed for ${url}: ${error.message}`), "fetch_failed");
}
}
function withCode(err, code) { function withCode(err, code) {
err.code = code; err.code = code;
return err; return err;
+9 -74
View File
@@ -1,77 +1,12 @@
import express from "express";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { fetchPageText } from "./fetch-page.js"; import { createDb, migrate } from "./db.js";
import { runPrefill } from "./prefill.js"; import { createApp } from "./app.js";
import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT = path.join(__dirname, ".."); const port = Number(process.env.PORT) || 8090;
const PORT = Number(process.env.PORT) || 8090; const db = createDb();
await migrate(db);
const app = express(); createApp({ db, root }).listen(port, () =>
app.use(express.json({ limit: "2mb" })); console.log(`Roast planner listening on ${port}`),
);
app.use(express.static(path.join(ROOT, "public")));
app.use("/shared", express.static(path.join(ROOT, "shared")));
app.post("/api/prefill", async (req, res) => {
const url = typeof req.body?.url === "string" ? req.body.url.trim() : "";
if (!url) return res.status(400).json({ ok: false, code: "bad_url", error: "Missing url." });
let page;
try {
page = await fetchPageText(url);
} catch (err) {
const code = err.code ?? "fetch_failed";
const status = code === "fetch_timeout" ? 504 : code === "bad_url" ? 400 : 502;
return res.status(status).json({ ok: false, code, error: err.message });
}
try {
const result = await runPrefill(page);
res.json({ ok: true, ...result });
} catch (err) {
console.error("prefill failed:", err);
const code = err.code ?? "prefill_failed";
const status = code === "no_model" ? 503 : code === "unparseable_model_output" ? 422 : 500;
res.status(status).json({ ok: false, code, error: err.message });
}
});
app.post("/api/alog", (req, res) => {
const content = req.body?.content;
if (typeof content !== "string" || content.trim() === "") {
return res.status(400).json({ ok: false, code: "bad_request", error: "Missing .alog file content." });
}
try {
const result = parseAlog(content, req.body?.filename ?? "upload.alog");
res.json({ ok: true, ...result });
} catch (err) {
console.error("alog parse failed:", err);
res.status(422).json({ ok: false, code: "unparseable_alog", error: err.message });
}
});
app.get("/api/alog/library", async (_req, res) => {
try {
res.json({ ok: true, files: await listAlogLibrary() });
} catch (err) {
res.status(500).json({ ok: false, code: "library_failed", error: err.message });
}
});
app.get("/api/alog/library/:filename", async (req, res) => {
try {
const result = await readAlogFromLibrary(req.params.filename);
res.json({ ok: true, ...result });
} catch (err) {
const status = err.code === "not_found" ? 404 : err.code === "bad_filename" ? 400 : 422;
res.status(status).json({ ok: false, code: err.code ?? "library_read_failed", error: err.message });
}
});
app.listen(PORT, () => {
console.log(`Roast planner webapp listening on http://localhost:${PORT}`);
});
+9
View File
@@ -0,0 +1,9 @@
/** Sends transactional email via SMTP when configured; the caller decides what to do when this
* returns false (e.g. log the link so an admin can hand it to the user manually). */
export async function sendMail({ smtpUrl, from, to, subject, text }) {
if (!smtpUrl) return false;
const { default: nodemailer } = await import("nodemailer");
const transporter = nodemailer.createTransport(smtpUrl);
await transporter.sendMail({ from, to, subject, text });
return true;
}
+239
View File
@@ -0,0 +1,239 @@
// Browser-safe ESM (served at /shared/, imported by both server and client — no Node-only
// APIs) — the single implementation of cupping scoring/validation. The server always
// recomputes the total from coerceSession's output; a client-submitted total is never trusted.
//
// FLAVOR_TAXONOMY is an ORIGINAL taxonomy for this project: the family/subgroup/descriptor
// list and id strings are our own, deliberately not a transcription of the copyrighted
// SCA/WCR Coffee Taster's Flavor Wheel graphic or its exact category boundaries/wording.
export const SCORE_ATTRS = [
"fragrance_aroma",
"flavor",
"aftertaste",
"acidity",
"body",
"balance",
"overall",
];
export const SCORE_LABELS = {
fragrance_aroma: "Fragrance/Aroma",
flavor: "Flavor",
aftertaste: "Aftertaste",
acidity: "Acidity",
body: "Body",
balance: "Balance",
overall: "Overall",
};
export const TICK_ATTRS = ["uniformity", "clean_cup", "sweetness"];
export const TICK_LABELS = {
uniformity: "Uniformity",
clean_cup: "Clean cup",
sweetness: "Sweetness",
};
export const STAGE_IDS = [
"dry_fragrance",
"pour",
"crust_aroma",
"break",
"skim",
"taste_1",
"taste_2",
"taste_3",
];
const STAGE_ORDER = Object.fromEntries(STAGE_IDS.map((s, i) => [s, i]));
export const DEFAULT_CUP_COUNT = 5;
export const MAX_CUP_COUNT = 12;
export const MAX_FLAVOR_TAGS = 32;
export const MAX_NOTES_CHARS = 4000;
const MAX_STAGE_ELAPSED_SEC = 24 * 60 * 60;
// 1-3 dot-separated segments, lowercase alnum(+underscore) in EVERY segment — three family
// ids (nutty_cocoa, green_vegetal, fermented_sour) have an underscore in the first segment.
const FLAVOR_ID_RE = /^[a-z0-9_]+(\.[a-z0-9_]+){0,2}$/;
export const FLAVOR_TAXONOMY = {
fruity: {
label: "Fruity",
subgroups: {
berry: ["blueberry", "blackberry", "strawberry"],
citrus: ["orange", "lemon", "grapefruit"],
stone_fruit: ["peach", "apricot", "cherry"],
dried_fruit: ["raisin", "fig", "prune"],
},
},
floral: {
label: "Floral",
subgroups: {
blossom: ["jasmine", "orange_blossom", "chamomile"],
herbal_floral: ["lavender", "rose"],
},
},
sweet: {
label: "Sweet",
subgroups: {
sugars: ["brown_sugar", "honey", "caramel"],
vanilla: ["vanilla", "malt"],
},
},
nutty_cocoa: {
label: "Nutty / Cocoa",
subgroups: {
nutty: ["almond", "hazelnut", "peanut"],
cocoa: ["dark_chocolate", "cocoa_powder"],
},
},
spice: {
label: "Spice",
subgroups: {
warm_spice: ["cinnamon", "clove", "nutmeg"],
pungent: ["pepper", "anise"],
},
},
roasted: {
label: "Roasted",
subgroups: {
grain: ["toast", "cereal"],
char: ["smoky", "tobacco", "pipe_tobacco"],
},
},
green_vegetal: {
label: "Green / Vegetal",
subgroups: {
fresh: ["cut_grass", "leafy", "herbaceous"],
raw: ["beany", "peapod"],
},
},
fermented_sour: {
label: "Fermented / Sour",
subgroups: {
sour: ["citric", "acetic", "tart"],
fermented: ["winey", "boozy", "overripe"],
},
},
other: {
label: "Other",
subgroups: {
chemical: ["rubber", "medicinal"],
papery: ["papery", "musty", "woody"],
},
},
};
const snapQuarter = (v) => Math.round(v * 4) / 4;
export function blankSession(cupCount = DEFAULT_CUP_COUNT) {
return {
cup_count: cupCount,
scores: Object.fromEntries(SCORE_ATTRS.map((a) => [a, 0])),
ticks: Object.fromEntries(TICK_ATTRS.map((a) => [a, 0])),
taint_cups: 0,
fault_cups: 0,
flavor_tags: [],
stage_marks: [],
ritual_started_at_iso: "",
notes: "",
};
}
/** total = sum(scored attrs) + 10*ticks/cupCount per tick attr - 2*taint - 4*fault, floored
* at 0, rounded to 2dp. Pure arithmetic over whatever it's handed — call coerceSession first. */
export function computeTotalScore(scores, ticks, taintCups, faultCups, cupCount) {
let total = SCORE_ATTRS.reduce((sum, a) => sum + Number(scores?.[a] ?? 0), 0);
const cc = Number(cupCount);
if (cc > 0)
total += TICK_ATTRS.reduce((sum, a) => sum + (10 * Number(ticks?.[a] ?? 0)) / cc, 0);
total -= 2 * Number(taintCups ?? 0);
total -= 4 * Number(faultCups ?? 0);
total = Math.max(0, total);
return Math.round(total * 100) / 100;
}
/** Validates and clamps a session document. Throws Error with a human message on genuinely
* bad input (malformed flavor id, unknown stage, too many tags); everything else is clamped. */
export function coerceSession(session) {
const raw = session && typeof session === "object" ? session : {};
// Tolerant of a missing cup_count (defaults it), same stance as every other field below —
// but a *present and garbage* value (NaN, a string, etc.) is a genuine client bug, not an
// absent field, so that still throws rather than silently defaulting.
const cupCountRaw = raw.cup_count == null ? DEFAULT_CUP_COUNT : Number(raw.cup_count);
if (!Number.isFinite(cupCountRaw)) throw new Error("cup_count must be a finite number");
const cup_count = Math.max(1, Math.min(MAX_CUP_COUNT, Math.round(cupCountRaw)));
const scores = {};
for (const attr of SCORE_ATTRS) {
const f = Number(raw.scores?.[attr] ?? 0);
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
scores[attr] = f <= 0 ? 0 : Math.round(snapQuarter(Math.max(6, Math.min(10, f))) * 100) / 100;
}
const ticks = {};
for (const attr of TICK_ATTRS) {
const f = Number(raw.ticks?.[attr] ?? 0);
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
ticks[attr] = Math.max(0, Math.min(cup_count, Math.round(f)));
}
const taintRaw = Number(raw.taint_cups ?? 0);
if (!Number.isFinite(taintRaw)) throw new Error("taint_cups must be a finite number");
const taint_cups = Math.max(0, Math.min(cup_count, Math.round(taintRaw)));
const faultRaw = Number(raw.fault_cups ?? 0);
if (!Number.isFinite(faultRaw)) throw new Error("fault_cups must be a finite number");
const fault_cups = Math.max(0, Math.min(cup_count, Math.round(faultRaw)));
const notes = String(raw.notes ?? "").slice(0, MAX_NOTES_CHARS);
const flavor_tags = [];
const seen = new Set();
for (const tag of Array.isArray(raw.flavor_tags) ? raw.flavor_tags : []) {
if (typeof tag !== "string" || !FLAVOR_ID_RE.test(tag))
throw new Error(`invalid flavor tag id: ${JSON.stringify(tag)}`);
if (seen.has(tag)) continue;
seen.add(tag);
flavor_tags.push(tag);
}
if (flavor_tags.length > MAX_FLAVOR_TAGS)
throw new Error(
`at most ${MAX_FLAVOR_TAGS} flavor tags allowed, got ${flavor_tags.length} (after de-duplication)`,
);
const markByStage = {};
for (const mark of Array.isArray(raw.stage_marks) ? raw.stage_marks : []) {
const stage = mark?.stage;
// Object.hasOwn, not `in` — `in` walks the prototype chain, so a stage value of
// "toString"/"constructor"/etc. would pass validation and later corrupt the sort below
// (STAGE_ORDER["toString"] is a function, not a number).
if (!Object.hasOwn(STAGE_ORDER, stage))
throw new Error(`unknown cupping stage: ${JSON.stringify(stage)}`);
const elapsedRaw = Number(mark.elapsed_sec);
if (!Number.isFinite(elapsedRaw)) throw new Error("elapsed_sec must be a finite number");
const elapsed_sec = Math.max(0, Math.min(MAX_STAGE_ELAPSED_SEC, elapsedRaw));
markByStage[stage] = {
stage,
elapsed_sec,
marked_at_iso: String(mark.marked_at_iso ?? ""),
};
}
const stage_marks = Object.values(markByStage).sort(
(a, b) => STAGE_ORDER[a.stage] - STAGE_ORDER[b.stage],
);
return {
cup_count,
scores,
ticks,
taint_cups,
fault_cups,
notes,
flavor_tags,
stage_marks,
ritual_started_at_iso: String(raw.ritual_started_at_iso ?? ""),
};
}
+1
View File
@@ -55,6 +55,7 @@ export function blankPlan() {
sanityOverride: { drying: null, maillard: null, dtr: null, ceiling: null }, sanityOverride: { drying: null, maillard: null, dtr: null, ceiling: null },
reference: null, reference: null,
prefill: null, prefill: null,
inventory: { lotId: "", lotLabel: "", consumed: null },
}; };
} }
+590
View File
@@ -0,0 +1,590 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { createApp } from "../server/app.js";
import { root, password, setup, signup } from "./helpers.js";
test("strict CSP/static modules, no-store data, auth lifecycle, and ownership share one database", async () => {
const { db, app, agent: first } = await setup();
const second = request.agent(app);
const anonymous = request.agent(app);
const marketing = await anonymous.get("/");
assert.equal(marketing.status, 200);
assert.match(
marketing.headers["content-security-policy"],
/default-src 'self'/,
);
assert.doesNotMatch(
marketing.headers["content-security-policy"],
/(?:default-src|script-src)[^;]*unsafe-inline/,
);
assert.match(marketing.text, /Get started/);
const login = await anonymous.get("/login");
assert.equal(login.status, 200);
assert.match(
login.text,
/<script type="module" src="\/js\/login\.js"><\/script>/,
);
assert.equal((await anonymous.get("/signup")).status, 200);
assert.equal((await anonymous.get("/forgot")).status, 200);
assert.equal((await anonymous.get("/reset")).status, 200);
assert.equal((await anonymous.get("/api/auth/signup-enabled")).body.enabled, true);
// Page routes redirect a signed-out browser navigation to /login (never a bare JSON 401 —
// that's only for /api/* callers) but the API underneath stays strictly 401/no-store.
const adminHtml = await anonymous.get("/admin");
assert.equal(adminHtml.status, 302);
assert.equal(adminHtml.headers.location, "/login");
assert.equal(adminHtml.headers["cache-control"], "no-store, private");
const accountHtml = await anonymous.get("/account");
assert.equal(accountHtml.status, 302);
assert.equal(accountHtml.headers.location, "/login");
const appHtml = await anonymous.get("/app");
assert.equal(appHtml.status, 302);
assert.equal(appHtml.headers.location, "/login");
assert.equal((await anonymous.get("/api/plans")).status, 401);
assert.equal(
(await anonymous.get("/api/plans")).headers["cache-control"],
"no-store, private",
);
// Protected HTML shells are never reachable by static filename, only through their routes —
// including percent-encoded and repeated-slash variants that bypass an undecoded string
// comparison but still resolve to the same file once express.static decodes them.
assert.equal((await anonymous.get("/index.html")).status, 302);
assert.equal((await anonymous.get("/admin.html")).status, 302);
assert.equal((await anonymous.get("/account.html")).status, 302);
assert.equal((await anonymous.get("/%69ndex.html")).status, 302);
assert.equal((await anonymous.get("//index.html")).status, 302);
assert.match(
(await anonymous.get("/js/admin.js")).text,
/loadNavUser/,
);
const mainScript = await anonymous.get("/js/main.js");
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
const serviceWorker = (await anonymous.get("/sw.js")).text;
assert.match(serviceWorker, /data-free authenticated shell/);
assert.match(
serviceWorker,
/if \(url\.pathname !== "\/app"\) return;[\s\S]*caches\.match\("\/app"\)/,
);
assert.match(serviceWorker, /logout clears that namespace/);
const one = await signup(first, "[email protected]");
const two = await signup(second, "[email protected]");
assert.equal(one.response.status, 201);
assert.equal(two.response.status, 201);
// A signed-in visitor is bounced off the public auth pages straight to /app.
assert.equal((await first.get("/login")).status, 302);
assert.equal((await first.get("/")).status, 302);
const plan = await first
.post("/api/plans")
.set("x-csrf-token", one.csrf)
.send({ plan: { fields: { 0.1: "Private" } } });
assert.equal(plan.status, 201);
assert.equal(
(await first.get("/api/plans")).headers["cache-control"],
"no-store, private",
);
assert.equal(
(
await second
.put(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", two.csrf)
.send({ plan: {} })
).status,
404,
);
// A non-UUID id is a clean 404, not a 500 from the database driver.
assert.equal(
(
await first
.put("/api/plans/not-a-uuid")
.set("x-csrf-token", one.csrf)
.send({ plan: {} })
).status,
404,
);
// PUT validates the plan body just like POST does.
assert.equal(
(
await first
.put(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", one.csrf)
.send({ plan: null })
).status,
400,
);
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
// A signed-in non-admin visiting /admin lands back in the app, not a bare 403 JSON page.
const nonAdminVisitsAdmin = await second.get("/admin");
assert.equal(nonAdminVisitsAdmin.status, 302);
assert.equal(nonAdminVisitsAdmin.headers.location, "/app");
assert.equal(
(
await second
.delete(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", two.csrf)
).status,
404,
);
assert.equal(
(
await first
.delete(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", one.csrf)
).status,
200,
);
assert.equal((await first.get("/api/plans")).body.plans.length, 0);
const loginAgent = request.agent(app);
assert.equal(
(
await loginAgent
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
200,
);
assert.equal((await loginAgent.get("/api/auth/me")).status, 200);
// Logout succeeds even without a valid CSRF token (it can only end the caller's own session).
assert.equal((await loginAgent.post("/api/auth/logout")).status, 200);
assert.equal((await loginAgent.get("/api/auth/me")).status, 401);
const admin = await first.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(admin.status, 201);
assert.equal((await first.get("/api/admin/users")).status, 200);
assert.equal((await second.get("/api/admin/users")).status, 403);
assert.equal(
(
await first
.put("/api/admin/signup-enabled")
.set("x-csrf-token", admin.body.csrfToken)
.send({ enabled: false })
).status,
200,
);
assert.equal(
(
await anonymous
.post("/api/auth/signup")
.send({ email: "[email protected]", password })
).status,
403,
);
assert.equal(
(await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count,
3,
);
});
test("bootstrap token is optional after first setup and unavailable before setup without one", async () => {
const { app, db } = await setup();
const agent = request.agent(app);
assert.equal(
(
await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "wrong",
})
).status,
403,
);
const noTokenApp = createApp({ db, root, env: { NODE_ENV: "test" } });
assert.equal(
(
await request(noTokenApp).post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
})
).status,
503,
);
await db.query(
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')",
["[email protected]", "not-used-in-this-test"],
);
assert.equal(
(
await request(noTokenApp).post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
})
).status,
409,
);
});
test("/setup redirects to /login once the administrator exists", async () => {
const { app } = await setup();
const agent = request.agent(app);
assert.equal((await agent.get("/setup")).status, 200);
await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const fresh = request.agent(app);
const setupResponse = await fresh.get("/setup");
assert.equal(setupResponse.status, 302);
assert.equal(setupResponse.headers.location, "/login");
});
test("forgot/reset password issues a working link without leaking account existence", async () => {
const { app } = await setup();
const memberAgent = request.agent(app);
await signup(memberAgent, "[email protected]");
const admin = request.agent(app);
await admin.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const unknown = await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
assert.equal(unknown.status, 200);
const known = await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
assert.equal(known.status, 200);
assert.deepEqual(known.body, unknown.body);
const pending = await admin.get("/api/admin/password-resets");
assert.equal(pending.status, 200);
const entry = pending.body.links.find((l) => l.email === "[email protected]");
assert.ok(entry, "expected a pending reset link for [email protected]");
const token = new URL(entry.url, "http://x").searchParams.get("token");
assert.equal(
(
await request(app)
.post("/api/auth/reset")
.send({ token: "wrong-token", password: "another long password" })
).status,
400,
);
const resetResponse = await request(app)
.post("/api/auth/reset")
.send({ token, password: "another long password" });
assert.equal(resetResponse.status, 200);
// The old password no longer works; the new one does.
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password: "another long password" })
).status,
200,
);
});
test("account: email change, password change, sessions, and self-delete", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
assert.equal(
(
await agent
.put("/api/account/email")
.set("x-csrf-token", csrf)
.send({ email: "[email protected]", password: "wrong password wrong" })
).status,
401,
);
assert.equal(
(
await agent
.put("/api/account/email")
.set("x-csrf-token", csrf)
.send({ email: "[email protected]", password })
).status,
200,
);
const sessionsBefore = await agent.get("/api/account/sessions");
assert.equal(sessionsBefore.body.sessions.length, 1);
assert.equal(sessionsBefore.body.sessions[0].current, true);
assert.equal(
(
await agent
.put("/api/account/password")
.set("x-csrf-token", csrf)
.send({ currentPassword: password, newPassword: "yet another long one" })
).status,
200,
);
// Logging in with the old password now fails; the new one works.
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
const relogin = request.agent(app);
assert.equal(
(
await relogin
.post("/api/auth/login")
.send({ email: "[email protected]", password: "yet another long one" })
).status,
200,
);
assert.equal(
(
await agent
.delete("/api/account")
.set("x-csrf-token", csrf)
.send({ password: "yet another long one" })
).status,
200,
);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password: "yet another long one" })
).status,
401,
);
});
test("the bootstrap admin's email is reserved and fixed, and the bootstrap admin cannot delete themself even as a second admin exists", async () => {
const { app } = await setup();
// Nobody can claim the reserved email via plain signup before bootstrap ever runs.
assert.equal(
(
await request(app)
.post("/api/auth/signup")
.send({ email: "[email protected]", password })
).status,
409,
);
const bootstrapAgent = request.agent(app);
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(admin.status, 201);
// Someone else still can't claim it after bootstrap (this would also just 409 on the
// column's UNIQUE constraint, but the reserved-email check must fire first regardless).
const otherAgent = request.agent(app);
const { csrf: otherCsrf } = await signup(otherAgent, "[email protected]");
assert.equal(
(
await otherAgent
.put("/api/account/email")
.set("x-csrf-token", otherCsrf)
.send({ email: "[email protected]", password })
).status,
403,
);
// The bootstrap admin can't change away from the reserved email either — that would strip
// every other guard's protection for this account.
assert.equal(
(
await bootstrapAgent
.put("/api/account/email")
.set("x-csrf-token", admin.body.csrfToken)
.send({ email: "[email protected]", password })
).status,
403,
);
// Promote a second admin, then confirm the bootstrap admin still can't delete themself even
// though the "last admin" count check alone would otherwise allow it.
const secondAgent = request.agent(app);
await signup(secondAgent, "[email protected]");
const secondId = (
await bootstrapAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
await bootstrapAgent
.put(`/api/admin/users/${secondId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" });
assert.equal(
(
await bootstrapAgent
.delete("/api/account")
.set("x-csrf-token", admin.body.csrfToken)
.send({ password })
).status,
403,
);
assert.equal((await bootstrapAgent.get("/api/auth/me")).status, 200);
});
test("password-reset links for admin accounts never appear in the shared admin panel", async () => {
const { app } = await setup();
const bootstrapAgent = request.agent(app);
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const secondAgent = request.agent(app);
await signup(secondAgent, "[email protected]");
const secondId = (
await bootstrapAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
await bootstrapAgent
.put(`/api/admin/users/${secondId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" });
// The second admin requests a reset for the bootstrap admin's own account...
await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
// ...but that link must never surface in the panel any admin (including this one) can read —
// otherwise a second admin could take over the account every other guard protects.
const pending = await secondAgent.get("/api/admin/password-resets");
assert.equal(pending.status, 200);
assert.equal(
pending.body.links.some((l) => l.email === "[email protected]"),
false,
);
});
test("admin: metrics, role changes, disable, delete, and audit trail", async () => {
const { app } = await setup();
const adminAgent = request.agent(app);
const admin = await adminAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const userAgent = request.agent(app);
await signup(userAgent, "[email protected]");
const userId = (
await adminAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
const metrics = await adminAgent.get("/api/admin/metrics");
assert.equal(metrics.status, 200);
assert.equal(metrics.body.metrics.totalUsers, 2);
// The bootstrap admin cannot be demoted, disabled, or deleted by another admin, or by itself.
const bootstrapId = (
await adminAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${bootstrapId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "user" })
).status,
403,
);
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${userId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" })
).status,
200,
);
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${userId}/disabled`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ disabled: true })
).status,
200,
);
// A disabled user's existing session stops working and cannot log back in.
assert.equal((await userAgent.get("/api/auth/me")).status, 401);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
const audit = await adminAgent.get("/api/admin/audit");
assert.equal(audit.status, 200);
assert.ok(audit.body.events.some((e) => e.action === "role_changed"));
assert.ok(audit.body.events.some((e) => e.action === "user_disabled"));
assert.equal(
(
await adminAgent
.delete(`/api/admin/users/${userId}`)
.set("x-csrf-token", admin.body.csrfToken)
).status,
200,
);
assert.equal((await adminAgent.get("/api/admin/metrics")).body.metrics.totalUsers, 1);
});
test("login is locked out per-email after repeated failures, independent of source IP", async () => {
// Uses distinct simulated client IPs (via a trust-proxy app instance) so the assertion
// isolates the per-email lockout from the separate per-IP rate limiter.
const { db } = await setup();
const app = createApp({
db,
root,
env: {
NODE_ENV: "test",
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
TRUST_PROXY: true,
},
});
await signup(request.agent(app), "[email protected]");
for (let i = 0; i < 10; i++) {
await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", `10.0.0.${i}`)
.send({ email: "[email protected]", password: "wrong password wrong" });
}
// Further wrong guesses are locked out...
const stillWrong = await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", "10.0.0.99")
.send({ email: "[email protected]", password: "still not it either" });
assert.equal(stillWrong.status, 429);
assert.equal(stillWrong.body.code, "too_many_attempts");
// ...but the lock never blocks the real owner: the correct password always gets them in,
// so the lockout can only ever throttle guessing, never be weaponized to deny the owner.
const legitimateLogin = await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", "10.0.0.100")
.send({ email: "[email protected]", password });
assert.equal(legitimateLogin.status, 200);
});
+89
View File
@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const root = path.resolve(import.meta.dirname, "..");
async function dockerAvailable() {
try {
await execFileAsync("docker", ["info"], { timeout: 15_000 });
return true;
} catch {
return false;
}
}
test("Docker image starts against PostgreSQL and applies migrations", async (t) => {
if (!(await dockerAvailable())) {
t.skip("Docker daemon is unavailable");
return;
}
const temp = await mkdtemp(
path.join(os.tmpdir(), "roast-planner-container-"),
);
const agentDir = path.join(temp, "pi-agent");
await mkdir(agentDir);
const envFile = path.join(temp, "compose.env");
await writeFile(
envFile,
`POSTGRES_PASSWORD=container-test-password\nBOOTSTRAP_SETUP_TOKEN=\nPI_AGENT_CONFIG_DIR=${agentDir}\n`,
);
const project = `roastplanner${Date.now()}`;
const compose = (args, options = {}) =>
execFileAsync(
"docker",
["compose", "--project-name", project, "--env-file", envFile, ...args],
{ cwd: root, timeout: 120_000, ...options },
);
t.after(async () => {
try {
await compose(["down", "--volumes", "--remove-orphans"]);
} catch {
// Preserve the startup failure rather than masking it with cleanup.
}
});
await compose(["up", "--build", "--detach"]);
let lastError;
for (let attempt = 0; attempt < 30; attempt++) {
try {
await compose([
"exec",
"-T",
"app",
"node",
"--input-type=module",
"-e",
"const response = await fetch('http://127.0.0.1:8090/'); process.exit(response.ok ? 0 : 1)",
]);
lastError = null;
break;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
}
assert.equal(
lastError,
null,
"application never became ready in its container",
);
const migration = await compose([
"exec",
"-T",
"db",
"psql",
"-U",
"roast",
"-d",
"roast",
"-tAc",
"SELECT count(*) FROM schema_migrations WHERE filename = '001_auth.sql'",
]);
assert.equal(migration.stdout.trim(), "1");
});
+212
View File
@@ -0,0 +1,212 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
import { computeTotalScore, SCORE_ATTRS, TICK_ATTRS } from "../shared/cupping.js";
async function bootstrapPlan(agent, csrf) {
const r = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Test coffee" } } });
return r.body.plan.id;
}
test("cupping: auth and CSRF are required on every route", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/cupping")).status, 401);
assert.equal((await anon.post("/api/cupping").send({})).status, 401);
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
assert.equal((await agent.post("/api/cupping").send({})).status, 403);
const created = await agent.post("/api/cupping").set("x-csrf-token", csrf).send({});
assert.equal(created.status, 201);
assert.equal(
(await agent.put(`/api/cupping/${created.body.session.id}`).send({ data: {} })).status,
403,
);
assert.equal((await agent.delete(`/api/cupping/${created.body.session.id}`)).status, 403);
});
test("cupping: create (with and without a linked plan), list, and ownership isolation", async () => {
const { app } = await setup();
const first = request.agent(app);
const second = request.agent(app);
const { csrf: firstCsrf } = await signup(first, "[email protected]");
await signup(second, "[email protected]");
const planId = await bootstrapPlan(first, firstCsrf);
const linked = await first
.post("/api/cupping")
.set("x-csrf-token", firstCsrf)
.send({ roastPlanId: planId, cupCount: 4 });
assert.equal(linked.status, 201);
assert.equal(linked.body.session.roastPlanId, planId);
assert.equal(linked.body.session.data.cup_count, 4);
assert.equal(linked.body.session.totalScore, 0);
const unlinked = await first.post("/api/cupping").set("x-csrf-token", firstCsrf).send({});
assert.equal(unlinked.status, 201);
assert.equal(unlinked.body.session.roastPlanId, null);
assert.equal((await first.get("/api/cupping")).body.sessions.length, 2);
assert.equal((await second.get("/api/cupping")).body.sessions.length, 0);
assert.equal((await second.get(`/api/cupping/${linked.body.session.id}`)).status, 404);
const filtered = await first.get(`/api/cupping?plan=${planId}`);
assert.equal(filtered.body.sessions.length, 1);
assert.equal(filtered.body.sessions[0].id, linked.body.session.id);
});
test("cupping: creating against another user's plan is refused", async () => {
const { app } = await setup();
const owner = request.agent(app);
const attacker = request.agent(app);
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
const planId = await bootstrapPlan(owner, ownerCsrf);
const attempt = await attacker
.post("/api/cupping")
.set("x-csrf-token", attackerCsrf)
.send({ roastPlanId: planId });
assert.equal(attempt.status, 404);
});
test("cupping: server always recomputes the total score and ignores a client-supplied value", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 5 })
).body.session;
const scores = Object.fromEntries(SCORE_ATTRS.map((a) => [a, 8]));
const ticks = Object.fromEntries(TICK_ATTRS.map((a) => [a, 5]));
const expected = computeTotalScore(scores, ticks, 1, 1, 5);
const saved = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({
data: {
cup_count: 5,
scores,
ticks,
taint_cups: 1,
fault_cups: 1,
flavor_tags: ["fruity.berry.blueberry"],
notes: "Bright and clean.",
total_score: 999999, // must be discarded — the server recomputes it
},
});
assert.equal(saved.status, 200);
assert.equal(saved.body.session.totalScore, expected);
assert.notEqual(expected, 999999);
const reloaded = await agent.get(`/api/cupping/${session.id}`);
assert.equal(reloaded.body.session.totalScore, expected);
assert.deepEqual(reloaded.body.session.data.flavor_tags, ["fruity.berry.blueberry"]);
});
test("cupping: coerceSession validation errors surface as 400s", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (await agent.post("/api/cupping").set("x-csrf-token", csrf).send({})).body
.session;
const badFlavor = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: ["Not A Valid Id!"] } });
assert.equal(badFlavor.status, 400);
assert.equal(badFlavor.body.code, "bad_session");
const dedupedTags = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: Array.from({ length: 33 }, () => "other.chemical.rubber") } });
// 33 identical tags dedupe to 1, so this specific input should NOT throw — verifies dedup
// happens before the >32 check.
assert.equal(dedupedTags.status, 200);
assert.deepEqual(dedupedTags.body.session.data.flavor_tags, ["other.chemical.rubber"]);
// Build 33 genuinely distinct valid-shaped ids by walking the real taxonomy, to trip the
// actual >32-after-dedup limit.
const { FLAVOR_TAXONOMY } = await import("../shared/cupping.js");
const manyValid = [];
for (const [famId, fam] of Object.entries(FLAVOR_TAXONOMY)) {
for (const [subId, descriptors] of Object.entries(fam.subgroups)) {
for (const d of descriptors) manyValid.push(`${famId}.${subId}.${d}`);
}
}
assert.ok(manyValid.length > 32);
const overLimit = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: manyValid } });
assert.equal(overLimit.status, 400);
const badStage = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { stage_marks: [{ stage: "not_a_real_stage", elapsed_sec: 1 }] } });
assert.equal(badStage.status, 400);
const notObject = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: "nope" });
assert.equal(notObject.status, 400);
});
test("cupping: scores clamp/snap and ticks clamp to cup count", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
).body.session;
const saved = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({
data: {
cup_count: 3,
scores: { flavor: 11, acidity: 7.13, body: -1 },
ticks: { uniformity: 99 },
},
});
assert.equal(saved.status, 200);
assert.equal(saved.body.session.data.scores.flavor, 10);
assert.equal(saved.body.session.data.scores.acidity, 7.25);
assert.equal(saved.body.session.data.scores.body, 0);
assert.equal(saved.body.session.data.ticks.uniformity, 3);
});
test("cupping: delete removes the session, and deleting the linked plan nulls roastPlanId instead of deleting the session", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const planId = await bootstrapPlan(agent, csrf);
const session = (
await agent
.post("/api/cupping")
.set("x-csrf-token", csrf)
.send({ roastPlanId: planId })
).body.session;
await agent.delete(`/api/plans/${planId}`).set("x-csrf-token", csrf);
const afterPlanDelete = await agent.get(`/api/cupping/${session.id}`);
assert.equal(afterPlanDelete.status, 200);
assert.equal(afterPlanDelete.body.session.roastPlanId, null);
assert.equal(
(await agent.delete(`/api/cupping/${session.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
});
+52
View File
@@ -0,0 +1,52 @@
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import request from "supertest";
import { newDb } from "pg-mem";
import { createApp } from "../server/app.js";
export const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
export const password = "this is a long password";
export async function setup(env = {}) {
const mem = newDb();
mem.public.registerFunction({
name: "gen_random_uuid",
returns: "uuid",
implementation: () => crypto.randomUUID(),
impure: true,
});
const pg = mem.adapters.createPg();
const db = new pg.Pool();
await db.query(
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz);
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
INSERT INTO app_settings VALUES('signup_enabled','true');
CREATE TABLE password_reset_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now());
CREATE TABLE audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,action text NOT NULL,target text,created_at timestamptz DEFAULT now());
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now())`,
);
const app = createApp({
db,
root,
env: {
NODE_ENV: "test",
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
...env,
},
});
return { db, app, agent: request.agent(app) };
}
export async function signup(agent, email) {
const response = await agent.post("/api/auth/signup").send({ email, password });
return { response, csrf: response.body.csrfToken };
}
+198
View File
@@ -0,0 +1,198 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
async function bootstrapPlan(agent, csrf) {
const r = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Test coffee" } } });
return r.body.plan.id;
}
test("inventory: auth and CSRF are required on every route", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/inventory")).status, 401);
assert.equal((await anon.post("/api/inventory").send({})).status, 401);
const { agent, csrf } = await (async () => {
const a = request.agent(app);
const { csrf: c } = await signup(a, "[email protected]");
return { agent: a, csrf: c };
})();
assert.equal(
(await agent.post("/api/inventory").send({ origin: "X", initialWeightG: 100 })).status,
403,
);
const created = await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 });
assert.equal(created.status, 201);
assert.equal(
(await agent.put(`/api/inventory/${created.body.lot.id}`).send({ origin: "Y" })).status,
403,
);
assert.equal(
(await agent.delete(`/api/inventory/${created.body.lot.id}`)).status,
403,
);
assert.equal(
(
await agent
.post(`/api/inventory/${created.body.lot.id}/consume`)
.send({ weightG: 10 })
).status,
403,
);
});
test("inventory: create, list, and ownership isolation", async () => {
const { app } = await setup();
const first = request.agent(app);
const second = request.agent(app);
const { csrf: firstCsrf } = await signup(first, "[email protected]");
await signup(second, "[email protected]");
const bad = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "", initialWeightG: 100 });
assert.equal(bad.status, 400);
const badWeight = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "Huila", initialWeightG: -5 });
assert.equal(badWeight.status, 400);
const created = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "Huila, Colombia", variety: "Caturra", initialWeightG: 2000 });
assert.equal(created.status, 201);
assert.equal(created.body.lot.remainingWeightG, 2000);
assert.equal((await first.get("/api/inventory")).body.lots.length, 1);
assert.equal((await second.get("/api/inventory")).body.lots.length, 0);
assert.equal((await second.get(`/api/inventory/${created.body.lot.id}`)).status, 404);
});
test("inventory: consume decrements remaining, allows negative, is idempotent per plan, and logs", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const planId = await bootstrapPlan(agent, csrf);
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 300 })
).body.lot;
const first = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 250, roastPlanId: planId });
assert.equal(first.status, 201);
assert.equal(first.body.lot.remainingWeightG, 50);
// A second draw against the SAME plan is rejected — one draw-down per roast plan, ever.
const dupe = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 10, roastPlanId: planId });
assert.equal(dupe.status, 409);
assert.equal(dupe.body.code, "already_consumed");
assert.equal(
(await agent.get(`/api/inventory/${lot.id}`)).body.lot.remainingWeightG,
50,
"remaining must be unchanged after the rejected duplicate",
);
// A manual (plan-less) draw is unlimited and can push remaining negative — an honest
// signal of paperwork/shelf drift, not clamped.
const manual = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 100 });
assert.equal(manual.status, 201);
assert.equal(manual.body.lot.remainingWeightG, -50);
const zero = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 0 });
assert.equal(zero.status, 400);
const withLog = await agent.get(`/api/inventory/${lot.id}`);
assert.equal(withLog.body.log.length, 2);
// Newest first: the manual (plan-less) draw has no plan title; the earlier draw does.
assert.equal(withLog.body.log[0].roastPlanId, null);
assert.equal(withLog.body.log[0].planTitle, null);
assert.equal(withLog.body.log[1].roastPlanId, planId);
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
});
test("inventory: consuming against another user's plan is refused", async () => {
const { app } = await setup();
const owner = request.agent(app);
const attacker = request.agent(app);
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
const planId = await bootstrapPlan(owner, ownerCsrf);
const lot = (
await attacker
.post("/api/inventory")
.set("x-csrf-token", attackerCsrf)
.send({ origin: "Huila", initialWeightG: 500 })
).body.lot;
const consume = await attacker
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", attackerCsrf)
.send({ weightG: 50, roastPlanId: planId });
assert.equal(consume.status, 404);
});
test("inventory: edit never accepts remainingWeightG directly, but shifting initialWeightG shifts remaining by the delta", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 400 });
// Attempting to set remainingWeightG directly is silently ignored.
const sneaky = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ remainingWeightG: 999999 });
assert.equal(sneaky.body.lot.remainingWeightG, 600);
// Correcting the recorded initial weight (e.g. a scale error) shifts remaining by the delta.
const corrected = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ initialWeightG: 1100 });
assert.equal(corrected.body.lot.initialWeightG, 1100);
assert.equal(corrected.body.lot.remainingWeightG, 700);
const archived = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ archived: true });
assert.equal(archived.body.lot.archived, true);
assert.equal(
(await agent.delete(`/api/inventory/${lot.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal((await agent.get(`/api/inventory/${lot.id}`)).status, 404);
});