From eabf8b6a7fe91d5d6345b05b22e97f5cdfb7ae05 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 18:06:15 +0200 Subject: [PATCH] docs: spec for beta indicator + paid/free gap (digests + news cap) Design doc for three coordinated closed-beta changes: a BETA chip in the app header, a 6h news-window cap on the free tier, and email digests (daily for paid Mon-Sat, Sunday weekly for everyone). Draft; awaits implementation plan. Co-Authored-By: Claude Opus 4.7 --- ...026-05-25-beta-mode-and-paid-gap-design.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md diff --git a/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md b/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md new file mode 100644 index 0000000..fa628c9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md @@ -0,0 +1,338 @@ +# Beta Mode + Wider Paid/Free Gap — Design Spec + +**Date:** 2026-05-25 +**Status:** Draft — awaiting approval +**Scope:** Three coordinated changes that ship together because they all +target the closed-beta launch: (1) a visible BETA indicator in the app +chrome, (2) a free-tier cap on the news feed window, and (3) email +digests — daily for paid, Sunday-weekly for everyone. + +## 1. Goals + +- Set expectations for closed-beta testers that the product is still + evolving, without making the app look unfinished. +- Create a tangible reason to upgrade beyond portfolio import + sync. + Today the free tier carries nearly the entire editorial layer; after + this change, paid gets meaningfully more. +- Use existing infra (scheduler, SMTP, OpenRouter, settings page) + rather than introducing new dependencies. + +## 2. Non-goals + +- No payment / Paddle work. Pricing copy is updated, but checkout is + still gated behind the existing "Coming soon" CTA. +- No per-user personalised digests. Same content for all recipients. +- No timezone handling for users. Fixed 06:30 UTC daily send. +- No new analytics / metrics dashboards for opens or clicks beyond a + simple audit row per send. + +## 3. Component overview + +| Component | Change | +|-----------|--------| +| `app/templates/base.html` | BETA chip in header next to brand. | +| `app/static/css/cassandra.css` | `.beta-chip` styles. | +| `app/config.py` | `BETA_MODE: bool = True` env flag. | +| `app/services/access.py` | `FREE_NEWS_WINDOW_HOURS = 6` constant. | +| `app/routers/api.py:222-280` (`news_list`) | Soft-auth dep + free-tier window clamp. | +| `app/templates/partials/news.html` | "Showing last 6h — upgrade for 24h" footer when capped. | +| `app/models.py` | New columns on `User`: `email_digest_opt_in: bool`, `digest_tone: str | None`. New table `EmailSend(user_id, kind, sent_at, status, error)`. | +| `alembic/versions/0017_email_digest.py` | Migration. | +| `app/services/openrouter.py` | Add `build_daily_digest_prompt(tone)` and `build_weekly_digest_prompt(tone)`, bump `PROMPT_VERSION`. | +| `app/services/email_service.py` | Add `render_digest_email(kind, tone, content)` + `send_digest(user, kind, tone, html, text)`. | +| `app/jobs/email_digest_job.py` (new) | Daily-or-weekly orchestrator. Generates content once per tone, fans out to recipients. | +| `app/scheduler_main.py` | Register the new job at 06:30 UTC. | +| `app/templates/settings.html` | "Email digests" section: opt-in toggle, tone radio. | +| `app/templates/login.html` (post-OTP-verify flow) | Default-checked "send me the digest" checkbox. | +| `app/routers/auth.py` | OTP-verify handler reads the new `subscribe_to_digests` form field and sets `email_digest_opt_in` on the new `User`. | +| `app/routers/settings.py` (or wherever existing settings PATCH lives) | New `PATCH /api/settings/digest` endpoint: body `{opt_in: bool, tone: "NOVICE"|"INTERMEDIATE"}`. | +| `app/routers/email.py` (new) | `GET /email/unsubscribe?token=...` — HMAC-verified one-click off switch. | +| `app/templates/pricing.html` | Updated bullets — free gets weekly digest + last 6h news; paid gets daily digest + last 24h news. | + +## 4. Detailed design + +### 4.1 BETA chip + +`app/templates/base.html` only (not `public_base.html`). Insert +immediately after the brand link: + +```html +{{ BRAND_NAME }} +{% if BETA_MODE %}BETA{% endif %} +``` + +The `BETA_MODE` flag is injected into the template context globally — +add it to `app/templates_env.py` so every render gets it (analogous to +how `BRAND_NAME` is provided today). + +CSS: small uppercase pill with `var(--accent)` background and brand-bg +foreground; spaced ~8px from the brand link. Sizing matches the +existing top-right `.meta` chip so the visual weight is balanced. + +`BETA_MODE` defaults to `True` in `app/config.py`. Flip to `False` for +general launch — one-line change. + +### 4.2 Free-tier news cap (6h window) + +`app/routers/api.py`'s `news_list` (lines 222-280) currently has no +auth dep. Add `principal: CurrentUser | None = Depends(maybe_current_user)` +(soft-auth — keeps the endpoint reachable by anonymous visitors, +matching today's behaviour). + +Compute the effective window: + +```python +window = since_hours +if not is_paid_active(principal): + window = min(window, FREE_NEWS_WINDOW_HOURS) # 6.0 +cutoff = utcnow() - timedelta(hours=window) +``` + +`FREE_NEWS_WINDOW_HOURS = 6.0` lives in `app/services/access.py` as a +module-level constant. Tuning it later is one edit; promoting it to env +config is YAGNI for now. + +`is_paid_active` already accepts `CurrentUser | User | None` and admins +auto-pass — no special-casing needed here. + +When the cap is in effect, pass `capped: True` into the news partial so +it can render a soft footer: + +> Free tier — showing the last 6 hours of news. [Upgrade] for the full +> 24-hour feed plus daily and weekly email digests. + +When the user is paid (or admin), the footer is absent. When the +visitor is anonymous, the link goes to `/pricing`. + +### 4.3 Database changes + +```python +# app/models.py — User columns added: +email_digest_opt_in: Mapped[bool] = mapped_column(Boolean, nullable=False, + default=True, server_default=text("1")) +digest_tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE | INTERMEDIATE +``` + +Defaults to opted-in (matches the chosen UX). `digest_tone` is nullable; +NULL is interpreted as INTERMEDIATE at render time. + +New table: + +```python +class EmailSend(Base): + __tablename__ = "email_sends" + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, index=True) + kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly" + sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + default=utcnow, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error" + error: Mapped[str | None] = mapped_column(String(255)) + __table_args__ = ( + Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"), + ) +``` + +Idempotency: the job queries `EmailSend` for the current day before +sending, so a job restart can't double-deliver. + +Migration: `alembic/versions/0017_email_digest.py`. + +### 4.4 Digest content generation + +Two new prompts in `app/services/openrouter.py`: + +- `build_daily_digest_prompt(tone)` — returns a `(system, user)` pair. + Pulls the same `quotes_by_group` + `headlines_by_bucket` data as + the hourly log but with a 24h headline window and a different + instruction set: less "current state" framing, more "what mattered in + the past day, what to watch today". Target length ~600 words. +- `build_weekly_digest_prompt(tone)` — 7-day headline window, weekly + recap + week-ahead anticipation. Target length ~900 words. + +Both reuse `call_llm()` and the existing cost-cap / ledger plumbing. +`PROMPT_VERSION` is bumped so the audit trail is unambiguous. + +For each digest run, the job generates **two** variants +(NOVICE + INTERMEDIATE) and stores them in memory for the fan-out +batch. There is no DB persistence of digest content — emails are the +artefact. If we later want to render them on a web archive, that's a +separate spec. + +Cost: at our current model pricing, two daily generations ≈ $0.04/day, +two weekly generations ≈ $0.06/week. Both well under the existing +`OPENROUTER_MONTHLY_CAP_USD` headroom. + +### 4.5 Email rendering and delivery + +`render_digest_email(kind, tone, html_body, text_body) -> (subject, +text, html)` in `email_service.py`. Wraps the LLM output in the same +multipart template family used for OTPs (light/dark palette, inline +styles, monospace stack, 520px max-width). Adds two footer rows: + +- "Don't want these? [Unsubscribe in one click](.../email/unsubscribe?token=...)" +- "Or change your preferences in [Settings](.../settings)" + +Subjects: + +- Daily: `"Read the Markets · Daily — {date}"` +- Weekly: `"Read the Markets · Weekly recap — {date}"` + +Fan-out: one SMTP send per recipient. Sequential with a small +`asyncio.sleep(0.1)` between sends to stay under common SMTP rate +limits. Failures are caught per-recipient, logged into `EmailSend`, +and don't block the rest of the batch. + +### 4.6 Sign-up opt-in checkbox + +The OTP-verify POST handler in `app/routers/auth.py` is where the user +is first established — that handler reads a `subscribe_to_digests` +form field and persists it into `User.email_digest_opt_in`. Default to +`True` if the field is absent (covers older clients or curl flows). +The verify template (`app/templates/verify.html`) gets a checkbox: + +```html + +``` + +Pre-existing users get `email_digest_opt_in=True` from the migration's +server-side default — but see §6 for the cutover plan. + +### 4.7 Settings page + +In `app/templates/settings.html`, add a section: + +``` +Email digests + [✓] Send me digests + Free tier: Sunday weekly. Paid: daily + Sunday. + Reading level: ( ) Novice (•) Intermediate + Last delivery: 2026-05-24 06:30 UTC — sent +``` + +The "Last delivery" row reads the most recent `EmailSend` row for this +user. If none, shows "—". + +Wire it via the existing settings JS pattern (look for the sync / +tone-toggle handlers in `static/js/`); the endpoints are +`PATCH /api/settings/digest` with body `{opt_in: bool, tone: str}`. + +### 4.8 One-click unsubscribe + +`GET /email/unsubscribe?token=`: + +- Token is `itsdangerous.URLSafeSerializer` over `{"uid": user_id, + "purpose": "digest_optout"}`, signed with `CASSANDRA_SECRET`. +- Handler verifies, flips `email_digest_opt_in=False`, renders a tiny + confirmation page ("You're unsubscribed. Re-enable any time in + [Settings](/settings)."). +- No auth required — that's the whole point of one-click unsubscribe. +- Replay-safe: re-running the same URL is idempotent (the column + is already false; the page renders the same confirmation). + +### 4.9 Scheduler integration + +`app/scheduler_main.py` already runs the hourly jobs. Add: + +```python +schedule_daily( # whatever helper exists, or apscheduler equivalent + "email_digest_job", + hour=6, minute=30, + target=email_digest_job.run, +) +``` + +The job itself decides what to do: + +```python +async def run(): + today = utcnow().date() + if today.weekday() == 6: # Sunday — weekly digest for everyone + await _run_weekly() + else: + await _run_daily() # paid only +``` + +`_run_weekly()` queries all users with `email_digest_opt_in=True`. +`_run_daily()` queries paid users with `email_digest_opt_in=True`. + +### 4.10 Pricing copy updates + +`app/templates/pricing.html` — modify the bullet lists. New copy: + +Free: +- "News aggregator — last 6 hours, auto-tagged by theme" (was: no time qualifier) +- "Cross-asset macro signals across every asset class" +- "Hourly AI interpretation of the news + the tape" +- "Per-group cross-asset summaries" +- "Novice / Intermediate reading levels" +- "**Sunday weekly digest by email**" *(new)* +- "❌ Portfolio import & analysis" +- "❌ Encrypted cloud sync" + +Paid (replaces the "Priority email when something material changes (later)" line): +- "Everything in Free" +- "**News aggregator — full 24 hours**" *(replaces the implicit 24h)* +- "Portfolio import (Trading 212 CSV)" +- "AI commentary on diversification, sector and currency concentration, …" +- "Optional encrypted cloud sync across devices" +- "**Daily email digest** (Mon–Sat) + Sunday weekly" *(replaces 'Priority email')* + +The intro paragraph at lines 8-13 needs to soften: + +> Two tiers. The news aggregator and hourly AI interpretation are +> available to everyone — paid extends the time window from 6h to 24h +> and adds daily editorial by email, plus the portfolio-import features. + +(Old copy said "free for everyone — we want the read out where people +can use it." That stance is moderated, not abandoned.) + +## 5. Error handling + +- **SMTP failure**: per-recipient try/except. Log to `EmailSend` with + `status="error"`, `error=str(exc)[:255]`. Job continues. Job-level + failure metrics surface via existing `JobRun` mechanism. +- **OpenRouter failure**: if the content generation fails for both + tones, the job records `JobRun.status="error"` and sends nothing. + Half-success (one tone) → send the variant that worked, skip the + other; users on the failed tone get nothing today (rather than wrong + content). +- **Cost-cap hit**: same pattern as the hourly job — skip the run with + a logged reason. +- **Unsubscribe token invalid / tampered**: render the same + confirmation page generically; do not leak whether the token was + valid (avoid enumeration). + +## 6. Cutover plan + +- Migration sets `email_digest_opt_in=True` for all existing users via + `server_default`. This is the user-requested default — they want + every paid beta-tester actually receiving the email. +- BETA mode is on from the first deploy. +- News cap is on from the first deploy. +- The first daily run lands at 06:30 UTC the morning after deploy. +- The first weekly run lands at 06:30 UTC the next Sunday. +- A pre-deploy admin CLI command (`python -m app.cli send-test-digest + --email me@…`) is added for the operator to dry-run a digest into + their own inbox before flipping the scheduler. + +## 7. Testing + +- Unit: `is_paid_active` window-clamping, opt-in flag round-trip, + token signing/verification. +- Integration: `tests/test_news_api.py` — anonymous + free vs paid + windowing. `tests/test_email_digest.py` — job runs, EmailSend rows + written, idempotency on re-run within the same day. +- Manual: send-test-digest CLI, click unsubscribe link, verify Settings + toggle round-trips. + +## 8. Open questions + +None at design time — all earlier ambiguities (chip scope, window +shape, content shape, send time, tone variants, opt-in default, +unsubscribe model) were resolved during brainstorming.