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

View source markdown ↗ generated by claude-fable-5 · diagrams mermaid

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.

  • Producer Claude agent (weather + HN + web) → ingest API
  • Backend Go module morningroutine (hexagonal, fieldforce-briefing layout)
  • Surface /app/morning-routine + settings route, glass style
  • Storage mr_reports + mr_preferences, JSONB sections
  • Gating 2-tier flag: global kill-switch + per-org enable
  • Delivery Telegram becomes a 3-line teaser + 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

#DecisionRationale
1Agent generates, POSTs structured JSON to an ingest APIReuses the working agent; keeps strong-model + web-search quality for trends/ideas; fastest to value
2Per-user reports, gated by a 2-tier feature flag (global + per-org)Matches the existing fieldforce briefing pattern; clean multi-tenant story
3SolidStart panel + new Go backend moduleLowest friction; reuses auth, nav, i18n, feature-flag middleware, glass layout
4Ingest auth = shared service token (constant-time compare), target user in payloadMirrors the existing webhook ingress; minimal token management. Better Auth system account is a future upgrade
5Single mr_reports row per report, JSONB arrays for list sectionsA report is always read as one unit per date; items are never queried independently
6Keep all reports (no retention sweep)Personal, low-volume data; YAGNI for MVP
7Dashboard rendered inside MainLayout (not a fullscreen route)Sidebar nav, auth, i18n, feature flag all apply automatically
8Card 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
9Items 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
10All sections beyond the top strip (Mindset, Focus, News, Inbox, Trends, Ideas) are optional per reportLets the agent send only what it produced; panels render only when present
11Re-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"
12Telegram becomes a teaser: 3-line summary (focus headline + top news title) + deep link to /app/morning-routineKeeps the push notification without maintaining two full renderings
13Payload carries a schema_version; the raw body is stored alongside parsed columnsLLM-produced JSON drifts; versioning + raw retention allow contract evolution and backfills

Architecture & Data Flow

Agent POSTs the structured report; the panel reads it back per user; Telegram only teases.
Agent POSTs the structured report; the panel reads it back per user; Telegram only teases.
  • 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[].url and inbox[].url are external links (open the article / the email).
  • trends[].full_content_md / ideas[].full_content_md hold longer agent-written markdown rendered in the in-app detail drawer; cards show the short fields.
  • focus is a single objective (anchor panel); mindset is 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 & pathAuthPurpose
POST /api/morning-routine/ingestService tokenUpsert 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 + flagCurrent user's report dates in a range (date rail)
GET /api/morning-routine/reports/:dateSession + flagFull report for that date (404 if none)
GET /api/morning-routine/preferencesSessionCurrent user's card prefs (defaults if no row)
PUT /api/morning-routine/preferencesSessionReplace the current user's card prefs
  • The trend/idea "view full" detail needs no extra endpointfull_content_md is 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 with crypto/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).

Wed 10
Tue 9
Mon 8
Sun 7
Pick a date…

Wednesday, 10 June

Generated 9:00 AM · MYT — Puchong
"What we dwell on is who we become." — Oprah
📅 Date & Time
9:00 AM
Wed, 10 Jun · MYT
🌤️ Weather
25.7°C
92% hum · rain 54% @ 4PM
🌅 Sun
7:02 / 19:24
12h 22m daylight
🗓️ Agenda
3 events
First: standup 10:00
🎯 Today's Focus
Ship morning-routine ingest API
Land the Go module + contract tests before noon.
📧 Inbox Highlights
Stripe invoice needs approval Open ↗
PR review requested: org-permissions-v2 Open ↗
📰 Hacker News
🔥 HOTTEST Postgres 19 released 1,750 pts
Native columnar storage and async I/O headline the release…
Show HN: terminal spreadsheet
Read on HN ↗
Why we left Kubernetes
Read on HN ↗
📈 Trends
Local-first sync engines View full →
On-device small models View full →
💡 Business Ideas
Briefing-as-a-service for teams View full →
HN signal digest for niches View full →
Dashboard mockup — date rail, freshness stamp + mindset line, top-stat strip, Focus/Inbox columns, score-aware News panel with heat bars, Trends/Ideas columns.
  • 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_stats cards (~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-points item 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 stored points; 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) with rel="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_version rejected
  • Read scoping (user A can't read user B)
  • Feature-flag gating; preferences defaults + round-trip

Frontend (Vitest)

components
  • TopStatCard rendering by key; date rail switching
  • Preference filtering + ordering, incl. unknown keys appended at the end
  • Detail drawer renders full_content_md with 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 (and trends) across all reports: GET /api/morning-routine/ideas?limit=&offset= via jsonb_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.