Commit graph

126 commits

Author SHA1 Message Date
f4d9c9f2ec settings: extract sync + import widget JS to static files
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.
2026-05-27 20:55:49 +02:00
dcc2c07111 tests: extract _build_session_factory to a shared conftest fixture
The same per-test sqlite-engine setup was duplicated across 14 test
files (~30 lines each). Consolidated into a single async fixture
`db_factory` in tests/conftest.py; tests now take db_factory as a
parameter and use `async with db_factory() as session` directly.

No behaviour change — same function-scope, same in-memory schema
created via Base.metadata.create_all, same app.db._engine /
_session_factory rebinding so module-level helpers see the test
engine. Just ~420 lines of boilerplate removed.
2026-05-27 20:50:09 +02:00
b13caa4c51 ui: name the theme-toggle handler instead of an inline IIFE
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.
2026-05-27 20:41:31 +02:00
eedd32b885 log: remove dead _resolve_log_content helper from pages.py
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.
2026-05-27 20:39:28 +02:00
664757ea8a i18n: localize indicator summaries (per-group + aggregate read) 2026-05-27 20:19:47 +02:00
7acd191051 log: serve localized content from the HTMX endpoints
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>
2026-05-27 20:08:54 +02:00
eb31d09782 alembic: 0023 — users.lang index + widen quotes_daily.symbol
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>
2026-05-27 19:35:09 +02:00
308878749f cleanup: drop redundant @pytest.mark.asyncio + fix log_id type
- 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>
2026-05-27 19:32:38 +02:00
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
e190d0e35b alembic: add 0022 localization (users.lang + strategic_log_translations) 2026-05-27 16:53:27 +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
2ecf250d53 localization: digest is shared, not per-user (corrected design)
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>
2026-05-27 16:22:41 +02:00
8af1da12dd docs: implementation plan for Italian localization
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>
2026-05-27 16:13:29 +02:00
e6308260a5 docs: localization spec — explicit no-tier-gating decision
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>
2026-05-27 16:08:28 +02:00
76f81648e5 docs: spec for Italian localization (ES/FR/DE as WIP)
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>
2026-05-27 15:50:10 +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
ca953e5ea2 ticker-validate: cover failure + side-effect paths
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:43:15 +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
ae3f104fa7 docs: implementation plan for manual portfolio composition
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>
2026-05-27 14:36:32 +02:00
4c92d8a3e7 docs: spec for manual portfolio composition
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>
2026-05-27 14:23:23 +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