Morning Routine Dashboard — Design
Persists the agent-generated daily Morning Routine briefing to Postgres and renders it on a frosted-glass dashboard in the SolidStart panel, with a service-token ingest API, per-user card preferences, a browsable date rail, and a 2-tier feature flag. Telegram is reduced to a 3-line teaser deep-linking to the dashboard.
- Status Approved
The daily AI-generated "Morning Routine" briefing moves from ephemeral Telegram messages to a
persistent, browsable frosted-glass dashboard inside the SolidStart admin panel. A new Go
morningroutine module ingests structured reports from the agent via a service-token
endpoint, stores one JSONB row per user per day, and serves them to session-authenticated read
endpoints gated by a 2-tier feature flag. Telegram shrinks to a 3-line teaser with a deep link.
Problem & Goal
Today the briefing is generated by an AI agent and delivered to Telegram — ephemeral, text-only, and impossible to filter or browse by date. The goal: persist each briefing to the database and present it on a glass-panel dashboard inside the existing SolidStart admin panel, so the user can read today's report and browse previous days. The agent keeps generating; it POSTs the structured report to a backend ingest endpoint.
Scope
In scope
Phase 1- Go module
morningroutine: ingest, store, serve per-user reports - Service-token ingest endpoint for the agent
- Session-authenticated read endpoints (current user only)
- Per-user card preferences (which top-row cards + order)
- Panel dashboard route + settings route, frosted-glass style
- 2-tier feature flag (reuses fieldforce briefing pattern)
- i18n (Paraglide) for UI chrome only
- Telegram teaser format (3-line summary + deep link) — the only agent-side change
Out of scope
explicit- The agent's generation logic (prompt, scheduling, external API calls)
- Per-user agent scheduling / location config on the generation side
- Better Auth system-service-account ingest (future replacement for shared token)
- Retention sweeps — all reports are kept
- Re-translating agent-authored content (server-supplied)
Key Decisions
| # | Decision | Rationale |
|---|---|---|
| 1 | Agent generates, POSTs structured JSON to an ingest API | Reuses the working agent; keeps strong-model + web-search quality for trends/ideas; fastest to value |
| 2 | Per-user reports, gated by a 2-tier feature flag (global + per-org) | Matches the existing fieldforce briefing pattern; clean multi-tenant story |
| 3 | SolidStart panel + new Go backend module | Lowest friction; reuses auth, nav, i18n, feature-flag middleware, glass layout |
| 4 | Ingest auth = shared service token (constant-time compare), target user in payload | Mirrors the existing webhook ingress; minimal token management. Better Auth system account is a future upgrade |
| 5 | Single mr_reports row per report, JSONB arrays for list sections | A report is always read as one unit per date; items are never queried independently |
| 6 | Keep all reports (no retention sweep) | Personal, low-volume data; YAGNI for MVP |
| 7 | Dashboard rendered inside MainLayout (not a fullscreen route) | Sidebar nav, auth, i18n, feature flag all apply automatically |
| 8 | Card preferences are a display filter (report carries full top_stats; UI shows the enabled subset) | Toggling a card takes effect instantly; no agent preference round-trip |
| 9 | Items with a natural URL (News, Inbox) → external link; agent-authored Trends & Ideas → in-app detail (full_content_md) | News/email have source URLs; trends/ideas are agent prose with no single URL |
| 10 | All sections beyond the top strip (Mindset, Focus, News, Inbox, Trends, Ideas) are optional per report | Lets the agent send only what it produced; panels render only when present |
| 11 | Re-POST for the same date is a full replace (every POST is the complete report) | Predictable retry semantics; a missing section means "doesn't exist today", never "keep the old one" |
| 12 | Telegram becomes a teaser: 3-line summary (focus headline + top news title) + deep link to /app/morning-routine | Keeps the push notification without maintaining two full renderings |
| 13 | Payload carries a schema_version; the raw body is stored alongside parsed columns | LLM-produced JSON drifts; versioning + raw retention allow contract evolution and backfills |
Architecture & Data Flow
- Ingest is a public route (no session middleware) authenticated by a shared bearer secret compared with
crypto/subtle, exactly like the digital-worker webhook ingress. The payload carries the target user (email or user_id); the backend resolves it to a Better Auth user and upserts. - Read endpoints require a session and only return the authenticated user's reports.
- Feature flag (2-tier): global kill-switch + per-org enable gate the read endpoints and the nav link. Ingest is not flag-gated — it always stores; the flag governs visibility, mirroring the webhook ingress.
- Idempotency: one report per
(user_id, report_date); re-POSTing the same date upserts, so the agent retries safely.
Data Model
mr_reports
mr_reports
id uuid pk
user_id text -- Better Auth user id (resolved at ingest)
report_date date -- local date in the report's timezone
timezone text -- e.g. "Asia/Kuala_Lumpur"
time_label text -- e.g. "9:00 AM"
location_label text -- e.g. "Puchong"
top_stats jsonb -- ordered array of compact top-row cards
mindset jsonb -- {quote, author} — optional tone-setting line
focus jsonb -- {headline, detail, link?} — the day's single objective
news jsonb -- [{rank, title, summary, url, source, points, posted_relative}]
inbox jsonb -- [{from, subject, summary, url, priority}]
trends jsonb -- [{rank, title, why_it_matters, what_to_learn, full_content_md}]
ideas jsonb -- [{rank, name, one_liner, target, features[], full_content_md}]
schema_version int -- payload contract version (starts at 1)
raw_payload jsonb -- the ingest body as received (forward-compat / backfill)
generated_at timestamptz
created_at timestamptz
updated_at timestamptz
UNIQUE (user_id, report_date)
news[].urlandinbox[].urlare external links (open the article / the email).trends[].full_content_md/ideas[].full_content_mdhold longer agent-written markdown rendered in the in-app detail drawer; cards show the short fields.focusis a single objective (anchor panel);mindsetis a short quote. Both optional — omitted sections simply don't render.
mr_preferences
mr_preferences
user_id text pk -- Better Auth user id
cards jsonb -- ordered [{key, enabled}]
created_at timestamptz
updated_at timestamptz
When no row exists the server returns the defaults: datetime,
weather, sunrise_sunset, agenda enabled; optionals
(air_quality, fx_usd_myr, open_prs) disabled.
top_stats entry shape
Each entry is self-describing so new card types need no schema change:
{ "key": "weather", "icon": "🌤️", "label": "Weather — Puchong",
"value": "25.7°C", "sub": "92% humidity · rain 54% @ 4PM", "link": "https://…" }
- Card catalog (known keys)
datetime,weather,sunrise_sunset,agenda(defaults);air_quality,fx_usd_myr,open_prs,markets,reminders(optional). Extensible.- Rendering rule
- The dashboard renders the subset the user enabled, in the user's order. A preferred card absent from a given report doesn't render that day.
Backend — Go module morningroutine
New module under backend/go/internal/modules/morningroutine/, following the
fieldforce briefing layout (domain / application / adapter). Hexagonal: handlers → use cases →
ports → postgres repository.
| Method & path | Auth | Purpose |
|---|---|---|
POST /api/morning-routine/ingest | Service token | Upsert the report for the payload's user. Validate shape; 200 on success, 401 bad token, 404 unresolvable user |
GET /api/morning-routine/reports?from=&to= | Session + flag | Current user's report dates in a range (date rail) |
GET /api/morning-routine/reports/:date | Session + flag | Full report for that date (404 if none) |
GET /api/morning-routine/preferences | Session | Current user's card prefs (defaults if no row) |
PUT /api/morning-routine/preferences | Session | Replace the current user's card prefs |
- The trend/idea "view full" detail needs no extra endpoint —
full_content_mdis already in the report payload the frontend holds. - Feature-flag middleware (global + per-org) reused from the briefing pattern; nav gated like the risk inbox (availability probe).
- Service token from config (e.g.
MORNING_ROUTINE_INGEST_TOKEN), compared withcrypto/subtle.
Ingest payload (contract)
{
"schema_version": 1,
"user": "cheahkokweng@gmail.com",
"report_date": "2026-06-10",
"timezone": "Asia/Kuala_Lumpur",
"time_label": "9:00 AM",
"location_label": "Puchong",
"generated_at": "2026-06-10T01:00:00Z",
"top_stats": [ { "key": "datetime", "icon": "📅", "label": "Date & Time", "value": "9:00 AM", "sub": "Wed, 10 Jun · MYT (UTC+8)" } ],
"mindset": { "quote": "…", "author": "…" },
"focus": { "headline": "…", "detail": "…", "link": "https://…" },
"news": [ { "rank": 1, "title": "…", "summary": "…", "url": "https://…", "source": "Hacker News", "points": 1750, "posted_relative": "1h ago" } ],
"inbox": [ { "from": "…", "subject": "…", "summary": "…", "url": "https://mail.google.com/…", "priority": "high" } ],
"trends": [ { "rank": 1, "title": "…", "why_it_matters": "…", "what_to_learn": "…", "full_content_md": "## …" } ],
"ideas": [ { "rank": 1, "name": "…", "one_liner": "…", "target": "…", "features": ["…"], "full_content_md": "## …" } ]
}
user may be an email or a user_id; the backend resolves it (unknown → 404).
schema_version is required (currently 1); the raw body is stored in
mr_reports.raw_payload so old reports can be re-parsed if the contract evolves.
Frontend — SolidStart panel
/app/morning-routine- The dashboard (authenticated, inside
MainLayout, behind the feature flag) /app/settings/morning-routine- The card preferences page (toggle + drag-reorder)
Layout (approved: "Hybrid, all-white glass")
Frosted-glass treatment + background image on the content area; one cohesive white-glass card family (subtle tone/opacity differences only — no dark/accent variants).
Wednesday, 10 June
- Left date rail — recent days as glass chips (active highlighted) + "Pick a date…"; switches the loaded report.
- Freshness stamp — "Generated 9:00 AM · MYT" from
generated_at+timezone, so staleness and duplicate ingests are visible. - Mindset line (optional) — subtle italic quote under the date.
- Top strip — compact row of
top_statscards (~88px tall), filtered + ordered by preferences. - Today's Focus + Inbox Highlights (two columns) — Focus is a prominent anchor panel; Inbox lists 1–3 emails needing action with "Open ↗" links to Gmail.
- News panel (full width) — score-aware: the highest-
pointsitem renders as the leader (left red accent, "🔥 HOTTEST" ribbon, large point number, summary); every item shows a heat bar (width = points ÷ day's-max-points) and a tier-colored point count: hot (🔥, ≥ 1000), warm (≥ 300), cool (otherwise). Tiers/bars computed client-side from storedpoints; thresholds live in one constant. - Trends + Ideas panels (two columns) — short fields on the card, "View full →" opens the detail drawer.
Components (new, glass style)
DateRail, TopStatCard (generic, renders one top_stats entry),
MindsetBanner, FocusPanel, InboxHighlightsPanel,
NewsPanel, TrendsPanel, IdeasPanel,
ReportDetailDrawer (slide-over rendering full_content_md via the existing
markdown renderer), and the settings CardPreferences list (toggle + drag-reorder).
The existing glass cards (stat/chart/map) don't fit text panels, so these are new but reuse the
glass surface tokens/treatment.
Data (TanStack Query)
useReportDates(range)- Date rail
useReport(date)- The page (defaults to the latest date)
useCardPreferences()/useUpdateCardPreferences()- Settings + display filtering
- Nav availability
- Mirrors
useRiskInboxAvailable— link shows only when the endpoint is reachable / flag on for the user's org
States
- Empty, past date
- "No report for this day."
- Empty, today
- Distinct state: "Today's report hasn't arrived yet — usually by ~9:05 AM." Without this, a silent agent/ingest failure is indistinguishable from being early.
- Card unavailable
- The agent degrades the field; the card shows a graceful fallback or is omitted.
- Enabled-but-missing card
- A preferred card absent from today's report doesn't render.
i18n
New Paraglide morningRoutine.* namespace (en/zh/ms) for chrome only —
section headers, card labels, "View full", "Read on HN", "Open", settings copy, empty/error states.
Agent-authored content (news titles/summaries, trend/idea bodies, top-stat values) is
server-supplied and not re-translated client-side, matching the digital-worker
convention.
Feature Flag (2-tier)
- Tier 1 — global: kill-switch (reuses the briefing global-flag mechanism).
- Tier 2 — per-org: per-organisation enable.
- Both gate the read endpoints and the nav link. Ingest is not gated (stores regardless; visibility is governed by the flag), as with the webhook ingress.
Security
- Ingest secret in config, never logged; constant-time compare.
- Read/preferences endpoints scope strictly to the authenticated user — no cross-user reads.
- Ingest payload limits (1 MB body, 20 items/section) enforced server-side.
- Agent content rendered with raw HTML disabled; external links allowlisted to
http(s)withrel="noopener noreferrer".
Testing
Go
unit + integration- Ingest auth (good/bad token)
- User resolution (email + id, unknown → 404)
- Upsert idempotency on
(user_id, report_date) - Full-replace semantics (re-POST without a section removes it)
- Payload limits (413 over 1 MB, 422 over 20 items)
- Missing/invalid
schema_versionrejected - Read scoping (user A can't read user B)
- Feature-flag gating; preferences defaults + round-trip
Frontend (Vitest)
componentsTopStatCardrendering by key; date rail switching- Preference filtering + ordering, incl. unknown keys appended at the end
- Detail drawer renders
full_content_mdwith raw HTML disabled - External link attributes (
noopener noreferrer, scheme allowlist) - Empty states: today-pending vs past-date empty
- News score viz: leader = max points, heat-bar width ratios, tier thresholds at the 300/1000 boundaries
Playwright e2e
flows- Ingest → dashboard render
- Toggle a card in settings → top strip updates instantly
- Browse a previous day via the rail
- Dashboard readable at 390px viewport (top strip scrolls, panels stack)
Phase 2
Designed-for but not built in Phase 1:
Ideas & Trends library
fast-follow- Cross-day view flattening
ideas(andtrends) across all reports:GET /api/morning-routine/ideas?limit=&offset=viajsonb_array_elements - Route (e.g.
/app/morning-routine/ideas) listing every idea ever generated — browsable, searchable - The durable-artifact payoff the per-day date rail can't give; the Phase-1 JSONB shape already supports it, no schema change
Read/seen state
after library- Mark which ideas/news items were opened (new table + interaction model)
- Makes the library view better; not before the library exists
Open Questions / Future
- Optional retention cap if volume ever matters.
- Per-user generation config (location/schedule) on the agent side — out of scope here.
- Credentialed optional cards (Open PRs, Agenda) depend on the agent having per-user access; if a user enables one the agent can't fill, the card is simply absent that day.