Commit graph

145 commits

Author SHA1 Message Date
b47c45e218 backend: dedupe shared logic (indicator_summary_job, CHAT_REFERENCE_LINE, call_openrouter alias)
- indicator_summary_job.py imported its own copies of _month_spend and
  _latest_quotes_by_group; _market_context.py already exposes these.
  Switched to the canonical imports. Also fixed _market_context's
  latest_quotes_by_group to actually filter null prices (it claimed to
  in its docstring but lacked the WHERE clause).
- api.py duplicated REFERENCE_LINE as CHAT_REFERENCE_LINE — same string,
  two sources of truth. Now imports REFERENCE_LINE.
- Chat endpoint used the deprecated `call_openrouter` alias and passed
  an explicit `model=` that bypassed the provider chain. Switched to
  `call_llm` with default model selection, then removed the alias.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:30:11 +02:00
a2bcb2c053 cleanup: drop stale tombstones and dead config fields
Stale comments referencing completed migrations:
- universe.py "remain live until step 10 of Phase G" — endpoints gone
- api.py "Portfolio endpoints moved to universe.py" — empty block
- csv_import.py "persist_pie removed in Phase G" — historical context

Dead Settings fields (all confirmed unreferenced by app code):
- CASSANDRA_PORT — port is hardcoded in docker-compose / uvicorn cmd
- POLAR_API_KEY — Polar was replaced by Stripe
- CASSANDRA_MOCK — env var still set by tests as a sentinel; the
  Settings field itself was never read
- CASSANDRA_BASE_CURRENCY — "GBP" hardcoded inline elsewhere

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:25:33 +02:00
59900f126f css: drop dead selectors (.app-footer, #submit-btn, .form-row, .pf-restore)
The .app-footer rule was kept "for /api/health" but the health page
doesn't reference it. #submit-btn and .form-row were leftovers from
the removed upload page. .pf-restore had a class attribute in
portfolio.js but no CSS rule — dropped the class attribute too.

Also removed the @media (prefers-color-scheme: dark) block — the
dashboard JS always sets data-theme so the media query was unreachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:22:35 +02:00
e807e58629 ui: fix chat pending class, invented CSS vars, .pf-secondary scope
- chat.js: pending indicator class was wrong (.pending instead of
  chat-msg--pending) so the … waiting message never got italic/dim
- settings.html + cassandra.css: three invented CSS vars (--panel-bg,
  --ok, --surface-1) had hardcoded fallbacks that broke dark mode;
  replaced with real tokens (--surface, --positive)
- cassandra.css: .pf-secondary was scoped to .pf-actions but used
  standalone in 4 places (sync modal, disable-sync, import cancel,
  forget-pie button) — hoisted to a top-level selector

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:19:42 +02:00
fb71854238 i18n: style the settings select + add a topbar lang toggle
Two issues addressed:

1. The /settings language <select> was unstyled — .settings-select and
   .settings-status classes didn't exist, so the dropdown rendered
   with full native browser chrome and clashed visually with the rest
   of the panel. Added a terminal-aesthetic select: transparent
   background, 1px var(--border), custom chevron via crossed
   linear-gradients, accent border on focus/hover. Disabled options
   (ES/FR/DE 'coming soon') render in --dim.

2. Added a compact EN | IT pill in the topbar next to the theme
   toggle, mirroring the .tone-toggle visual rhythm. Shown only when
   a user is signed in (admins skipped). Optimistic UI: clicking
   flips the pill immediately, PATCHes /api/settings/language, and
   reverts on failure. On /log specifically the page reloads so the
   user sees the localized version of the strategic log right away.

The /settings dropdown still surfaces all five languages (with ES/FR/DE
disabled) for visibility; the topbar pill keeps to the two active
languages to stay compact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:14:23 +02:00
50ac6b9366 settings: add language dropdown (IT active, ES/FR/DE WIP) 2026-05-27 17:17:18 +02:00
f4025e3cbb settings: PATCH /api/settings/language with ACTIVE_LANGUAGES gate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:16:17 +02:00
1ea71bc160 log: serve translated content when available; English fallback
Adds module-level _resolve_log_content(session, log_id, lang) helper
to app/routers/pages.py: looks up StrategicLogTranslation by (log_id,
lang) when lang != 'en'; falls back silently to the English original
when no translation row exists yet (the expected case for the first
hour after a new language activates, or when translation fails for a
specific log).

log_page / log_page_day pull cu.user.lang and thread it through
_log_page_context so the template renders the right variant.

Two tests cover both branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:13:57 +02:00
924f37548b digest: translate variants once per active non-en language
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:07:18 +02:00
d318039ad5 analyse: thread user.lang into the system prompt
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:01:00 +02:00
e4982cdc04 ai-log-job: translate strategic log for active non-en languages
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:57:06 +02:00
9423fa81b7 models: add User.lang + StrategicLogTranslation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:52:04 +02:00
7683f82820 i18n: add translate() helper backed by call_llm
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:48:32 +02:00
5730aad73c i18n: add LANGUAGES, ACTIVE_LANGUAGES, respond_in_clause helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:46:32 +02:00
1ecc527118 cleanup: drop dead upload.html + soften broker-only marketing copy
- Delete app/templates/upload.html. The /upload route redirected to
  /settings#import (302) and never rendered this template; the file
  was carrying stale Trading-212-only copy.
- Landing + pricing pages: replace "Trading 212 today, more brokers
  planned" with "Trading 212 natively, other formats auto-detected"
  to reflect the LLM-fallback parser that's been live for a few days.

The /upload redirect route in app/routers/pages.py stays — it remains
a useful bookmark-forwarder for users with old links.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:39:03 +02:00
11662c0ea8 portfolio-edit: add a quiet how-to hint inside the composer
A small italic muted line beneath the form explaining the controls:
"Type a symbol, then quantity and cost — or use the calendar to fill
cost from a buy date — then [+] to add. [×] next to an existing row
removes it."

Only renders while the composer itself is visible (i.e. in edit mode),
so it doesn't clutter the dashboard at rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:27:44 +02:00
6377c929b8 portfolio-edit: form is edit-mode only; submit becomes a + glyph
Two related polishes:

- The add form was auto-shown by the empty-state path so brand-new
  users would see something to act on. That conflicts with the user's
  preference for "Edit always toggles the form, no other path." The
  empty state now shows guidance copy ("click edit to add one")
  instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
  + glyph (26×26). Matches the visual weight of the calendar ghost
  next to it but stays in the accent colour so it reads as primary.
  Adds a tiny active-state scale tick for tactile feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:22:13 +02:00
2ffd228976 portfolio-edit: indent add form 12px to match panel-header
Composer had zero horizontal padding so the leading `$` prompt was
flush with the panel border. Match the panel-header's 12px horizontal
inset so the form sits inside the panel's content gutter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:20:20 +02:00
477a47be2c dashboard: drop "held locally · prices via /api/universe" meta line
Subtitle was technical noise that didn't earn its space in the header.
Title alone reads cleaner. Kept the scoped panel-header layout override
in cassandra.css since it's harmless and future-proof against re-adding
header children.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:19:52 +02:00
f997a8adde portfolio-edit: fix unescaped apostrophe in fallback string
A single-quoted string literal "couldn't validate" was breaking the
parse because the apostrophe wasn't escaped. The page logged a syntax
error and none of the edit-mode JS ran. Backslash-escape it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:15:46 +02:00
3de43f55a6 portfolio-edit: fix rogue Edit/Done/Add visibility
Two interlocking bugs surfaced after the design pass:

1. CSS `display: inline-flex` on .pf-edit-btn/.pf-done-btn overrode the
   UA's `[hidden] { display: none }`, so the JS toggling `editBtn.hidden`
   had no visual effect — both buttons rendered side by side.
2. portfolio.js's empty-state path sets `form.hidden = false` but the
   populated-portfolio render path only removed the `pf-empty` class; it
   never reset `form.hidden = true`. So once a user went through the
   empty state, the add form stuck around — leaving the Add button
   visible on a populated dashboard.

Fixes are surgical: add an explicit `[hidden]` rule for the two
header pills, and re-hide the form in `renderPanel` unless edit mode
is currently active (so we don't yank the form out from under an
edit-in-progress).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:09:34 +02:00
bb41ee38f7 portfolio-edit: design pass — terminal-aesthetic inline composer
The previous CSS used invented variable names (--neu-dim, --err, --ok)
that don't exist in the project's design system; the form fell back to
hardcoded hex values and looked disconnected from the rest of the site.

Rebuilt against the real tokens (--border, --dim, --muted, --positive,
--negative, --warning, --accent) and the mono-first 'geopolitical-
terminal aesthetic' the rest of the dashboard uses:

  $ ticker  ✓ 172.40 USD  │  qty  @  cost  USD  📅              add
                                                              ────

- No boxed-form chrome. A dashed bottom rule separates the composer
  from the table below.
- Inputs lose their card-style boxes; they're underline-only with a
  faint accent wash on focus — feels like editing a command line.
- '$' prompt marker, '│' divider, '@' between qty and cost give the
  row a terminal grammar without being twee.
- Submit is a ghost pill in the accent colour; lights to solid only
  when enabled.
- All controls now respond correctly to the light/dark theme toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:06:32 +02:00
a9b7d4d8bb portfolio-edit: rebuild form as compact inline strip
Replace the multi-row wizard-style form (Ticker / Qty on row 1, mode
radios on row 2, Date+Cost on row 3) with a single horizontal strip
that sits unobtrusively above the portfolio table. The radio toggle is
gone; a small calendar icon next to the Cost input pops out a date
picker that auto-fills cost on selection and then hides itself.

Same input IDs, so the existing validate/Add/× handlers work unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:02:03 +02:00
70da4cdf84 css: portfolio edit-mode + add-position form styles 2026-05-27 14:56:43 +02:00
9a46a0daec portfolio: render hidden × per row; empty state shows add form
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:55:29 +02:00
84934827b8 portfolio-edit: bought-on-date mode + historical lookup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:52 +02:00
58576a86fc portfolio-edit: add button writes position to localStorage
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:51:45 +02:00
ee6966399c portfolio-edit: ticker validate on blur + duplicate warning
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:50:48 +02:00
f1b242720d portfolio-edit: edit-mode toggle scaffold 2026-05-27 14:49:43 +02:00
9ed78f2758 dashboard: scaffold portfolio edit-mode markup 2026-05-27 14:49:01 +02:00
30e565909f ticker-validate: mount router at /api/ticker/*
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:48:00 +02:00
b7d6235fcb ticker-validate: add /api/ticker/historical with weekend-walkback
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:45:52 +02:00
3bb62763ea ticker-validate: add /api/ticker/validate endpoint
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:41:33 +02:00
bc55ab7d26 csv-parser: keep LLM-mapped tickers; don't pass them through T212 mapping
The route's resolve-slice loop is T212-specific — it looks tickers up
against the InstrumentMap, which only has T212's universe. For the LLM
path the ticker is already Yahoo-ready (e.g. VOD.L, ASML.AS), so
sending it through resolve_slice produced spurious "could not be
resolved" warnings and dropped the positions.

Fix: ParsedPie gains a ``tickers_resolved`` flag (default False for
T212 backward-compat); _apply_mapping in the LLM path sets it True
and also extracts currency from the LLM-mapped currency_col into a
new ``ParsedPosition.currency`` field. The route branches on the flag:
LLM-path positions are kept verbatim with a best-effort InstrumentMap
lookup for nicer name/currency overrides, never dropped.

Integration test tightened to assert all 5 IBKR fixture positions
round-trip with the right currencies (USD / GBP / EUR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:48:27 +02:00
b8ebba9503 ui: drop remaining T212-only framing from dashboard + import lede
- portfolio.js empty-state CTA: "Import a T212 CSV" → "Import a portfolio CSV"
- settings.html lede: lead with broker-agnostic copy; relegate the T212
  export path to a smaller secondary line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:41:05 +02:00
b352601228 settings: soften import copy to be broker-agnostic
Section heading drops "Trading 212"; drop-zone label and hint mention
the auto-detect path; the help-paragraph opens conditionally with
"If you use Trading 212" so non-T212 users don't feel like outsiders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:32:35 +02:00
8bc9dccd40 universe: paid-gate + LLM fallback on /portfolio/parse
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:31:07 +02:00
59b28506df csv-parser: add public parse_with_llm with cache hit/miss orchestration
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:24:38 +02:00
c77b3564f3 csv-parser: add _extract_mapping_via_llm with provider-failure wrapping
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:21:19 +02:00
b99f46d2fc csv-parser: add _apply_mapping helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:18:31 +02:00
f44b77df6f csv-parser: add _validate_mapping helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:16:26 +02:00
8dcf662945 csv-parser: add _detect_dialect helper
Heuristic refined from the plan draft: candidate header rows must be
followed by a row containing at least one numeric token. Without this,
IBKR-style multi-line preambles (all-text rows before the real header)
would be mistaken for the header at preamble=0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:14:11 +02:00
f8a0ed3923 csv-parser: add _fingerprint helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:08:34 +02:00
fb3498efb0 csv-parser: tighten CsvFormatTemplate generics + last_used_at note
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:01:06 +02:00
3f1d2a1034 csv-parser: add CsvFormatTemplate model
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:51:01 +02:00
1be0c5a436 docs: drop Phase D.x markers now that the referral loop is closed
The "Phase D.1/D.2/D.3" comment scaffolding and the "Paddle webhook
will fill this in" references became actively misleading after D.3
landed — anyone reading the code would think referral conversion was
still pending. Also corrects a stale "Paddle" reference to "Stripe"
(we never shipped Paddle; ended up on Stripe after the Paddle → Polar
→ Stripe MoR onboarding pivot).

Pure docstring sweep, no behaviour change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:09:39 +02:00
ce36ce36fd referrals: close D.3 — both parties get 45 days credit on conversion
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.

Closing the loop:

- New `convert_referral(session, user)` in referral_service.py looks
  up the user's Referral row, stamps `converted_at` + `credited_at`,
  and extends `credit_until` by 45 days on BOTH the buyer and the
  referrer. Idempotent — replayed webhooks and renewals are no-ops.
  Stacks correctly when the user already has a credit window running
  (anchors at max(now, current_credit_until) like cli.grant_credit).

- Stripe webhook wires this into `_grant_paid`. A captured
  `first_paid_transition = user.tier != "paid"` gate avoids the DB
  lookup on every renewal event; convert_referral's own idempotency
  is the second line of defence.

- `_grant_paid` now takes `session` as its first positional arg so
  the conversion runs inside the same transaction as the tier flip
  and audit-row write. A mid-flight failure rolls everything back
  together — no partial state.

- Settings page replaces the "— (D.3)" placeholder with the live
  count of conversions still inside their 45-day credit window, plus
  a "+N days on your account" hint when the user has any credit of
  their own (referrer bonus, admin grant, or future refund-as-credit).

- Marketing copy on pricing.html + settings.html switches from "50%
  off for 3 months" to "45 days of paid access" — same economic value,
  honest about the actual mechanism (full free access rather than
  discounted billing).

Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.

7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.

Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:05:29 +02:00
00211fec02 ui: collapsible settings sections + welcome-email + larger auth inputs
Settings page tidy-up driven by user feedback that it had grown too busy:

  - Each section (Import, Invite, Email digests, Cloud sync) is now a
    native <details>/<summary> accordion. Import stays open by default
    because /settings#import is the deep-link target from the dashboard
    CTA; the others collapse so the page lands quiet.
  - Manage subscription is a right-aligned gear-icon button instead of
    a rectangular text button — the descriptive copy moves into the
    tooltip. Frees up the Tier row of visual weight.

Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.

The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.

Tests rewritten to cover the new welcome-email path:
  - first login sends exactly one welcome email
  - returning user gets none
  - SMTP failure does not break the redirect
  - regression guard: returning user who opted out stays opted out

Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:32:59 +02:00
a07fd144ea stripe: per-cadence cooling-off + manage-subscription button
Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:

1. Manage subscription button on /settings (paid users with a real
   Stripe sub — i.e. not credit-granted access). POSTs to the existing
   /api/stripe/portal endpoint; Stripe-hosted customer portal handles
   card updates, cancellation, monthly↔annual switch, invoice history.
   Replaces the stale "Paid features unlock with Paddle (D.3) or
   invite credits" hint for free users with a live link to /pricing.

2. Per-cadence cooling-off treatment:

   - **Annual £70**: 14-day free trial via
     subscription_data.trial_period_days=14. No money moves during
     the trial, so the CCR 2013 14-day refund question doesn't arise
     (nothing paid = nothing to refund). Card is still required at
     checkout so Stripe can charge on day 15.

   - **Monthly £7**: bills immediately. A 14-day trial there would
     give away ~50% of cycle one. Instead, /pricing now carries a
     required tick-box above the Subscribe buttons (subscribe stays
     disabled until checked) — by ticking, the user expressly
     consents to begin performance immediately and acknowledges that
     this extinguishes their statutory 14-day right under Reg 36
     CCR 2013. Consent collected on our own page (not via Stripe's
     account-wide consent_collection.terms_of_service) so each
     product can keep its own Terms URL as we add more.

3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
   1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
   (post-cooling-off cancellation) unchanged.

Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.

Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.

Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
  consent_collection), annual (trial, no consent), trialing stores
  trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:06:19 +02:00
62960d5bea security: stop localStorage leaking portfolios across users
Bug: the per-browser pie was stored under a single global key
(`cassandra.pie`) with no per-user scope. If User A uploaded a
portfolio and User B then signed in on the same browser, User B saw
User A's holdings — portfolio.js read straight from localStorage on
hydration with no check that the data belonged to the current session.

This was not a server-side leak: the session cookie was correct, no
API returned User A's data to User B. The stale browser state was the
sole vector. Reported by the operator while testing the paid-checkout
flow with a second account on the same browser.

Fix — defense in depth, two layers:

1. base.html now stamps cu.user.id into localStorage as
   `cassandra.user_id` on every authenticated page load. If the
   previous stamp doesn't match the current user, wipe localStorage
   (preserving only `cassandra.theme`, which is cosmetic) and
   sessionStorage before any other script runs. This catches:
   - the reported scenario (User A logs out, User B logs in)
   - any case where logout missed the wipe (JS disabled, browser
     killed before the redirect ran)
   - cookie-revocation / session-rotation edge cases where the
     server-side identity changes without an explicit logout

2. /logout no longer returns a bare 303; it returns a small HTML
   page that actively wipes per-user localStorage + sessionStorage
   client-side (theme preserved), then redirects to /login. A
   meta-refresh covers the no-JS case (the cookie deletion is
   still server-side, so security is preserved either way).

Behaviour for the legitimate case (same user logs out + back in)
is unchanged: their localStorage data survives because the
mismatch check sees the same user_id and doesn't fire — the
logout wipe runs but they re-stamp + re-upload only the
cassandra.user_id and a fresh pie cycle if they choose to upload.

Suite: 221 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:14:17 +02:00