Commit graph

12 commits

Author SHA1 Message Date
a55168d20a ui: log panel stretches to portfolio bottom; AI analysis stays expanded
Two small fixes to the dashboard right column based on user feedback:

1. layout.css — drop align-self:start from #log-panel.
   The panel previously shrank to its content, leaving the right-hand
   column visually shorter than the indicators+portfolio stack on the
   left. Removing the override lets the grid stretch the panel to the
   full row span so the two columns now bottom-align. The log content
   still sits at the top of the panel; any extra height is empty
   padding inside the box.

2. portfolio.js — re-hydrate AI analysis expanded.
   The 60s auto-refresh rebuilds the portfolio mount and re-attaches
   the previously-generated analysis from localStorage, but the
   <details> element was re-attached with open:false — collapsing it
   under the user's cursor every minute. Users reasonably perceived
   that as "the analysis disappeared". Hydrate as open:true so the
   body stays visible; the user can still click the summary to
   collapse manually within a refresh window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:35:10 +02:00
1a20f0a15b mobile: tag Qty/Avg cells in JS-rendered portfolio table
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.
2026-05-28 19:13:52 +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
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
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
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
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
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
5c7cc4c6aa sync: detect orphaned blobs (pepper rotation) + fix AESGCM arg order
Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync
rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine
GCM corruption → 500), and the UI silently cleans up the dead row and
shows a soft "please re-import" notice instead of a confusing PIN
re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and
backfilled on success.

Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped
(ct, nonce instead of nonce, ct), so restore-from-cloud always failed
even when the pepper was correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:49:11 +02:00
f326b41a08 sync: encrypted cloud backup for portfolios + settings UX rework
Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:15:54 +02:00
6e7f57c6b2 phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.

Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.

LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.

Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00