User reported phone still showing old behaviour (Qty/Avg portfolio
columns visible) even though the server-side JS had been updated.
Root cause: every <link>/<script> URL was a plain
/static/css/foo.css with no query string, so mobile Chrome served
the file from its HTTP cache rather than refetching it.
Adds a process-startup timestamp to the Jinja environment as
ASSET_VERSION (computed once when templates_env is imported). Every
<link>/<script> reference now appends `?v={{ ASSET_VERSION }}` so a
container restart bumps the URL and the browser refetches. 38 URLs
across 8 templates updated via sed; tests still pass.
Side benefit: future CSS/JS edits no longer require users to hard-
refresh.
The portfolio table is rendered client-side in portfolio.js (not by
the partials/portfolio.html Jinja template, which is unused for this
view). The previous commit's mobile-hide class made it into the
template but never reached the actual DOM. Adding the class to the
JS-emitted <th> and <td> strings so .dense .mobile-hide { display:
none } actually picks them up at ≤480px.
Three pieces of phone-side feedback:
1. Indicator group tabs wrap onto multiple rows instead of
horizontal-scrolling — every group is visible at a glance. Each
button keeps its own bottom border so wrapped rows stay
visually delimited; the container's bottom border is removed.
2. Portfolio holdings table hides Qty and Avg columns on mobile via
the mobile-hide class (same mechanism as the indicator table).
Remaining columns are the actionable ones: Ticker, Name, Last,
P/L, %.
3. Markets bar at the bottom compacts to one row per chip —
dot + code + change% only. The state word ("open" / "closed")
is implied by the dot colour; the index label, price, and
until-time are dropped on mobile. Grid columns drop their 220px
floor so the full set fits the viewport without horizontal
scroll (previously the bar scrolled within itself).
User reported the page rendering at ~3x viewport width on Android
Chrome with overflow-x:hidden clipping off most of the content.
Root cause: CSS grid items default to min-width:min-content, and the
indicator table inside the indicators panel has white-space:nowrap
cells. A long Symbol/Label value forces the table wider than its
panel; the panel propagates that minimum width up the grid; the grid
expands the .app-main; .app-main pushes the page wider than the
viewport. overflow-x:hidden then just chops the right portion off.
Fix has three parts:
1. .app and .app-main get min-width:0 and max-width:100vw so the
shell can't be wider than the viewport regardless of descendants.
2. Every direct child of .app-main (each panel) gets min-width:0
on mobile so individual panels can shrink past their min-content.
3. table.dense drops white-space:nowrap on text cells at ≤480px —
long symbols wrap to two lines instead of forcing the table wide.
Numeric cells keep nowrap (negative percentages reading as
"−12\n.34%" would be unreadable).
Also adds an overflow-x:auto fallback on .panel-body pre/code so
any code block in AI output scrolls within the panel instead of
blowing the page out.
Two related bugs reported on phone:
1. Drawer was unclickable — backdrop covered it. Root cause: the
.app-header (position:sticky, z-index:50) creates a stacking
context, so the drawer inside it had its z-index:100 clamped to
"above other things inside the header" but NOT above siblings of
the header. The backdrop at root-level z:90 then sat over the
drawer subtree.
Fix: when body.drawer-open, raise .app-header z-index to 110
so its entire descendant tree (drawer included) draws above the
z:90 backdrop. The page body under the header stays dimmed.
2. Horizontal scrolling on the dashboard. Root cause: the bottom
markets bar used `grid-template-columns: repeat(auto-fit,
minmax(220px, 1fr))`, which at 4+ markets blows out to 880px+ and
forces the page wider than the viewport.
Fix: on ≤480px the markets bar becomes a horizontally scrolling
flex strip with min-width:160px per chip — page stays narrow,
user swipes the bar to see more markets.
Also added overflow-x:hidden to html/body as a defensive net against
the fixed off-screen drawer creating overflow on Safari iOS.
Adds the @media (max-width: 480px) blocks specified in the design:
- dashboard.css: indicator table hides the 'mobile-hide'-tagged
columns (Label, Ccy, 1y, anchor, as-of), keeping Symbol / Price /
1d / 1m. Cell padding + font shrink. Group-tab buttons get a
bigger touch target.
- panels.css: header padding tightens, scroll-body max-height drops
to 60vh so log/news stay above the fold in the stacked layout.
- portfolio.css: overall grid keeps 2 cols (already at 640px) with
tighter gap; action buttons wrap; composer input goes full-width.
- log-chat.css: chat bubbles edge-to-edge, input row stacks, font-
size:14px on form fields to avoid iOS Safari zoom-on-focus.
- news.css: row collapses to age | (title / source) — source moves
under the title. Tag filter strip wraps.
- settings.css: form rows stack (label above input). Import picker
becomes single-column. Buttons full-width.
- auth.css: card padding tightens to free up vertical space when the
iOS keyboard is up. font-size:14px on inputs.
- public.css: hero headline clamp() lower bound drops to 22px; CTAs
stack full-width; pricing tier-grid stacks.
indicators.html: tagged the secondary cells with .mobile-hide rather
than relying on positional nth-child — the anchor column is
conditional and would have shifted positions.
336 tests still pass.
≤480px gets a hamburger button in the topbar and a fixed slide-out
panel from the right edge (width min(82vw, 320px)). The topbar keeps
only brand + tone toggle + hamburger visible; nav and the
header-right widgets (theme, lang, user menu, version meta) move
into the drawer.
Markup change: nav and .header-right are now wrapped in
.mobile-drawer, which is display:contents on desktop (no layout
effect) and a fixed translateX panel on mobile. The user-menu
dropdown chip hides on mobile and its links surface flat inside the
drawer.
JS: ~50 lines of vanilla. Tap hamburger / backdrop / ESC / swipe-
right-on-drawer all close. Clicking a nav link inside the drawer
closes it after the navigation kicks off so the panel doesn't linger
on the next page.
CSS: per-file @media block at the bottom of layout.css per the
agreed-upon organisation.
Pass `currency` to Stripe checkout for first-time buyers so Stripe
picks the matching `currency_options` rate configured on the Price
in the Dashboard (multi-currency Prices: one Price, per-currency
unit_amount). Operator configures the rates on existing Prices
prod_UaZ0xCpCboUGCN/price_*; this commit is the application-side
signal.
Currency precedence: explicit request body > Cloudflare cf-ipcountry
header > Accept-Language locale > GBP fallback. Only honoured when
the user has no stripe_customer_id yet — Stripe locks currency to
the customer record at first checkout, so existing customers keep
their original currency (they can switch via the portal).
Adds 4 tests: sniffed currency on new customer, body override beats
sniff, currency omitted for existing customer, and unit-tests for
the sniffing fallback chain.
Previously translate_log_for_active_languages and
translate_summary_for_active_languages added every successful
translation to the session and called session.commit() once at the
end. A single bad row (DB error, constraint violation, encoding
mismatch) rolled back the whole batch — losing all the languages that
had succeeded.
Wrap each row in session.begin_nested() so a per-row failure only
loses that one row. Track succeeded/failed counts and log them at the
end — escalating to error if zero succeeded out of N attempted, so
total failure surfaces in monitoring instead of just N warning lines.
DeepSeek's native API returns prompt_tokens/completion_tokens but not
`usage.cost`. OpenRouter returns both. Result: with DeepSeek-direct as
primary (current default), every LogResult.cost_usd was None — and
every downstream cost ledger row (AICall, StrategicLog,
IndicatorSummary, translation tables) stored None instead of the real
spend.
Added a per-model rate table and fallback computation in _call_provider:
when the upstream omits cost, multiply tokens by the table rates. If the
upstream DOES return cost, keep it (authoritative). Falls back to None
if both the upstream and the table miss.
deepseek-v4-flash rates: \$0.07/M input, \$0.28/M output (per DeepSeek).
Splits the 2571-line cassandra.css into ten focused stylesheets:
tokens (palette + fonts), layout (chrome), panels, dashboard,
portfolio, log-chat, auth, settings, news, public. base.html and
public_base.html load only what they need; auth pages (login,
verify, unsubscribe confirm) load tokens + layout + auth.
Brand drift-detection test repointed at tokens.css (where the
palette now lives). 291 tests still pass.
api.py was 933 lines mixing four distinct concerns: indicators +
news + strategic log (the JSON/HTMX API proper), the chat endpoint
+ its three private helpers (~200 lines), and the two HTML-only ops
endpoints /markets-bar + /health (~150 lines).
Extracted:
- app/routers/chat.py — POST /api/chat + _latest_quotes_by_group_chat,
_thesis_headlines_for_chat, _month_spend
- app/routers/ops.py — GET /api/markets-bar + GET /api/health +
_fmt_price helper
Both new routers use the same dependencies=[Depends(require_token)]
as api.py and are mounted at the /api prefix in app/main.py.
URL surface is byte-identical with no externally-visible change.
api.py shrinks to ~620 lines focused on indicators+news+log+settings.
Helpers shared with the original api.py (_md_to_html, _resolve_tone_param)
are imported from app.routers.api where needed in chat.py to avoid
duplication.
Also updated tests/test_chat_and_log_gates.py to mount chat_router
in its local test app, since /api/chat now lives there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
email_service.py was 428 lines covering three different concerns:
SMTP transport, OTP/welcome rendering (tightly coupled — same brand
template + theme), and digest rendering (a totally different shape
of email, different layout, different copy cadence). The two halves
changed at different cadences and made the file noisy to navigate.
Extracted render_digest_email + _DIGEST_HTML_TEMPLATE +
_strip_html_to_text to app/services/digest_email.py. SMTP transport
and the OTP/welcome surface stay in email_service.py.
Import sites updated: email_digest_job and test_email_render now
import render_digest_email from digest_email. The OTP/welcome
import sites (auth router, branding tests, test_email_service) are
untouched.
No behaviour change — pure relocation. Templates byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
openrouter.py was 790 lines mixing two orthogonal concerns:
- Prompt engineering (build_system_prompt, build_summary_*,
build_chat_*, build_daily_digest_*, etc.) — ~400 lines, changes
weekly as PROMPT_VERSION bumps
- LLM transport (call_llm, _provider_chain, _call_provider, retry
+ fallback machinery) — ~250 lines, rarely changes
Extracted the prompt-engineering surface to app/services/llm_prompts.py.
Transport stays in openrouter.py (consistent with the filename — the
OpenRouter URL is the transport's anchor).
All import sites (jobs, routers, services, tests) split their
multi-import lines into two: prompt-things from llm_prompts, transport
from openrouter. PROMPT_VERSION constant, _TONE_ALIASES, _resolve_tone,
and SYSTEM_PROMPT moved with the prompt functions.
No behaviour change — pure relocation. Function signatures, body, and
naming all preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three recently-added tables (strategic_log_translations,
indicator_summary_translations, csv_format_templates) drifted from
the codebase's existing naming convention:
- llm_model -> model
- llm_cost_usd -> cost_usd
- content_md -> content (on the two translation tables; csv_format
doesn't have a content field)
Also added prompt_tokens and completion_tokens to the three tables;
they were silently dropped at write time despite LogResult exposing
them.
All writer call sites (ai_log_job, indicator_summary_job,
llm_csv_parser) and reader call sites (api.py localized helpers)
updated to match. Tests realigned.
Migration 0025 uses batch_alter_table for SQLite compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two largest inline <script> blocks in settings.html — the cloud
sync modal/management UI (~145 lines) and the import widget wiring
(~245 lines) — moved to app/static/js/settings-sync.js and
settings-import.js respectively, included via <script src="..."
defer> at the bottom of the template.
Where the inline code referenced Jinja vars or {% if %} guards,
those values are now passed via data-* attributes on the relevant
DOM elements (or via window.cassandra* config objects for structured
data) and read in the static JS.
Smaller blocks (Stripe portal, digest prefs, language select,
invite copy) stay inline — each <40 lines and easier to follow
next to their markup. settings.html drops from 758 lines to roughly
half that.
The theme toggle's onclick attribute held a 140-character inline
IIFE that was hard to read amongst the other named-function
handlers in the same header. Promoted it to cassandraToggleTheme()
alongside cassandraSetTone / cassandraSetLang.
The HTMX log endpoints in api.py do their own localization via
_localized_content; the pages.py helper was added during the
initial localization wiring but was bypassed once HTMX rendering
landed. No call sites remain.
The /log page renders its content asynchronously by hitting
/api/log/latest?as=html and /api/log/by-date/{day}?as=html via HTMX.
Both endpoints returned StrategicLog.content (English) verbatim,
ignoring the new StrategicLogTranslation table entirely. The
_resolve_log_content helper I added to pages.py earlier was wired
into the page handlers themselves but never reached for HTMX swaps,
so Italian users only ever saw English content despite their
lang='it' preference being persisted and translations being
generated correctly.
Fix: add a _localized_content helper in api.py that looks up the
matching translation row for the requesting principal's lang.
_log_partial_payload gains a content_override arg; both HTMX
endpoints (log_latest, log_by_date) compute the override and pass
it through. JSON paths (?as= other than html) remain English to
avoid changing the public API contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related schema fixes from the code review:
- users.lang gets a single-column index. The ai_log_job and
email_digest_job both SELECT DISTINCT on this column every cycle;
even at low cardinality an index is the right shape.
- quotes_daily.symbol widened to VARCHAR(128) to match quotes.symbol
(widened back in 0005). Long Eurostat/ONS symbols would silently
truncate during rollup otherwise.
Models updated to match (User.lang gains index=True, QuoteDaily.symbol
goes to String(128)).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pyproject already sets asyncio_mode=auto, so async def tests are
collected as async automatically. Removed the redundant decorator
from four files (test_i18n, test_llm_csv_parser, test_ticker_validate,
test_localization_integration); the bare async def is enough.
- StrategicLogTranslation.log_id used the _PK autoincrement type for
a non-PK FK column. Replaced with a portable BigInteger that emits
Integer on SQLite and BigInteger elsewhere — matches the migration's
sa.BigInteger() declaration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>