Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
eb402c7
docs(nutrition): E16 stories, Phases 7-9 beta track, mockups 38-42
lemmy-winks Jul 21, 2026
5706a3a
docs(nutrition): plate-art visuals, done-when steps, cook mode (E16.10)
lemmy-winks Jul 21, 2026
146e302
docs(nutrition): recipe sourcing — import-on-ask from BBC Good Food +…
lemmy-winks Jul 21, 2026
fa2cc7c
feat(food): Phase 7 server slice - schema, seed, routes (E16.2/4/8)
lemmy-winks Jul 21, 2026
be9d952
feat(food): Phase 7 frontend - Food tab, plate art, cook mode (E16.4/10)
lemmy-winks Jul 21, 2026
8c4a1fa
docs(nutrition): Phase 7 as-built note
lemmy-winks Jul 21, 2026
689da2a
ci: publish :beta image from the beta branch
lemmy-winks Jul 21, 2026
9b7eae2
fix(food): six bugs from Phase 7 review
lemmy-winks Jul 21, 2026
8434583
fix(ui): responsive guards — no horizontal pan, no overhanging boxes
lemmy-winks Jul 21, 2026
2282801
feat(food): Phase 8 first slice — coach ↔ meal planning (E16.3/16.5)
lemmy-winks Jul 21, 2026
45e6289
Merge main into beta (session fixes, metric drill-down, brighter text…
lemmy-winks Jul 21, 2026
f4f76bc
Merge main into beta: multi-line chat input, suggestion chips step aside
lemmy-winks Jul 21, 2026
5d62dbf
Merge main into beta: tuned reference bands, effort slider, hold get-…
lemmy-winks Jul 21, 2026
ad1ab7e
Calendar-week paging for Plan + Food, paginated History with activity…
claude Jul 21, 2026
8591f9d
Merge #1: calendar-week paging, paginated History, h/m durations
lemmy-winks Jul 21, 2026
4ffb4b1
Track the full nutrition-label macro set on meals
claude Jul 21, 2026
60e3fe9
Merge pull request #3 from lemmy-winks/claude/meals-macro-tracking-ex…
lemmy-winks Jul 21, 2026
7abd6bd
feat(mcp): external MCP food surface — eaten-out logging + recipe imp…
lemmy-winks Jul 22, 2026
28831c4
feat: link-preview metadata, coach day-note quality gates, inline Pro…
lemmy-winks Jul 22, 2026
1fb60f1
fix(welcome): serve /welcome pretty URL; expand the landing page
lemmy-winks Jul 22, 2026
2281b10
Merge remote-tracking branch 'origin/main' into beta
lemmy-winks Jul 22, 2026
7dca737
feat(demo): full food story for the demo seat + enrich path for exist…
lemmy-winks Jul 22, 2026
644f6cd
feat: serve RFC 9116 security.txt (Cloudflare check)
lemmy-winks Jul 22, 2026
3596617
chore: never name the public host in tracked files
lemmy-winks Jul 22, 2026
ac28f58
feat(runs): real map under the route — Leaflet + CARTO, dark/light aware
lemmy-winks Jul 22, 2026
7530c55
fix(pwa): dead space under the tab bar on cold iOS launch
lemmy-winks Jul 22, 2026
bd71e81
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
44dc8c3
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
cc6174a
fix(ui): run map breathes at desktop width (responsive sweep of beta …
lemmy-winks Jul 22, 2026
f212c2a
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
0fd9567
fix(map): run route stroke follows the accent palette
lemmy-winks Jul 22, 2026
cbaac48
feat(ui): immersive desktop shell + accent picker moves to a sub-screen
lemmy-winks Jul 22, 2026
df0ce35
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
43f6819
fix(coach): weekly note reads right — escape repair + present-voice gate
lemmy-winks Jul 22, 2026
21962e7
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
bb24eef
tune(ui): mute the accent palettes — professional over neon
lemmy-winks Jul 22, 2026
d0f4742
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
1d7b9b7
feat(plan): swipe between days in the day view
lemmy-winks Jul 22, 2026
2aad008
Merge branch 'main' into beta
lemmy-winks Jul 22, 2026
cb1370b
Merge main into beta
claude Jul 22, 2026
ad761fe
Merge pull request #9 from lemmy-winks/claude/merge-main-to-beta-x2q7af
lemmy-winks Jul 22, 2026
e66c5d3
Merge week-swipe + future planning into beta
claude Jul 22, 2026
c7c6310
Merge pull request #13 from lemmy-winks/claude/week-swipe-future-plan…
lemmy-winks Jul 22, 2026
e600a74
Merge remote-tracking branch 'origin/main' into claude/merge-main-to-…
claude Jul 22, 2026
2576970
Merge pull request #16 from lemmy-winks/claude/merge-main-to-beta-mhy06x
lemmy-winks Jul 22, 2026
f4d9f67
Merge finger-tracked week swipe into beta
claude Jul 22, 2026
35ff074
Merge pull request #18 from lemmy-winks/claude/week-swipe-future-plan…
lemmy-winks Jul 22, 2026
da89bb0
feat(mcp): connect-Claude-Desktop card in Settings → Connections
lemmy-winks Jul 23, 2026
530b5a4
feat(mcp): OAuth 2.1 — add Forge to Claude from the connector UI
lemmy-winks Jul 23, 2026
0e008c5
Merge origin/beta — OAuth MCP joins multi-domain serving
lemmy-winks Jul 23, 2026
a023763
chore: MIT license
lemmy-winks Jul 23, 2026
13bd4c5
feat(food): in-app recipe library + importer grows the ingredient ref…
lemmy-winks Jul 23, 2026
84d7f47
feat(mcp): pantry maintenance tools + expanded trusted-source ingredi…
lemmy-winks Jul 23, 2026
789cfd0
feat(food+settings): recipe planning, servings scaling, parallel cook…
lemmy-winks Jul 23, 2026
8aaddf5
fix(food)+feat: coach meal replace, sleep-stage sync, compact macros,…
lemmy-winks Jul 23, 2026
ec58d32
fix(food): show required amount for pantry ingredients
lemmy-winks Jul 23, 2026
021d5de
fix(food): plated hero image, one-time 'cook something else', cook-mo…
lemmy-winks Jul 23, 2026
4608e30
feat(food): dense macro grid on the day view
lemmy-winks Jul 23, 2026
ab901df
feat(food): MCP-populated recipe library — SEED_RECIPES flag + admin …
lemmy-winks Jul 23, 2026
1849e16
feat(design): restore card language — cards over hairline rows
lemmy-winks Jul 23, 2026
19eb6f6
feat(mcp): add delete_recipe tool to remove library recipes
claude Jul 25, 2026
49cb2ef
Merge pull request #22 from lemmy-winks/claude/remove-sample-recipes-…
lemmy-winks Jul 25, 2026
b4778bd
Merge main into beta
claude Jul 25, 2026
1053e29
Merge pull request #28 from lemmy-winks/claude/merge-main-to-beta-4bipdy
lemmy-winks Jul 25, 2026
70e7ad1
feat(food): show real recipe/meal photos in food lists, icon as fallback
Jul 25, 2026
793b9b1
style(food): circular meal-row thumbnails
Jul 25, 2026
245b0b6
fix(ingest): fall back to inBed when Watch sleep stages are all zero
Jul 25, 2026
eb3efd9
style(food): bigger rounded-square media tiles, not tiny circles
Jul 26, 2026
9ab0417
Merge beta into main — nutrition track joins the trunk
lemmy-winks Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: Publish image

# main → :latest (the stable stack) · beta → :beta (the nutrition beta stack).
# The branch name IS the tag except main, so the mapping needs no per-branch jobs.
on:
push:
branches: [main]
branches: [main, beta]

permissions:
contents: read
Expand All @@ -26,6 +28,6 @@ jobs:
file: server/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository_owner }}/forge:latest
tags: ghcr.io/${{ github.repository_owner }}/forge:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ ios/**/xcuserdata/
ios/**/*.xcuserstate
ios/DerivedData/
ios/**/.build/
# the public host never lands in a tracked file — see ServerDefaults.example.plist
ios/**/ServerDefaults.plist
*.p8
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ live in `docs/`.
- `ios/` — native SwiftUI app (started Jul 2026, in progress). One app, three modules: Train / Cook / Shop. Design decisions in `docs/native-app-design.md`, setup + phases (N0–N5) in `docs/ios-implementation-plan.md`, iOS-session working rules in `ios/CLAUDE.md`. The server is unchanged by it — the app is another API client. The PWA stays deployed as the fallback and the desktop dashboard, so any API change is checked against both clients.
- `web/` — React 19 + Vite 7 + TypeScript PWA. TanStack Query for server state; IndexedDB offline set queue in `src/queue.ts`; hand-written `src/styles.css` IS the design system — no Tailwind, keep it that way.
- Coach: server-side Claude tool-use loop; tools call route handlers directly with `(user=user, db=db)`. `propose_revision` validates content and creates `status='proposed'` revisions the user approves in-app (auto-apply only when `prefs.coach_approval == 'auto'`). Sunday 20:00 Europe/London review scheduler in `main.py` lifespan; `agent_runs` logs token spend. Model via `COACH_MODEL` (default claude-sonnet-5).
- `/api/week` is a calendar week Mon–Sun: pass any `date` and the server snaps to that week's Monday; the response carries `today` so clients mark the current day and flag past "missed" days. The Plan screen pages weeks with prev/next (capped one week ahead); the current week auto-focuses today. Sessions can pull any strength plan day onto today's date (`plan_day` override on POST /api/sessions). `/api/history` is offset-paginated (`limit`≤100/`offset`) and takes `favorites=true` to return only starred sessions. Session management: `DELETE /api/sessions/{id}` discards a session (drops its sets + cardio series, leaves all-time records — same simplification as edit_set) — used by the unfinished-workout sheet's "Discard" (a `ConfirmSheet` in `ui.tsx`) and History's swipe-to-delete (`data.tsx::SwipeRow`, pointer-events + `touch-action:pan-y`); `PATCH /api/sessions/{id}/favorite` toggles the `WorkoutSession.favorite` star (additive column, migrated in `main.py` lifespan since create_all never alters tables). Destructive actions always route through `ConfirmSheet` (warm `--warn` CTA, never a second accent).
- `/api/week` (and `/api/food/week`) is a calendar week Mon–Sun: pass any `date` and the server snaps to that week's Monday; the response carries `today` so clients mark the current day and flag past "missed" days. The Plan screen pages weeks with a finger-tracked swipe (three-pane prev/cur/next track in `today.tsx`, `touch-action:pan-y`, prev/next buttons reuse the eased slide; forward cap four weeks) — Food still pages with prev/next capped one week ahead; the current week auto-focuses today. Forward planning: `planned_items` (`POST/PATCH/DELETE /api/plan-items`, surfaced per-day as `planned` in `/api/week`) pencils user-authored workouts and meals onto future dates — the Plan screen renders them as pills with a per-day sheet. Sessions can pull any strength plan day onto today's date (`plan_day` override on POST /api/sessions). `/api/history` is offset-paginated (`limit`≤100/`offset`) and takes `favorites=true` to return only starred sessions. Session management: `DELETE /api/sessions/{id}` discards a session (drops its sets + cardio series, leaves all-time records — same simplification as edit_set) — used by the unfinished-workout sheet's "Discard" (a `ConfirmSheet` in `ui.tsx`) and History's swipe-to-delete (`data.tsx::SwipeRow`, pointer-events + `touch-action:pan-y`); `PATCH /api/sessions/{id}/favorite` toggles the `WorkoutSession.favorite` star (additive column, migrated in `main.py` lifespan since create_all never alters tables). Destructive actions always route through `ConfirmSheet` (warm `--warn` CTA, never a second accent).
- Phase 4: Withings OAuth+webhooks in `routers/withings.py` (`WITHINGS_CLIENT_ID/SECRET`; webhook needs public ingress). `/api/withings/connect` answers with a **302** to the authorize URL and the button navigates to it directly — never convert this back to fetch-then-`location.href`: a direct navigation to `account.withings.com` triggers the iOS Withings app's universal link and kills the OAuth flow; server-side redirects don't. Ingested Watch workouts reconcile against the planned cardio day (`ingest.py::_match_prescription`) — matched → `status='completed'` with target-vs-actual + `pct_in_zone`; unmatched stay `unplanned`. Zone-2 weekly minutes use the fixed 110–145 band (`ZONE2_BAND`); the per-plan prescription band only drives `pct_in_zone`. Desktop dashboard: `/dashboard` route (same SPA bundle) ← `/api/dashboard`.
- Run detail (E5.4): HR + GPS series live in `workout_series` (one row per cardio session, downsampled at ingest — a separate table on purpose: create_all can't add columns, and history queries never load the blobs). Re-ingesting an existing day backfills series instead of skipping (`ingest._attach_series`), so HAE manual exports enrich old runs. `/api/sessions/{id}` adds `series` + a five-zone breakdown (`training._zone_breakdown`: `prefs.hr_max`, else estimated and flagged). The route renders on a MapLibre GL basemap (Jul 2026) when a MapTiler key is set (`MAPTILER_KEY`, admin-managed via Settings → Server → Maps; `/api/map/config` hands the publishable key to signed-in clients); styles are built at runtime from the live design tokens in `web/src/routemap.tsx::forgeStyle` so both themes carry through — hosted MapTiler tiles only, never a second tile provider. maplibre-gl is lazy-loaded on the run-detail route and excluded from the PWA precache (`globIgnores` in vite.config); tiles cache via workbox runtime caching (capped, 30 days). The self-contained SVG trace remains and is the automatic fallback — no key, offline, or tile errors — so a run always shows its route. Demo runs get generated Cherry Orchard (Dublin) loops (`demo._run_series`).
- External MCP (beta): `/mcp` in `routers/mcp_food.py` — a hand-rolled **stateless** Streamable-HTTP JSON-RPC endpoint (single JSON responses, no SSE/sessions, no SDK dependency — keep it that way, it's what makes it testable in the sqlite suite). Auth = the per-user ingest token as `Authorization: Bearer` (same token as `/ingest`; Settings → Connections). Ten tools: six food — `log_food` (eaten-now with venue/cost/currency/photos, `client_id` idempotent, slot inferred from `coach_tz` time when omitted), `get_food_log`, `delete_food_log`, `import_recipe`, `search_recipes`, `get_recipe` — plus four pantry tools that maintain the canonical `ingredients` reference (per-100g macros/aisle/pantry-flag): `list_ingredients` (read, demo-visible), `bulk_import_ingredients` (upsert-by-name, `overwrite` default true, the bulk path), `update_ingredient` (partial patch by name), `delete_ingredient` (reports recipe references). All ingredient writes reject the demo seat and share `_apply_ingredient_fields`/`_new_ingredient` with `import_recipe`'s inline creation. The seeded default pantry (`food_seed.INGREDIENTS`, ~100 items, USDA/McCance per-100g values, insert-missing every boot) is the trusted-source baseline these tools extend. Import rules: `source_url` is the import's identity (re-import updates in place), seed recipes are write-protected, macros are per-serving, steps must be rewritten in done-when voice (never verbatim source prose), and unknown ingredients / difficulty `hard` / zero kcal park the recipe `complete=0` (browsable, never proposable) — except an unknown ingredient supplied with per-100g reference macros (`kcal_100` is the gate, plus optional `aisle`/`pantry`) is added to the canonical `ingredients` table instead of parking. The library is browsable in-app: `GET /api/food/recipes` (everything incl. parked, `q`/`kind` params, shares `food.query_recipes` with the MCP search tool) ← Food → "Recipes ›" (`food.tsx::RecipeLibraryScreen`, client-side as-you-type filter, parked entries badged warm; recipe detail's Back honours `foodFrom`). Recipe seeding is opt-out: `SEED_RECIPES=false` stops the boot from seeding the curated recipes + first food week (the pantry/ingredient reference still seeds so imports complete); `DELETE /api/admin/recipes` (Settings → Server → Recipe library, ConfirmSheet) wipes the library and retires the shared food week for an MCP-populated setup — meal logs and ingredients are kept. Images (recipe heroes, step photos, meal photos) are materialized into `media_blobs` by `app/media.py` (data: URIs or URL fetch, 3 MB cap, keep-remote-URL fallback, deduped by `src_url`) and served at `/api/food/media/{id}` — recipe imagery is household-shared (`user_id` NULL), meal photos are private to their owner. The demo seat can log its own meals but can never write the shared recipe library.
- Responsive shell (Jul 2026): one DOM, CSS-only adaptation in the "responsive" block at the end of `styles.css` — ≤380px compact type/padding, ≥640px wider bordered column, ≥900px (AND min-height 520px, so landscape phones keep bottom tabs) the tab bar becomes a fixed 88px left rail, all shell bands (`.app > *:not(.tabs)`) cap to a 700px centered column, sheets center as dialogs. Signed-out screens have no rail via `.app:has(> .tabs)`. New footer-bar or shell-band elements are capped automatically — never position screen chrome outside the `.app` children pattern.
- Web Push: `notify.send_push` is the only send path and hard-rejects kinds outside proposal/reminder (the filming kind was retired with the media pipeline — form media is curated free-exercise-db photos in `web/public/media/exercises/`, wired by `seed.MEDIA_SLUGS`); reminders are once-per-day via the `notification_log` unique constraint, window `REMINDER_HOUR`(16)–`QUIET_END`(21). VAPID keys via `python -m app.vapid` → compose environment; push is silently off until set. SW push handler lives in `web/public/push-listener.js` (workbox `importScripts`).

## Invariants — do not break

- Fitting: the priority-1 main lift is never trimmed; accessories trim first down to `min_sets`; cooldown shortens 5→2 min but is never dropped.
- Proposals: known exercise slugs only; exactly one priority-1 main lift per strength day; non-empty mobility-only cooldown; `content.changes` delta list and per-day `why` one-liners required (the UI renders them).
- Every query is scoped by `user_id` — James and Shelby must never see each other's data (test_user_segregation guards this). The optional demo user (`app/demo.py`, Bruce Willis, `role='demo'`) rides the same scoping; it is excluded from the two-seat cap, admin user list, dev sign-in buttons and the Sunday scheduler, and is created/reset/removed only via `/api/admin/demo` (`test_demo_cannot_reach_member_data` probes API, chat context, coach tools, and admin). Known exposure: the demo's coach runs on the real API key with no rate limit until Phase 6. Household-shared equipment profiles (`user_id IS NULL`) are visible to every user including the demo.
- Every query is scoped by `user_id` — James and Shelby must never see each other's data (test_user_segregation guards this). The optional demo user (`app/demo.py`, Bruce Willis, `role='demo'`) rides the same scoping; it is excluded from the two-seat cap, admin user list, dev sign-in buttons and the Sunday scheduler, and is created/reset/removed only via `/api/admin/demo` (`/api/admin/demo/enrich` tops up an existing demo with newer-feature data — currently the food beta — without resetting training history; `demo.enrich_demo` is idempotent per block and a full reset runs it too) (`test_demo_cannot_reach_member_data` probes API, chat context, coach tools, and admin). Known exposure: the demo's coach runs on the real API key with no rate limit until Phase 6. Household-shared equipment profiles (`user_id IS NULL`) are visible to every user including the demo.
- Ingest is idempotent on `(user_id, type, ts, source)` and must honor the payload's `units` field — Health Auto Export sends pounds.
- Secrets never touch tracked files: the committed `docker-compose.yml` carries `change-me` placeholders only; real values live in `docker-compose.override.yml` (chmod 600, gitignored), the Portainer stack editor, or the `app_settings` table (admin-managed via Settings → Server → `routers/admin.py`; `config.apply_overrides`/`set_override` mutate the cached settings singleton so changes apply live, and DB beats env). Admin API responses mask secrets to a 4-char tail — `ANTHROPIC_API_KEY` never reaches the frontend. Google OAuth is compose-only by design (needed before login). `ALLOWED_USERS` only seeds an empty users table; after that the users table IS the allowlist. Mask tokens when displaying them; rotating an ingest token invalidates the old one instantly.
- Design system "Void × Volt": bg `#060708`, raised `#121316`, hairlines `#191b1f`, ONE accent at a time via the `--volt` token family (actions, trends, positive status — volt lime `#bce53a` dark / moss `#5c7d0a` light), warm `#e8a360` for warnings only. Volt is fixed — there is no accent-palette picker (removed Jul 2026); the dark volt was eased ~15% off `#c9f73a` because that peak-luminance lime bloomed against the void and dimmed the neutral greys beside it. Never introduce a second simultaneous accent, never hard-code an accent hex outside the `--volt` token block in styles.css (JS reads `getComputedStyle` when it needs the hex). No borders, pill CTAs, thin tabular numerals.
Expand All @@ -43,4 +44,4 @@ and falls back to `BASE_URL` otherwise, so Host-header spoofing can't steer redi
stays anchored to `BASE_URL` (single registered callback). Uvicorn already trusts proxy headers
(`--proxy-headers --forwarded-allow-ips *` in the Dockerfile).

Runs via Docker Compose on a single home server (`deploy/fresh-install.sh` bootstraps a clean install). As of Jul 2026 the app is on public ingress (`https://www.get-forged.com`, `BASE_URL` in the compose override) with Google OAuth active — dev sign-in buttons only appear when Google is unconfigured (fresh installs). Health Auto Export syncs from anywhere; Withings OAuth round-trips against the public callback URL. Rate limiting is still Phase 6 — the public surface is login + the demo seat.
Runs via Docker Compose on a single home server (`deploy/fresh-install.sh` bootstraps a clean install). As of Jul 2026 the app is on public ingress with Google OAuth active — the public domain lives ONLY in `BASE_URL` in the gitignored compose override and must never be written into tracked files or commit messages; HTML entry points carry a `__BASE_URL__` token that `main.py::_html_page` substitutes at serve time (OG/share meta needs absolute URLs) — dev sign-in buttons only appear when Google is unconfigured (fresh installs). Health Auto Export syncs from anywhere; Withings OAuth round-trips against the public callback URL. Rate limiting is still Phase 6 — the public surface is login + the demo seat.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 James Atkinson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading