What's shipped.
Every release. Every notable tweak. Nothing buried. The forward-looking version of this page is at /roadmap.
The page previously only read the manually-set annual_income field (null for every broker import) — so it showed nothing despite 164 dividend + 108 withholding + 384 interest events sitting in the ledger since Dec 2023. Rebuilt on three legs: RECEIVED — real broker history aggregated by year (dividends / withholding / interest / net) + a 12-month payout bar strip; SAME BOOK, PAST DECADE — hypothetical reconstruction, today's share counts × each year's actual split-adjusted per-share payouts (new yahooDividendHistory via chart events=div, GBp-normalized) at today's FX; EXPECTED — trailing-twelve-month per-share payouts × current shares per holding, with per-holding table (shares, TTM/share, income, yield, HIST·TTM vs MANUAL source) and manually-set annual_income rows folded in. New lib/dividends.ts; events amounts were never encrypted so received numbers are exact. Workers-budget aware (≤14 ticker lookups, biggest positions first).
A Trading 212 import 'doing nothing' unravelled into the real story: since the 2026-06-01 plaintext-column drop, 11 query sites still requested dropped columns. PostgREST 400s the whole query when any selected column doesn't exist, and every caller discarded the error object — so every ledger surface silently read []: net worth €0, rank stuck at L1, assets list empty, import dedup blind (re-uploads duplicated all positions), snapshot capture 400ing on WRITE (9-day history gap), weekly digest selecting dead columns. Purged the lot: ledger-reads ASSET/LIABILITY_COLS (the root), tier.ts (incl. a never-existed liabilities.current_balance — liabilities now actually count against net worth), both snapshot readers + the capture upsert, three events.ts reconciliation reads, import dedup, digest cron (net_worth_enc + per-user decrypt). Verified against live PostgREST: old shapes 400, new shapes 200. Data repair: 171 duplicate assets from the blind-dedup window deleted (the 36 originals intact); business login renamed hello@legacycodex.xyz → hello@millefold.com in auth + profiles (Google sub-matching had kept the pre-rebrand email). Rank ladder rethemed to the dynasty line: Apprentice → Steward → Mercenary → Merchant → Magnate → Patrician → Sovereign (slugs/L-codes stable).
Verifying the sandbox surfaced a deep one: the daily price cron had NEVER updated a row — Stooq deployed an anti-bot proof-of-work wall that blocks datacenter IPs, both price tables sat empty since creation, and every HIST rate silently degraded to class assumptions. Yahoo Finance chart API now fronts quotes + 10y daily history (stocks and crypto quotes; GBp pence normalized ÷100 — the GBX trap — with 429-backoff retries and request spacing); Stooq and CoinGecko demoted to fallbacks. Second root cause: the Workers free plan caps a request at 50 subrequests, which deterministically killed sweeps after ~13 tickers. refreshMarketPrices now batches writes (2 upserts total), threads an explicit fetch budget, reuses previously-resolved Yahoo symbols (1 fetch/ticker steady-state), processes unknowns first with a per-ticker spend cap, and defers leftovers to the next run. End state: 32/35 portfolio tickers priced with history, budget no longer exhausted; 3 dead broker codes (13M/RY6/ML) permanently fall back to assumptions by design. Engine + lookup verified end-to-end from prod egress; scripts/verify-sandbox.mts added as the repeatable check.
Second paid feature, same day. /dashboard/sandbox builds hypothetical portfolios out of slices — your real holdings (carrying their HIST/ASSUMED rates from the personal-projection engine), asset classes at stated assumptions, live ticker lookups (real 10y price CAGR fetched via server action), or custom rates. Weight inputs with sum indicator + normalize-to-100. CURRENT vs ALT A vs ALT B race on one chart, end-state table shows final value and delta vs current at the chosen horizon, same monthly DCA into every path. Entitlement gate shared with PERSONAL (founder cohort or paid plan; locked page upsells /pricing). Shared math extracted to lib/projection-math.ts; CLASS_ASSUMPTIONS split into client-safe lib/class-assumptions.ts. SANDBOX added to dashboard nav, open_sandbox link on projection. Also: cursor lightning-on-click effect removed (CursorLightning → slim CursorDot, −300 lines).
The first paid feature ships. New lib/personal-projection.ts blends one nominal rate from the user's actual mix: broker-imported tickers get a real 10y price CAGR (Stooq dailies cached in market_price_history, price-only ex-dividends, min 1y span), everything else uses stated per-class assumptions (stocks 8 / crypto 15 / savings 2.5 / real estate 4 / vehicles −8 …), weighted by FX-converted values. Projection page gets a full-width PERSONAL preset (PAID badge) + a holding-by-holding breakdown table showing weight, rate, and HIST·xY vs ASSUMED source per row, with history-coverage % in the panel head. Gating via new lib/entitlements.ts: paid plan or founder cohort; everyone else sees a locked preset linking to /pricing. Trial-countdown mechanics deferred to pre-release paid-tier definition. CLASS_ASSUMPTIONS doubles as the growth table for per-category forward projection. Also: next 16.2.6 → 16.2.7 (closes audit item L8).
Five buttons across the projection panel: MEAN (5%) / HISTORICAL (7%, default) / GENEROUS (10%) / CUSTOM (any %) / PERSONAL (soon). Each has bold label + mute one-liner explaining the rate. PERSONAL is a coming-soon placeholder for the first paid feature: weighted CAGR from the user's actual holdings. Paid tier defined: PERSONAL projection + custom portfolio sandbox, 3-7d free trial then €4/mo or €36/yr, activates at pre-release when Stripe lands. Roadmap + vault inbox notes updated. Custom-portfolio sandbox added as alpha 4 paid todo.
New /dashboard/history page: single-date backfill form (pick a past date + total assets + total liabilities → upserts a snapshot) + bulk CSV import (date,total_assets,total_liabilities OR date,net_worth, up to 1000 rows). Existing same-date rows overwrite. Snapshots get id surfaced so individual rows are deletable. ProjectionPreview rewritten as a curve chart: horizon slider 5-40y, decade gridlines, monthly contribution + return inputs, milestone cards at +10/+20/+30/+40y. Per-category filter parked as schema decision (~2d).
Email/recovery: callback returns specific error codes (expired_link / link_used / invalid_token / oauth_cancelled / missing_code), login page surfaces them with friendly copy, check-email state shows the entered address + 30s resend cooldown + 'lost access' escape to /contact. Activity feed: new activities table + SECURITY DEFINER get_activity_feed() function + /dashboard/activity page showing my level-ups + OG claims + follows + followees' public events. Snapshot insights: chart now has 7d/30d/90d/all range toggle (loads up to 365d), milestones-crossed list (€1k–€10M thresholds), biggest-swing callout in-range. All three alpha 4 items closed.
Phase plan rewritten into alpha 1-4 → beta 1-3 → pre-release → 1.0 → post-1.0. Stripe deferred to pre-release; donate button slotted into alpha 4 (vendor decision parked, Ko-fi recommended). Mission Control gets a seed-demo-ledger / clear-demo-rows panel (10 assets across all 9 categories + 3 liabilities + 60d snapshots tagged [DEMO] / source=demo_seed) so the redesigned dashboard renders with shape. Scanner-probe WAF rule confirmed actively blocking in the wild.
All six high-priority Phase 4 secrets shipped and verified: BREVO_API_KEY (email live), MISSIONCONTROL_PASSWORD + SECRET (24h HMAC gate), SUPABASE_SERVICE_ROLE_KEY (deletion + cron + webhook unlock), NEXT_PUBLIC_VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY (push), CRON_SECRET (Wrangler + GitHub). GH Actions cron-digest.yml fires weekly. Cloudflare WAF: 10/min rate-limit on POST / + /login (1 free slot) + custom rule blocking scanner probes (wp-/.env/.git/xmlrpc/etc + empty UA). hello@millefold.com also granted is_admin alongside paulius485@gmail.com.
Removed `export const runtime = 'edge'` from /api/cron/digest and replaced lib/email.ts import with inline Brevo fetch. The edge declaration + transitive server-only import combo returned 500 before reaching the handler. Test workflow run returns 200.
join_waitlist() lets anon insert + receive unsubscribe_token without needing SELECT on the table (anon had no SELECT, so the previous .insert().select() chain 401'd). sendEmail now awaited so Cloudflare Workers doesn't terminate the dangling promise mid-flight.
Whole authed subtree moved to /dashboard. RESERVED_SLUGS broadened. Roadmap restructured into 4 phases — code phases 1–3 + new Phase 4 dedicated to user-side manuals (Brevo, Stripe, Sentry, broker connect, product call). Mission Control + /roadmap show 4 phase cards. Public profile header stacks on mobile + smaller avatar. Static pages get tighter mobile padding.
Live cap monitoring against Brevo's free 300/day. Warns at 80% / critical at 95%. email_events table persists send failures + threshold crossings.
lib/email.ts now calls Brevo's transactional API. 300/day free (vs Resend 100/day) and EU-based (Paris). Privacy + status page + manual setup checklist updated. Env var renamed RESEND_API_KEY → BREVO_API_KEY.
waitlist.invited_by_slug column captured from ?ref=<slug>; /dashboard/profile shows your invite link + real-time attributed-signups counter via the invite_counts view.
/status pings Supabase + Frankfurter live + reports Resend config. /sitemap.xml + /robots.ts (excludes app + missioncontrol + api). /contact routes general / privacy / security inquiries. Backup + DR runbook landed in vault Library.
Downloadable Trading 212 / Revolut / IBKR starter CSVs in the import panel. /dashboard layout reflows on mobile — wrap-nav with horizontal scroll, sign_out above the fold.
/roadmap + /docs (+ FAQ + scoring + privacy-by-design + getting-started), subscriptions table seeded + Mission Control reads it w/ burn-by-category chart, follows + /dashboard/friends grid + optimistic follow button on /u/[slug], hardcoded admin email → app_metadata.is_admin via lib/auth.isAdmin().
/privacy + /terms, GDPR JSON export + account delete with confirm-phrase, per-user unsubscribe tokens + /unsubscribe/[token], L11 grant UI in mission control, 404 + error.tsx + global-error pages, 4-step onboarding ladder on empty ledger, footer privacy/terms links.
public_profiles → SECURITY INVOKER + column grants, anon write revoked, storage listing closed, RLS perf wrapped, HTTP headers set.
Atomic L11 grant + ARCHITECT panel + admin newsletter dispatch with confirm step.
Country + default currency selectors (LT/NO/PL/SE/GB/US + EUR/USD/GBP/NOK/PLN/SEK pinned). Waitlist newsletter opt-in revealed only after first join click.
Provider enabled in Supabase + GCloud OAuth client wired under business identity hello@millefold.com.
First 1000-to-L8 OG mechanic, Storage avatars bucket, Frankfurter rates, Resend integration + transactional confirmation.
Slug + is_public toggle + bio + avatar URL. `/u/<slug>` page + `/dashboard/profile` settings + reserved slug list.
Daily lazy net-worth capture, SVG sparkline + Δ_7d/Δ_30d cards. Bulk CSV import for assets + liabilities with per-row errors.
Leverage ratio, allocation bars by category, rating chips, score-breakdown 6-input panel, client-side what-if projection.
Page-level spotlight w/ Y-fade, status chips on Features, real founders counter from public_stats.
Session recovery + Active context layer + manual setup checklist. CRD host live to survive PC restarts.
Off Vercel onto @opennextjs/cloudflare. `npm run deploy` + GH Actions on push to main. millefold.com live.
Net worth, assets/liabilities CRUD, mission control admin gate.
MegaETH-raw aesthetic, hero scramble, Features grid, OG counter, waitlist.
// 33 ENTRIES TOTAL · source of truth is git log on main.