- 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>
The user pointed out that the only genuinely per-user AI surface is
portfolio analysis. The strategic log AND the email digest are both
shared cycles — generated once per cycle, consumed by many users.
For the digest, this means:
- _generate_variants still produces one English variant per tone (as
today, unchanged)
- A new helper translates each variant once per active non-en lang in
parallel via asyncio.gather, producing a {(tone, lang): content}
table for the duration of the job run
- The per-user send loop selects (user.digest_tone, user.lang),
falling back to the English variant of the same tone on miss
Translation count per run = tones × non-en active langs = 3 today.
100 Italian users no longer mean 100 translation calls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 TDD-style tasks: i18n service, translation helper, model + migration,
ai_log_job translation fan-out, per-user surfaces (analyse, digest),
localized /log endpoint, PATCH /api/settings/language, dropdown UI, and
final regression + manual smoke.
Per-user surfaces append "Respond in Italian." to the system prompt
(one extra line, no extra LLM call). The strategic log is generated in
English, then fanned out to translate() per active non-en language in
parallel via asyncio.gather. The /log endpoint serves the matching
translation row when present, English fallback otherwise.
Translation uses the default call_llm provider chain — no separate
cheap-model carve-out needed at DeepSeek's $0.28/M output pricing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Translate for any user with lang='it' regardless of paid/free status.
Italian + UK are the first markets, so IT availability is part of the
public-facing experience — a free-tier visitor needs to see the AI in
Italian to convert. At ~$0.005/day total cost the gating isn't worth
the savings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hybrid model: per-user surfaces (analyse, digest, chat) generated
directly in the target language via a "Respond in Italian" clause
appended to the system prompt. Shared content (strategic log)
generated in English as today, then post-translated and cached per
language in a new strategic_log_translations table. Translation calls
fan out in parallel with asyncio.gather so total job latency stays
bounded by max(single call).
No separate translation-model setting — DeepSeek-4-flash at $0.28/M
output is cheap enough that the routine cost is noise (~$0.005/day
with Italian only at 24 logs/day).
Users.lang VARCHAR(8) DEFAULT 'en'. Settings dropdown lists all four
options but ES/FR/DE are disabled UI-side and rejected server-side
against an ACTIVE_LANGUAGES allowlist — flipping them on later is a
one-line constant change.
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>
12 TDD-style tasks: two backend endpoints (validate + historical),
router registration, dashboard markup, and five JS slices building the
edit-mode behaviour (toggle → ticker validate → Add → date-mode →
delete via delegation). CSS pass and final manual smoke close it out.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dashboard-native edit mode: EDIT button toggles in-place editing; the
add-position form has on-blur ticker validation against a new paid
endpoint, qty input, and an avg-cost / bought-on-date toggle. Only
avg_cost + qty are persisted to localStorage (no acquisition date,
no server-side holdings). Empty state replaces "Import a CSV" with
the inline form so brand-new users can act without leaving the page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
- 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>
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>
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>