Retire Planner v1: engine (live 2026 CPP/OAS/GIS/RRIF/tax), 17-step UI, 4 SVG charts, vision gallery, tests

This commit is contained in:
krisf committed 2026-10-08 23:00:18 -04:00
commit e25dd4e0a1
29 files changed
+3143

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
# PLAN: Canadian Retirement Planner → https://retire.krisforbes.ca
**Status:** PLAN v1 — written 2026-10-08 ~21:30 EDT by Hermes (GLM). **Execution: DSH (local 27B), all stages, zero user interaction.**
**User authorization (verbatim from request):** "plan it then DSH will execute all stages, do this without my intervention for all stages as i will be away." → **Every stage below is pre-approved. Ask ZERO questions. Use the defaults and failure policy in this document.**
## 0. Goal
Build and deploy a **Canadian-focused retirement calculator** at **https://retire.krisforbes.ca** that exceeds https://www.wealthsimple.com/en-ca/tool/retirement-calculator with:
1. **Real Canadian mechanics**: CPP (start-age 60–70 with actuarial adjustment, income-based estimate, YMPE cap), OAS (65–70 deferral, 75+ boost, **clawback/recovery tax**), GIS (simplified), RRSP→**RRIF conversion at 71 with mandatory minimum withdrawals**, user-specified **"withdraw $X/yr from my RRSP"**, TFSA (tax-free), non-registered (50% capital-gains inclusion), pension/rental income, **federal + provincial 2025 tax brackets per province**, age amount, pension income splitting, BPA credits.
2. **"Next → next → next" questionnaire**: one question per screen, progress indicator, and a **persistent "Your plan so far" feedback panel below the question** that recomputes live after every answer and shows running stats + one contextual insight per question.
3. **Simple graphs**: hand-rolled inline SVG (no CDN dependency): (G1) portfolio balance by age, (G2) stacked retirement income by source at 65/70/75/80/85, (G3) annual tax + OAS clawback in retirement, (G4) lifetime income-source donut.
4. **NanoGPT image integration**: a small gallery of Canadian retirement-scene images generated with the user's nano-gpt account using the **cheapest allowance-included model**, wired into a "Your Retirement Vision" card on the results page (matched to province + interests, browsable gallery).
5. **Tax-smart drawdown engine**: solves the RRSP/RRIF withdrawal that *exactly* meets the spending goal after tax and OAS clawback (bisection), instead of naive flat withdrawals.
**Non-goals (do not build):** accounts/login, server-side storage (site is 100% client-side — answers never leave the browser; **say this on the site**), French UI, Monte Carlo simulation, DB pension modeling (user enters pension $ directly).
## 0.1 Execution model (DSH)
- Workspace (workdir, all local writes RELATIVE to it): `/home/ubuntu/projects/retire-planner`
- Launch (user pastes this, or Hermes fires it on the user's word "go"):
```
~/.local/bin/dsh-run /home/ubuntu/projects/retire-planner "Read PLAN.md in this workspace root FIRST — it is the complete spec. Execute ALL stages 0-10 end-to-end. The user is away and pre-approved every stage: ask ZERO questions, follow the defaults and failure policy. When done, REPORT.md must exist in the workspace root and your FINAL output line must be exactly: RETIRE_PLANNER_DONE"
```
- DSH constraints (from deepseek-harness skill): keep prompts free of real secrets (read keys from files at runtime); local file writes relative to the workspace; repo commits in the workspace; `dsh-run` positional arg = workdir. Long build (~30–45 min) is expected and fine.
- **Done criteria:** site live at https://retire.krisforbes.ca returning 200 with marker `<!-- retire-planner v1 -->`, REPORT.md written, final line `RETIRE_PLANNER_DONE`.
## 0.2 Ground truth gathered at plan time (do not re-derive; re-verify only what the stages say to re-verify)
| Fact | Value |
|---|---|
| CF zone id (krisforbes.ca) | `8cf8e4d7035b85deb6ebfe0026da3392` |
| CF API token (live) | **NOT in shell env.** Extract on Unraid: `grep -oE 'cfut_[A-Za-z0-9_\-]+' /mnt/cache/docker/caddy/config/caddy/autosave.json | head -1` (this is Caddy's LE DNS-challenge token; proven recipe in skill `mcp-deployment-pipeline` §"Cloudflare API token") |
| Existing DNS pattern | CNAME `<sub>.krisforbes.ca` → `dyn.krisforbes.ca`, `proxied:false`, ttl 1. DNS POST = JSON body to `/client/v4/zones/<zone>/dns_records` with `Authorization: Bearer <tok>` |
| Unraid | `root@192.168.0.123` ssh works from this box (BatchMode). **Unraid has NO python3.** Pipe scripts to `ssh root@192.168.0.123 bash -s` on stdin (quoting via `bash -c '<cmd>'` loses everything after the first quote) |
| Free host ports on Unraid | 8100 simcoe, 8101 blogapp, 8102 dashboard, 8105 firecrawl, 8106 novel, 8109 apex300 are TAKEN → **use 8103** (verified free at plan time; re-check in Stage 6) |
| Caddy | container `CaddyV2` (image `caddy:cloudflare`), Caddyfile at `/mnt/cache/docker/caddy/config/Caddyfile`. Existing per-subdomain block pattern (blogapp/dashboard):
```
# HERMES: <name>
<sub>.krisforbes.ca {
encode gzip
reverse_proxy 192.168.0.123:<port> {
header_up X-Real-IP {remote_host}
}
}
# END HERMES: <name>
```
Edit → **`docker restart CaddyV2`** (reload does NOT pick up bind-mount changes). TLS is handled globally (no tls block needed — grey-cloud DNS-only sites get LE certs via the caddy:cloudflare plugin). |
| Webroot convention | Skill `static-site-unraid-deploy`: appdata at `/mnt/better/appdata/<name>` (NOT /mnt/cache). blogapp is the known exception at `/mnt/cache/appdata/blogapp`. **Use `/mnt/better/appdata/retireapp/`** |
| nginx pattern | blogapp = nginx container, webroot bind-mounted `:ro` at `/usr/share/nginx/html`. Copy blogapp's exact image tag: `docker inspect blogapp --format '{{.Config.Image}}'` |
| Gitea | `https://gitea.krisforbes.ca` (API also at `http://192.168.0.123:3000/api/v1`, version 28.0.0). Credentials in `~/.git-credentials`: `https://krisf:<tok>@gitea.krisforbes.ca` (git uses it automatically; for API calls parse it, **never print the token**). Create repos via Gitea API, push via git HTTPS — never scp/ssh-copy repo content. |
| nano-gpt key | `HERMES_CUSTOM_NANO_GPT_API_KEY=` in `~/.hermes/.env` (read at runtime, never commit/print) |
| nano-gpt image API | `POST https://nano-gpt.com/api/v1/images/generations` (Image API only — the chat-completions image route is dead with `zdr_provider_required`). Catalog: `GET /api/v1/images/models`; per-model params: `GET /api/v1/images/models/<id>/endpoints`. Response: `data[].b64_json` **or** `data[].url` (R2 links expire — download immediately), plus `cost` and `remainingBalance`. |
| **Free/cheap image models (checked live 2026-10-08)** | No model is literally $0 list price. Cheapest allowance-included: **`z-image-turbo` — $0.0119/image** (Pro daily allowance; fastest ~5 s; pixel-grid resolutions like `1024*1024`, max 1536*1536, max 4 images/call, prompt ≤1200 chars) and **`qwen-image` — $0.02/image** (also allowance-included). **`nano-banana-pro-ultra` = $0.26 — FORBIDDEN by user.** Budget: 13 images × $0.0119 ≈ **$0.16 worst case**. |
| node on this box | `/home/ubuntu/.hermes/tools/node-26.7.0-linux-x64/bin/node` v26.7.0 (on PATH as `node`) — use it to unit-test the engine |
| Off-LAN verification vantage | Home hairpin NAT is BLOCKED — `curl https://<sub>.krisforbes.ca` from this box proves nothing about inbound. Verify from Midhurst: site proxy `projects/ego-test/site_proxy.py` at `synoadmin@100.96.20.61:8899` (use as `curl -x http://100.96.20.61:8899 ...`), or the troublebox `trouble@100.96.20.61:2222`. DNS-only (grey cloud) records mean traffic goes straight to the WAN IP (UDR7 DDNS via `dyn.krisforbes.ca`). |
| Existing `retire` DNS/Caddy | Not present at plan time (Caddyfile blocks: tv/bw/photos/midhurstroofing/gitea/plex/simcoe/blogapp/dashboard/bluetti/forces/novel; no retire). Stage 8 checks for an existing record first anyway. |
## 0.3 Failure policy (user is away — decide, don't ask)
- **Stage 4 (images) fails or balance < $1** → ship the site with built-in SVG gradient placeholder art; note it in REPORT.md. NOT fatal.
- **Stage 8 (CF DNS) fails** (no token / API error) → site stays live at `http://192.168.0.123:8103`; write a loud BLOCKER section in REPORT.md with the exact manual command. NOT fatal to the build.
- **Any other stage fails** → fix forward if a safe fix exists; otherwise roll back Stage 7 (Caddy) so no half-configured route remains, and write the failure + evidence to REPORT.md. Never leave the Caddyfile broken: always `docker restart CaddyV2` only after the block is syntactically complete, and keep the backup.
- Every stage appends evidence (command + output snippet) to REPORT.md as it goes, so a failure mid-run is diagnosable without re-running anything.
---
## Stage 0 — Preflight (read-only; ~2 min)
Run and log to REPORT.md:
1. `pwd` is the workspace; `node --version`; `git --version`.
2. Gitea reachable: `curl -s http://192.168.0.123:3000/api/v1/version`.
3. Unraid reachable: `ssh -o BatchMode=yes -o ConnectTimeout=10 root@192.168.0.123 'echo ok'`.
4. Port 8103 free: `ssh root@192.168.0.123 'ss -tlnp | grep :8103'` → expect empty.
5. nano-gpt key present: `grep -c '^HERMES_CUSTOM_NANO_GPT_API_KEY=' ~/.hermes/.env` → 1. Fetch balance/allowance later in Stage 4 (response carries `remainingBalance`).
6. Confirm no existing repo `retire-planner`: `curl -s http://192.168.0.123:3000/api/v1/repos/krisf/retire-planner` → expect 404.
## Stage 1 — Canadian constants module (`site/engine/canada.js`)
**Fetch-first, fallback-second.** Create `CONSTANTS` with every value stamped `{value, source, asOf}`. Fetch these pages (web fetch or curl; canada.ca is reachable from this box) and parse the current figures:
| Key | Source URL | Fallback (stamp "FALLBACK — verify") |
|---|---|---|
| `cppMax65Monthly` (max CPP at 65) | canada.ca/en/services/benefits/publicpensions/cpp/cpp-benefit/amount.html | 1433.00 (2025) |
| `ympe` (Year's Maximum Pensionable Earnings) | same page | 69700 (2025) |
| `oasMax6574Monthly` | canada.ca/en/services/benefits/publicpensions/cpp/old-age-security/payments.html | 713.34 (2025 Q1) |
| `oas75Boost` | same (10% at 75) | 1.10 |
| `oasClawbackThreshold` (net income) | canada.ca/en/services/benefits/publicpensions/cpp/old-age-security/recovery-tax.html | 93454 (2025) |
| `gisMaxSingleMonthly` (simplified) | canada.ca/.../guaranteed-income-supplement.html | 1086.88 (2025 Q3, verify) |
| RRIF minimum % by age 71–95 | canada.ca/en/revenue-agency/services/tax/individuals/topics/registered-retirement-income-fund-rrif (minimum withdrawal tables) | table: 71:5.28 72:5.40 73:5.53 74:5.67 75:5.82 76:5.98 77:6.17 78:6.36 79:6.58 80:6.82 81:7.08 82:7.38 83:7.71 84:8.08 85:8.51 86:8.99 87:9.55 88:10.21 89:10.99 90:11.91 91:13.06 92:14.49 93:16.34 94:18.79 95:20.00 |
| Federal 2025 brackets | canada.ca/en/revenue-agency/services/tax/individuals/frequently-asked-questions-individuals/canadian-income-tax-rates-individuals-current-previous-years.html | 15% ≤57,375; 20.5% ≤114,750; 26% ≤177,882; 29% ≤253,414; 33% above. BPA 15,705. Age amount max 8,790 (65+, claws back 15% of net income over 44,017). Pension income amount 2,000 |
| Provincial 2025 brackets + BPA, all 13 prov/terr | canada.ca provincial tax pages / taxtips.ca 2025 tables | embed at least ON/QC/BC/AB; if a province can't be fetched, use federal-only + a flat 5% surrogate **and stamp it clearly** |
| CPP actuarial factors | canada.ca CPP "before/after 65" adjustment | −0.6%/month before 65 (60 → ×0.64), +0.7%/month after 65 (70 → ×1.42) |
| OAS deferral | canada.ca OAS | +0.6%/month 65→70 |
Rules: all money in the engine is **real (today's) dollars** — inflation only indexes the *displayed* CPP/OAS amounts and is used for the inflation assumption insight. Defaults stamped in the UI: real return 3.5% pre-retirement / 2.5% post (net of fees), inflation 2%, life expectancy 95.
## Stage 2 — Calculator engine (`site/engine/engine.js`)
Pure functions, no DOM. UMD export (`if (typeof module !== 'undefined') module.exports = {...}`) so node can unit-test. Core:
```
inputs = {
age, province, household: 'single'|'couple',
partner: {age, income, rrsp, tfsa, nonreg, monthlyContribution} | null,
income, rrsp, tfsa, nonreg, monthlyContribution, employerMatchMonthly,
retireAge, spendingAnnual (today's $),
cppStartAge (60-70), cppOverrideMonthlyAt65 (0 = auto),
oasStartAge (65-70),
rrspWithdrawalOverrideAnnual (0 = auto),
pensionAnnual, rentalAnnual, otherAnnual,
realReturnPre (0.035), realReturnPost (0.025), inflation (0.02), lifeExp (95)
}
```
Functions:
1. `estimateCppMonthlyAt65(income, age)` → `0.25 × min(avgEarnings, ympe) × min(1, contribYears/40)` where `contribYears ≈ max(0, age − 22)` capped at 47; capped at `cppMax65Monthly`. (contributory period 18→65 = 47 yrs; general drop-out ≈ 17% → ~40 effective years. Document as an estimate; the UI shows the canada.ca link to get the real statement.)
2. `cppMonthlyAt(age, startAge, at65)` → `age < startAge ? 0 : at65 × adj(age)` with `adj = (1 − 0.006×monthsBefore65)` if startAge<65 else `(1 + 0.007×monthsAfter65)`; applied to the pension *started* at startAge: `monthly = at65 × adj(startAge)` and stays flat (real) after start.
3. `oasMonthlyAt(startAge)` → 0 before start; `oasMax6574 × (1 + 0.006×monthsAfter65 if startAge>65) × (1.10 if age≥75)`.
4. `tax(taxableIncome, province, age, {pensionIncome})` → federal + provincial on brackets; credits: BPA (fed+prov, clawed back 15% over ~$165k/$173k net income thresholds — simplify: reduce BPA by 15% of net income above 165,000, floor 0), age amount if 65+ (clawback 15% over 44,017), pension income amount $2,000 (fed+prov) on eligible pension/RRIF income if 65+. Couple mode: pension splitting — model 50% of pension+RRIF income split (recompute both partners' tax on split incomes, sum). Keep this simple but present.
5. `clawback(netIncome)` → `0.15 × max(0, netIncome − oasClawbackThreshold)`.
6. `gis(incomeExOas, household)` → simplified: only if income ex-OAS below ~$20k (single) / ~$26k (couple, both 65+); `gis = max(0, gisMax − 0.5 × max(0, incomeExOas − 5000))` (first $5k employment exemption approximated); non-taxable. Stamp "simplified".
7. `project(inputs)` → year-by-year array from `age` to `lifeExp`:
- Accumulation (age → retireAge): `bal = bal×(1+r) + 12×contrib(+match)`; contributions grow at `inflation` (real terms: keep flat — choose flat, document). Track `rrsp`, `tfsa`, `nonreg` separately. At retireAge: `nestEgg = sum`.
- Drawdown (retireAge → lifeExp), all real dollars:
- `cpp = cppMonthlyAt(...)×12`, `oasGross = oasMonthlyAt(...)×12` (0 before start ages)
- `other = pension + rental + otherAnnual`
- `need = spendingAnnual`
- RRIF rule: from the end of the year the person turns 71, `rrsp` is a RRIF: `minWithdrawal = rrifPct(age) × rrspBalance` (age 71 uses the 71 row; at exactly 71 use that year's pct on the Jan-1 balance).
- Solve `W` (RRSP/RRIF withdrawal) by **bisection (25 iterations)** such that:
`cash = cpp + oasGross + other + W + nonregW + tfsaW − tax(taxable) − clawback(netIncome) = need`
where `taxable = cpp + oasGross + other + W + 0.5×nonregW` (non-reg drawdowns taxed as 50% capital gains on the withdrawn amount — conservative simplification), `netIncome = taxable` (line 23400 approx), `nonregW = min(nonregBal, max(0, needGapAfterRrsp…))` — order the waterfall **RRSP/RRIF → non-reg → TFSA (last, tax-free)**, with `W = max(userOverride, rrifMin)` when override > 0 (override means "withdraw at least X from RRSP per year"), else `W = solution`.
If user override set: `W = max(override, rrifMin)`; then nonregW/TFSAW fill any remaining gap; if cash still < need → record `unfunded = need − cash`.
- Update balances: `rrsp −= W`; `nonreg −= nonregW`; `tfsa −= tfsaW`; floor at 0. If all hit 0 → `depletedAge = age`, stop drawing, `unfunded` accrues.
- Record per-year: `{age, cpp, oasGross, oasClawback, gis, other, rrifWithdrawal: W, nonregW, tfsaW, taxable, tax, cash, need, unfunded, rrspBal, tfsaBal, nonregBal, totalBal}`.
- Couple mode: run `project` per person with household spending split 50/50 (or 60/40 if one retires later — keep 50/50, document), then merge: household cash = sum; depletion = when combined balances hit 0. OAS: each person's own. GIS: household rate if both 65+.
8. `sustainableSpending(inputs)` → bisection on `spendingAnnual` so that `totalBal ≥ 0` through `lifeExp` (or depletion age > lifeExp). Returns $/yr and $/mo.
9. `recommendations(inputs, projection)` → compute three gap-closers: (a) extra monthly contribution needed to close gap, (b) retirement age that closes gap (+N yrs), (c) spending reduction that closes gap. Each as a concrete number.
Unit tests (Stage 2 exit): `node tests/engine.test.js` — assert: CPP at 60 = 0.64×at65; CPP at 70 = 1.42×at65; RRIF min at 71 = 5.28% of balance; ON tax on $80k taxable ≈ $13–15k (sanity band, print actual); a depleting scenario returns a `depletedAge`; sustainableSpending ≤ spending input when gap exists. Print PASS/FAIL lines; all must pass.
## Stage 3 — UI (`site/index.html`, `site/app.js`, `site/style.css`)
- Single page, no build step, no CDN. Vanilla JS + inline SVG charts. Mobile-first, clean light design, CAD formatting (`$1,234,567`), accessible labels.
- **Questionnaire**: one question per view, big controls, **"Question N of M" + progress bar**, Back / Next buttons. Steps:
1. Age now (25–70)
2. Province/territory (13)
3. Household: just me / I have a partner → if partner: partner age, partner income, partner savings split (3 sub-questions)
4. Your gross income $/yr
5. Your savings: RRSP $, TFSA $, non-registered $ (three inputs, one screen)
6. Monthly contributions + employer match $/mo
7. Retirement age (50–75)
8. Annual spending goal in retirement (today's $)
9. CPP: start age slider 60–70 + optional "my estimated CPP at 65" override
10. OAS start age 65–70
11. RRSP/RRIF withdrawals: "$____ per year (0 = smart auto)"
12. Other income: pension $/yr, rental $/yr, other $/yr
13. Interests in retirement (multi-select chips: cottage/lake, travel, gardening, workshop, grandkids, volunteering, golf, city life) → drives vision image
14. Assumptions (collapsible, defaults shown): real return pre/post, inflation, life expectancy 80–100
- **Building feedback panel** (the core UX requirement): a persistent card directly below the question, visible on every step. After EVERY answer it re-runs `project()` with current inputs (missing values = defaults) and shows 4 live stat chips — **Nest egg at retirement: $X** / **On track: X%** (nestEgg ÷ required, where required = sustainableSpending⁻¹… define: required nest egg = spending × 25 adjusted, or better: run sustainableSpending on current inputs → "your current savings pace supports $Y/yr") / **Money lasts to age Z** / **CPP at 65 ≈ $X/mo** — plus **one contextual insight sentence per question**, e.g.:
- Province → "Top marginal rate in Ontario: 53.53% — RRSP/RRIF withdrawals are taxed as ordinary income."
- Income → "At $85k you're building toward ≈ $1,240/mo CPP at 65 (2026 max $1,447)."
- Savings → "62% of your savings are in registered accounts. TFSA withdrawals are tax-free — handy for bridging before CPP starts."
- Contributions → "You're saving 9% of gross including match; 15% is the common rule of thumb."
- Retirement age → "Retiring at 67 projects ≈ $1.42M (today's dollars) at your current pace."
- Spending → "A $72k/yr goal needs ≈ $1.9M at a 3.5% real return. You're at 74% of that."
- CPP start → "Starting CPP at 60 pays $X/mo (−36% vs 65); starting at 70 pays $Y/mo (+42%). Break-even ≈ age 74."
- OAS → "OAS at 65 ≈ $X/mo. At your income the recovery tax may claw back ≈ $Y/yr." (compute from projection)
- RRSP withdrawal → "$24k/yr from RRSP at 67 costs ≈ $Z/yr in Ontario tax; the engine tops up from TFSA last."
- Other income → income-stack preview sentence.
- Interests → "Nice — we'll match a retirement scene to that."
- Assumptions → "±1% real return swings your nest egg by ≈ ±$X."
- **Results view**: hero stats (nest egg, sustainable spending $/mo, depletion age or "lasts past 95", lifetime tax+clawback drag), graphs G1–G4 (SVG), **Your Retirement Vision** card (matched image + gallery strip + Download link), **3 ways to close the gap** (from `recommendations`), what-if sliders (retireAge, spending, realReturn) with live recalc, disclaimer footer: *"Estimates only — not financial advice. CPP/OAS/GIS figures from canada.ca as of <date>; tax rates <year>."*
- Privacy line on the first screen: **"100% client-side — your answers never leave your browser."**
- Persist answers in `localStorage` (resume on refresh).
## Stage 4 — Vision images (nano-gpt, cheapest model)
1. `KEY=$(grep -m1 '^HERMES_CUSTOM_NANO_GPT_API_KEY=' ~/.hermes/.env | cut -d= -f2)` (never echo the key).
2. Confirm model params: `curl -s https://nano-gpt.com/api/v1/images/models/z-image-turbo/endpoints` (already known: pixel-grid resolutions, prompt ≤1200 chars).
3. Generate **13 images**, `POST https://nano-gpt.com/api/v1/images/generations`, body `{"model":"z-image-turbo","prompt":"...","resolution":"1024*1024","n":1}`. Save `data[0].url` immediately via curl (R2 expiry) into `site/vision/<slug>.png` (check magic bytes; rename .webp if needed and reference correctly). Log `cost` + `remainingBalance` per image into REPORT.md. **Abort the stage if `remainingBalance` < 1.0 or cumulative cost > $2** (site ships with SVG fallbacks).
4. Prompts (tasteful, no text, no specific people; Canadian):
- `hero` — "A calm Canadian lakeside at dawn, mist over still water, pine forest, soft pastel sky, wide serene landscape, photorealistic"
- `muskoka` — "A cozy wooden cottage on a pine-framed lake at golden hour, Muskoka Ontario, loon, warm window light, photorealistic"
- `rockies` — "Turquoise glacial lake with snow-capped Rocky Mountains at sunrise, Banff, photorealistic"
- `maritimes` — "White lighthouse on rugged red sandstone cliffs, Atlantic waves, Peggy's Cove style, soft morning light, photorealistic"
- `prairies` — "Endless golden wheat field under a huge prairie sky with a dramatic summer sunset, Saskatchewan, photorealistic"
- `quebec` — "Colourful historic houses and cobblestone streets of Quebec City old town in autumn, photorealistic"
- `vancouver` — "Vancouver skyline with North Shore mountains and Stanley Park seawall at dusk, photorealistic"
- `toronto` — "Toronto skyline and Lake Ontario waterfront at blue hour, photorealistic"
- `garden` — "A sunny backyard vegetable and flower garden with a small garden shed, Canada, photorealistic"
- `travel` — "Aerial view of a seaplane over a northern Canadian lake surrounded by forest, adventure travel, photorealistic"
- `workshop` — "A bright woodworking workshop with hand tools on a pegboard wall, warm light, hobby, photorealistic"
- `winter` — "A snow-covered log cabin with warm glowing windows in a quiet northern forest, winter evening, photorealistic"
- `dock` — "A wooden dock at sunset with two empty chairs overlooking a quiet lake, peaceful, photorealistic"
5. Province→scene map for the vision card: ON→muskoka, QC→quebec, BC→vancouver, AB→rockies, SK/MB→prairies, NS/NB/NL/PE→maritimes, YT/NT/NU→winter, default→muskoka. Interest chips override: gardening→garden, workshop→workshop, travel→travel, cottage→muskoka/rockies by province, grandkids→dock, city→toronto/vancouver by province. Gallery strip shows all with captions; Download button per image.
6. If the stage aborts: `app.js` detects missing images (fetch HEAD 404) → renders gradient placeholder cards with the scene name. Build this fallback regardless.
## Stage 5 — Gitea repo (source of truth)
1. Create repo via API (token from `~/.git-credentials`, parse with awk/sed, never print):
`curl -s -X POST http://192.168.0.123:3000/api/v1/user/repos -H "Authorization: token <tok>" -H 'Content-Type: application/json' -d '{"name":"retire-planner","private":false,"auto_init":false,"default_branch":"main"}'`
(If 422 "already exists" → reuse it.)
2. In workspace: `git init -b main`, set local identity if unset (`git config user.name "Kris Forbes"; git config user.email "kris@gohosted.ca"`), `echo site/ > .gitignore`? No — commit `site/` and `tests/` and `PLAN.md` and `REPORT.md` (when written). `git remote add origin https://gitea.krisforbes.ca/krisf/retire-planner.git`, commit, push. **Verify no secret strings in the repo** (`git grep -iE 'HERMES_CUSTOM_NANO_GPT_API_KEY=..|cfut_' $(git rev-list --all)` must be empty; the key value must never appear).
## Stage 6 — Unraid web app (nginx, port 8103)
All via `ssh root@192.168.0.123 bash -s` (pipe on stdin):
1. Re-verify 8103 free.
2. `IMG=$(docker inspect blogapp --format '{{.Config.Image}}')` — reuse blogapp's exact nginx image.
3. `mkdir -p /mnt/better/appdata/retireapp/html /mnt/better/appdata/retireapp/config`
4. Write `/mnt/better/appdata/retireapp/config/nginx.conf` (via stdin heredoc): server on 80, root `/usr/share/nginx/html`, `index index.html`, gzip on, `location / { try_files $uri $uri/ =404; }`, `location /vision/ { expires 7d; }`.
5. `rsync -a --delete site/ root@192.168.0.123:/mnt/better/appdata/retireapp/html/` (from the workspace; rsync over ssh works).
6. `docker run -d --name retireapp --restart unless-stopped -p 8103:80 -v /mnt/better/appdata/retireapp/html:/usr/share/nginx/html:ro -v /mnt/better/appdata/retireapp/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro $IMG`
7. Verify: `docker ps --format '{{.Names}} {{.Status}}' | grep retireapp` (Up), `curl -s http://192.168.0.123:8103/ | grep -c 'retire-planner v1'` → ≥1, and `curl -s http://192.168.0.123:8103/engine/engine.js | head -3`.
## Stage 7 — Caddy route (config push — user pre-approved; still do it safely)
1. Backup: `cp /mnt/cache/docker/caddy/config/Caddyfile /mnt/cache/docker/caddy/config/Caddyfile.bak-retire-$(date +%s)`.
2. Append the block (heredoc via stdin, at END of file — top-level blocks can go anywhere):
```
# HERMES: retire
retire.krisforbes.ca {
encode gzip
reverse_proxy 192.168.0.123:8103 {
header_up X-Real-IP {remote_host}
}
}
# END HERMES: retire
```
3. Sanity: `grep -c 'END HERMES: retire' Caddyfile` → 1; then `docker restart CaddyV2`; `sleep 3`; `docker logs --tail 15 CaddyV2` (no config errors).
4. Verify local route: `curl -s -H 'Host: retire.krisforbes.ca' http://192.168.0.123:8103/ | grep -c 'retire-planner v1'` (Host header hits the vhost directly since Caddy proxies to 8103 regardless — this just proves origin serving; the real check is Stage 9).
## Stage 8 — Cloudflare DNS (CNAME retire → dyn.krisforbes.ca, grey cloud)
1. Token: `TOK=$(ssh root@192.168.0.123 "grep -oE 'cfut_[A-Za-z0-9_\-]+' /mnt/cache/docker/caddy/config/caddy/autosave.json | head -1")` — never print it. Zone: `8cf8e4d7035b85deb6ebfe0026da3392`.
2. Check existing: `curl -s "https://api.cloudflare.com/client/v4/zones/$Z/dns_records?name=retire.krisforbes.ca" -H "Authorization: Bearer $TOK"` → if a record exists with content `dyn.krisforbes.ca`, skip creation (log it). If it exists pointing elsewhere, PATCH it.
3. Create: `curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$Z/dns_records" -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"type":"CNAME","name":"retire.krisforbes.ca","content":"dyn.krisforbes.ca","proxied":false,"ttl":1}'` → expect `"success":true`; log the record id into REPORT.md (needed for rollback).
4. Propagation check: `dig +short retire.krisforbes.ca @1.1.1.1` → CNAME dyn.krisforbes.ca → A record(s).
## Stage 9 — Verification (evidence into REPORT.md)
1. Origin: `curl -s http://192.168.0.123:8103/ | grep -o '<title>[^<]*</title>'`.
2. DNS: `dig +short retire.krisforbes.ca` (CNAME → dyn → A).
3. **Off-LAN HTTPS (the real test — hairpin is blocked, do NOT trust a same-LAN curl):** via the Midhurst site proxy: `curl -s -x http://100.96.20.61:8899 -o /dev/null -w '%{http_code}\n' https://retire.krisforbes.ca/` → expect 200 (see `projects/ego-test/site_proxy.py` for how the proxy is started; if it's not running, start it per that project's notes, or use the troublebox `ssh trouble@100.96.20.61 -p 2222 'curl -sI https://retire.krisforbes.ca/'`). First hit may take up to ~60 s if Caddy issues an on-demand LE cert — retry up to 5× with 15 s sleeps. Also fetch the page body off-LAN and grep the `retire-planner v1` marker + `<title>`.
4. Asset checks off-LAN: `/engine/engine.js`, `/style.css`, `/app.js`, one `/vision/*.png` → all 200.
5. Engine smoke in browser context: `node tests/engine.test.js` already passed (Stage 2); additionally `node -e` a full `project()` run with sample inputs and print nestEgg/depletionAge/sustainableSpending — log the numbers.
6. Mobile: `curl -s .../ | grep -c 'name="viewport"'` → 1.
## Stage 10 — REPORT.md + done
Write `REPORT.md` in the workspace root: what was built (file tree), the launch URL, Stage-by-stage checklist with evidence snippets, image-gen spend (sum of `cost`, models used), constants as-of dates, any skipped/failed stages + why, rollback instructions (below), and the done token. Commit + push REPORT.md. **Final output line: `RETIRE_PLANNER_DONE`.**
## Rollback (if ever needed — put this in REPORT.md too)
```bash
# 1. remove Caddy block + restart
ssh root@192.168.0.123 bash -s <<'EOF'
sed -i '/# HERMES: retire/,/# END HERMES: retire/d' /mnt/cache/docker/caddy/config/Caddyfile
docker restart CaddyV2
docker rm -f retireapp
rm -rf /mnt/better/appdata/retireapp
EOF
# 2. delete DNS record (id captured in REPORT.md)
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/8cf8e4d7035b85deb6ebfe0026da3392/dns_records/<record-id>" -H "Authorization: Bearer <cftok>"
# 3. repo stays (source of truth); site files stay in the repo
```
## File tree (workspace)
```
retire-planner/
PLAN.md ← this file
site/
index.html ← questionnaire + results + vision, loads app.js
style.css
app.js ← questionnaire flow, feedback panel, charts (SVG), vision card
engine/
canada.js ← Stage 1 constants (value, source, asOf)
engine.js ← Stage 2 pure math (UMD)
vision/ ← Stage 4 generated images (committed; ~13 × ~1-2 MB)
tests/
engine.test.js ← node unit tests
REPORT.md ← Stage 10
```
## Definition of done (checklist for DSH)
- [ ] `node tests/engine.test.js` all PASS
- [ ] Site files built; secrets scan clean; repo pushed to gitea.krisforbes.ca/krisf/retire-planner
- [ ] retireapp container Up on Unraid :8103; origin serves marker
- [ ] Caddyfile block added; CaddyV2 restarted clean
- [ ] CF CNAME retire → dyn.krisforbes.ca (grey cloud) created (or BLOCKER documented)
- [ ] Off-LAN `curl https://retire.krisforbes.ca/` → 200 + marker + title
- [ ] Vision images generated with z-image-turbo (or documented fallback)
- [ ] REPORT.md committed + pushed; final line `RETIRE_PLANNER_DONE`