feat(brief): dashboard Latest Brief panel (Phase 4) (#3159)

* feat(brief): dashboard "Latest Brief" panel (Phase 4)

New PRO-gated panel that reads /api/latest-brief and renders today's
brief with a cover-style thumbnail + greeting + thread count + CTA.
Clicking opens the signed magazine URL in a new tab. Base Panel
class handles the PRO overlay (ANONYMOUS/FREE_TIER) via
premium: 'locked' — no story content, headline, or greeting leaks
through DOM on the locked state.

Three render states:
- ready     → cover card + "Read brief →" CTA linking to magazineUrl
- composing → neutral empty state ("Your brief is composing.")
- error     → base showError() with retry

Files:
- src/components/LatestBriefPanel.ts — new Panel subclass, self-
  fetching via premiumFetch (handles Clerk Bearer + X-WorldMonitor-
  Key tester keys + api key fallback)
- src/components/index.ts — export the new panel
- src/app/panel-layout.ts — createPanel('latest-brief', ...)
- src/config/panels.ts — registry entry (priority 1 so it sorts up
  front across all variant registries)
- src/styles/panels.css — cover-card + meta-strip styles using the
  same e-ink palette as the magazine (sienna kicker, bone text on
  ink cover, serif greeting)

Self-contained: no Convex migration, no new env vars, no backend
changes. Reads the /api/latest-brief endpoint already shipped in
Phase 2 (#3153 merged). Lands independently of Phase 3b / 5 / 6 / 8.

Follow-ups (not in this PR):
- CMD+K entry for "Open Latest Brief" — locale strings + commands
  registry, trivial.
- Localisation of panel title + copy strings.
- Share button (todo 223).

Typecheck clean, lint clean on the new file.

* fix(brief): register latest-brief in both premium gate registries

Addresses the review finding that the Panel base class's
`premium: 'locked'` flag is NOT what actually enforces PRO gating in
the app. Two separate registries do:

1. WEB_PREMIUM_PANELS in src/app/panel-layout.ts — the set
   updatePanelGating() iterates on every auth-state change to decide
   which panels to lock with a CTA overlay. Panels not in this set
   get `reason === NONE` and are always unlocked for whoever's
   viewing them, regardless of the Panel constructor flag.

2. The `premium:` property on each entry in src/config/panels.ts —
   which isPanelEntitled() checks to decide whether a panel is
   premium at all.

`latest-brief` was missing from both. Result for anonymous/free
users: the panel mounted, self-fetched /api/latest-brief, got 401
or 403, and showed raw error UI instead of the intended "Upgrade to
Pro" overlay. Also: a PRO user who downgraded mid-session would
retain the rendered brief because updatePanelGating() wouldn't
re-lock them.

Fixes:

- src/app/panel-layout.ts — add 'latest-brief' to WEB_PREMIUM_PANELS
  so updatePanelGating() locks the panel correctly for non-PRO users
  and RE-locks it on a mid-session downgrade.

- src/config/panels.ts — add `premium: 'locked' as const` to all
  four registry entries (full, finance, tech, happy variants) so
  isPanelEntitled() treats it as premium everywhere.

- src/components/LatestBriefPanel.ts — guard refresh() against
  running without premium access. Belt-and-suspenders against race
  conditions where the panel mounts before updatePanelGating()
  completes, and against mid-session downgrade where the panel
  stays mounted but should stop hitting the endpoint. Uses the
  same hasPremiumAccess(getAuthState()) check as the gating
  infrastructure itself.

Typecheck + biome lint clean.

* fix(brief): SVG logo now actually renders + queue concurrent refresh

Addresses two P1 + one P2 from Greptile on PR #3159.

1. P1 (line 147 + line 167): `h('div', { innerHTML: ... })` silently
   did nothing. src/utils/dom-utils.ts applyProps has no special
   case for `innerHTML` — it falls through to
   `el.setAttribute('innerHTML', svgString)` which just sets a
   literal DOM attribute. Both logo containers rendered empty.
   Switched to:
     const logo = h('div', { className: '...' });
     logo.appendChild(rawHtml(WM_LOGO_SVG));
   rawHtml() exists in dom-utils for exactly this case; returns a
   parsed DocumentFragment.

2. P2: Concurrent refresh() was silently dropped. Added a
   refreshQueued flag so a second refresh during an in-flight one
   queues a single follow-up pass instead of disappearing. Now a
   retry-after-error or a downstream caller that triggers refresh
   while another is mid-fetch always sees its intent applied.

Typecheck + biome lint clean.

* fix(brief): close downgrade-leak + blank-on-upgrade races on panel

Addresses two P1 findings on PR #3159.

1. In-flight fetch leaked premium content after downgrade.
   refresh() checked entitlement only BEFORE await premiumFetch.
   If auth flipped during the fetch, updatePanelGating had already
   replaced this.content with the locked CTA, but renderReady/
   renderComposing then overwrote it with brief content. Fixed with
   a three-gate sequence + fetch abort:

   (a) Pre-fetch check: gate + hasPremiumAccess — unchanged.
   (b) In-flight abort: override showGatedCta() to abort()
       the AbortController before super() paints the locked CTA.
       renderReady/renderComposing never even runs.
   (c) Post-response re-check: re-verify this.gateLocked +
       hasPremiumAccess before any this.content mutation. Catches
       the tight window where abort() lost the race or where an
       error-handler path could still paint brief-ish UI.

   All three are needed — a user can sign out between any two of
   them; removing any one leaves a real leakage window.

2. Upgrade → blank panel.
   unlockPanel() base-class behaviour clears the locked content and
   leaves the content element empty. No refresh was triggered on
   the free/anon → PRO transition, so the panel stayed blank until
   page reload. Overrode unlockPanel() to detect the wasLocked
   transition and call refresh() after re-rendering the loading
   state.

Also tracks gateLocked as a local mirror of the base's private
_locked, since Panel doesn't expose a getter. Synced via the two
override sites above; used in the three-gate checks.

Typecheck + biome lint clean.
This commit is contained in:
Elie Habib
2026-04-18 13:28:23 +04:00
committed by GitHub
parent 711636c7b6
commit fd419bcfae
5 changed files with 385 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import {
OtherTokensPanel,
PredictionPanel,
MonitorPanel,
LatestBriefPanel,
EconomicPanel,
ConsumerPricesPanel,
EnergyComplexPanel,
@@ -123,6 +124,7 @@ const WEB_PREMIUM_PANELS = new Set([
'deduction',
'chat-analyst',
'wsb-ticker-scanner',
'latest-brief',
]);
export interface PanelLayoutManagerCallbacks {
@@ -754,6 +756,11 @@ export class PanelLayoutManager implements AppModule {
this.callbacks.updateMonitorResults();
});
// Latest Brief — reads /api/latest-brief and opens the hosted
// magazine on click. Self-fetching (no data-loader integration);
// PRO gating handled by the base Panel class via premium: 'locked'.
this.createPanel('latest-brief', () => new LatestBriefPanel());
this.createPanel('commodities', () => new CommoditiesPanel());
this.createPanel('energy-complex', () => new EnergyComplexPanel());
this.createPanel('oil-inventories', () => new OilInventoriesPanel());

View File

@@ -0,0 +1,255 @@
/**
* LatestBriefPanel — dashboard surface for the WorldMonitor Brief.
*
* Reads `/api/latest-brief` and renders one of three states:
*
* - ready → cover-card thumbnail + greeting + thread count +
* "Read brief →" CTA that opens the signed magazine
* URL in a new tab.
* - composing → soft empty state. The composer hasn't produced
* today's brief yet; the panel auto-refreshes on
* the next user-visible interaction.
* - locked → the PRO gate (ANONYMOUS or FREE_TIER) is
* handled by the base Panel class via the
* premium-locked-content pattern — the panel itself
* is marked premium and the base draws the overlay.
*
* The signed URL is generated server-side in `api/latest-brief.ts`
* so the token never lives in the client bundle. The panel only
* displays + links to it.
*/
import { Panel } from './Panel';
import { premiumFetch } from '@/services/premium-fetch';
import { PanelGateReason, hasPremiumAccess } from '@/services/panel-gating';
import { getAuthState } from '@/services/auth-state';
import { h, rawHtml, replaceChildren, clearChildren } from '@/utils/dom-utils';
interface LatestBriefReady {
status: 'ready';
issueDate: string;
dateLong: string;
greeting: string;
threadCount: number;
magazineUrl: string;
}
interface LatestBriefComposing {
status: 'composing';
issueDate: string;
}
type LatestBriefResponse = LatestBriefReady | LatestBriefComposing;
const LATEST_BRIEF_ENDPOINT = '/api/latest-brief';
const WM_LOGO_SVG = (
'<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2" '
+ 'stroke-linecap="round" aria-hidden="true">'
+ '<circle cx="32" cy="32" r="28"/>'
+ '<ellipse cx="32" cy="32" rx="5" ry="28"/>'
+ '<ellipse cx="32" cy="32" rx="14" ry="28"/>'
+ '<ellipse cx="32" cy="32" rx="22" ry="28"/>'
+ '<ellipse cx="32" cy="32" rx="28" ry="5"/>'
+ '<ellipse cx="32" cy="32" rx="28" ry="14"/>'
+ '<path d="M 6 32 L 20 32 L 24 24 L 30 40 L 36 22 L 42 38 L 46 32 L 56 32" stroke-width="2.4"/>'
+ '<circle cx="57" cy="32" r="1.8" fill="currentColor" stroke="none"/>'
+ '</svg>'
);
export class LatestBriefPanel extends Panel {
private refreshing = false;
private refreshQueued = false;
/**
* Local mirror of Panel base `_locked`. The base doesn't expose a
* getter, so we track transitions by overriding showGatedCta() +
* unlockPanel() below. The flag lets renderReady/renderComposing
* detect a downgrade-while-fetching race and abort the render
* even if abort() on the fetch signal was too late.
*/
private gateLocked = false;
private inflightAbort: AbortController | null = null;
constructor() {
super({
id: 'latest-brief',
title: 'Latest Brief',
infoTooltip:
"Your personalised daily editorial magazine. One brief per day, assembled from the news-intelligence layer and delivered via email, Telegram, Slack, and here.",
// premium: 'locked' marks this as PRO-gated. The base Panel
// handles the ANONYMOUS + FREE_TIER overlay via
// panel-gating.ts's getPanelGateReason. No story content,
// headline, or greeting leaks through DOM attributes on the
// locked state — the base renders a generic "Upgrade to Pro"
// card without touching our `content` element.
premium: 'locked',
});
this.renderLoading();
// Defer the self-fetch until updatePanelGating() (called on mount
// + on auth state changes) has either unlocked us or rendered
// the gated CTA. If we fetch first, anonymous/free users would
// hit 401/403 and see raw error UI for a moment before the gate
// repaints over us. refresh() also short-circuits when the user
// has no premium access, so a mid-session downgrade stops
// hitting the endpoint immediately.
void this.refresh();
}
/**
* Called by the dashboard when the panel first mounts or is
* revisited. A refresh while one is already in flight queues a
* single follow-up pass instead of being silently dropped — the
* user-facing state always reflects the most recent intent
* (e.g. retry after error, fresh fetch after a visibility change).
*
* Entitlement is checked THREE times to close the downgrade-
* mid-fetch leak: before starting, on AbortController signal, and
* again after the response resolves. All three are required — a
* user can sign out between any two of them.
*/
public async refresh(): Promise<void> {
if (this.refreshing) {
this.refreshQueued = true;
return;
}
// Check #1: gate before starting.
if (this.gateLocked || !hasPremiumAccess(getAuthState())) return;
this.refreshing = true;
const controller = new AbortController();
this.inflightAbort = controller;
try {
const data = await this.fetchLatest(controller.signal);
// Check #3 (post-response): auth may have flipped during the
// await. If the gate was flipped by updatePanelGating, it has
// already replaced `this.content` with the locked CTA — we
// must NOT overwrite that with brief content.
if (this.gateLocked || !hasPremiumAccess(getAuthState())) return;
if (data.status === 'ready') {
this.renderReady(data);
} else {
this.renderComposing(data);
}
} catch (err) {
// AbortError comes from showGatedCta's abort() → render nothing.
if ((err as { name?: string } | null)?.name === 'AbortError') return;
if (this.gateLocked || !hasPremiumAccess(getAuthState())) return;
const message = err instanceof Error ? err.message : 'Brief unavailable — try again shortly.';
this.showError(message, () => { void this.refresh(); });
} finally {
this.refreshing = false;
this.inflightAbort = null;
if (this.refreshQueued) {
this.refreshQueued = false;
void this.refresh();
}
}
}
/**
* Override to abort any in-flight fetch so the response can't
* overwrite the locked CTA after it's painted. Check #2 in the
* three-gate sequence above.
*/
public override showGatedCta(reason: PanelGateReason, onAction: () => void): void {
this.gateLocked = true;
this.inflightAbort?.abort();
this.inflightAbort = null;
super.showGatedCta(reason, onAction);
}
/**
* Override to catch the unlock transition. `updatePanelGating`
* calls this when a user upgrades (free/anon → PRO). The base
* clears locked content but leaves us empty — without this
* override the panel stays blank until page reload. Trigger a
* fresh fetch on transition.
*/
public override unlockPanel(): void {
const wasLocked = this.gateLocked;
this.gateLocked = false;
super.unlockPanel();
if (wasLocked) {
this.renderLoading();
void this.refresh();
}
}
private async fetchLatest(signal: AbortSignal): Promise<LatestBriefResponse> {
const res = await premiumFetch(LATEST_BRIEF_ENDPOINT, { signal });
if (res.status === 401) {
throw new Error('Sign in to view your brief.');
}
if (res.status === 403) {
// PRO gate — base panel handles the visual. Keep the throw so
// the caller's error branch is a no-op; locked-state overlay
// already covers the content area.
throw new Error('PRO required');
}
if (!res.ok) {
throw new Error(`Brief service unavailable (${res.status})`);
}
const body = (await res.json()) as LatestBriefResponse;
if (!body || (body.status !== 'ready' && body.status !== 'composing')) {
throw new Error('Unexpected response from brief service');
}
return body;
}
private renderLoading(): void {
clearChildren(this.content);
this.content.appendChild(
h('div', { className: 'latest-brief-empty' },
h('div', { className: 'latest-brief-empty-title' }, 'Loading your brief…'),
),
);
}
private renderComposing(data: LatestBriefComposing): void {
clearChildren(this.content);
// h()'s applyProps has no special-case for innerHTML — passing
// it as a prop sets a literal DOM attribute named "innerHTML"
// rather than parsing HTML. Use rawHtml() which returns a
// DocumentFragment.
const logoDiv = h('div', { className: 'latest-brief-logo' });
logoDiv.appendChild(rawHtml(WM_LOGO_SVG));
this.content.appendChild(
h('div', { className: 'latest-brief-card latest-brief-card--composing' },
logoDiv,
h('div', { className: 'latest-brief-empty-title' }, 'Your brief is composing.'),
h('div', { className: 'latest-brief-empty-body' },
`The editorial team at WorldMonitor is writing your ${data.issueDate} brief. Check back in a moment.`,
),
),
);
}
private renderReady(data: LatestBriefReady): void {
const threadLabel = data.threadCount === 1 ? '1 thread' : `${data.threadCount} threads`;
const coverLogo = h('div', { className: 'latest-brief-cover-logo' });
coverLogo.appendChild(rawHtml(WM_LOGO_SVG));
const coverCard = h('a', {
className: 'latest-brief-card latest-brief-card--ready',
href: data.magazineUrl,
target: '_blank',
rel: 'noopener noreferrer',
'aria-label': `Open today's brief — ${threadLabel}`,
},
h('div', { className: 'latest-brief-cover' },
coverLogo,
h('div', { className: 'latest-brief-cover-issue' }, data.dateLong),
h('div', { className: 'latest-brief-cover-title' }, 'WorldMonitor'),
h('div', { className: 'latest-brief-cover-title' }, 'Brief.'),
h('div', { className: 'latest-brief-cover-kicker' }, threadLabel),
),
h('div', { className: 'latest-brief-meta' },
h('div', { className: 'latest-brief-greeting' }, data.greeting),
h('div', { className: 'latest-brief-cta' }, 'Read brief →'),
),
);
replaceChildren(this.content, coverCard);
}
}

View File

@@ -10,6 +10,7 @@ export * from './StockAnalysisPanel';
export * from './StockBacktestPanel';
export * from './PredictionPanel';
export * from './MonitorPanel';
export * from './LatestBriefPanel';
export * from './SignalModal';
export * from './PlaybackControl';
export * from './StatusPanel';

View File

@@ -60,6 +60,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
ai: { name: 'AI/ML', enabled: true, priority: 2 },
layoffs: { name: 'Layoffs Tracker', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'latest-brief': { name: 'Latest Brief', enabled: true, priority: 1, premium: 'locked' as const },
'satellite-fires': { name: 'Fires', enabled: true, priority: 2 },
'macro-signals': { name: 'Market Regime', enabled: true, priority: 2 },
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 2 },
@@ -279,6 +280,7 @@ const TECH_PANELS: Record<string, PanelConfig> = {
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },
'world-clock': { name: 'World Clock', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'latest-brief': { name: 'Latest Brief', enabled: true, priority: 1, premium: 'locked' as const },
'tech-hubs': { name: 'Hot Tech Hubs', enabled: false, priority: 2 },
'ai-regulation': { name: 'AI Regulation Dashboard', enabled: false, priority: 2 },
};
@@ -469,6 +471,7 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },
'world-clock': { name: 'World Clock', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'latest-brief': { name: 'Latest Brief', enabled: true, priority: 1, premium: 'locked' as const },
};
const FINANCE_MAP_LAYERS: MapLayers = {
@@ -770,6 +773,7 @@ const COMMODITY_PANELS: Record<string, PanelConfig> = {
polymarket: { name: 'Commodity Predictions', enabled: true, priority: 2 },
'world-clock': { name: 'World Clock', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
'latest-brief': { name: 'Latest Brief', enabled: true, priority: 1, premium: 'locked' as const },
};
const COMMODITY_MAP_LAYERS: MapLayers = {

View File

@@ -2846,3 +2846,121 @@ a.hub-top-story {
color: var(--text-muted);
opacity: 0.7;
}
/* ── Latest Brief panel ───────────────────────────────────────────── */
.latest-brief-card {
display: block;
text-decoration: none;
color: inherit;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--border-color, #222);
transition: transform 140ms ease, border-color 140ms ease;
}
.latest-brief-card--ready:hover {
transform: translateY(-1px);
border-color: var(--accent, #4ade80);
}
.latest-brief-cover {
background: #0a0a0a;
color: #f2ede4;
padding: 22px 22px 18px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.latest-brief-cover-logo {
width: 32px;
height: 32px;
opacity: 0.85;
margin-bottom: 6px;
}
.latest-brief-cover-logo svg {
width: 100%;
height: 100%;
display: block;
}
.latest-brief-cover-issue {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.7;
}
.latest-brief-cover-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 26px;
font-weight: 900;
line-height: 0.95;
letter-spacing: -0.02em;
}
.latest-brief-cover-kicker {
margin-top: 10px;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8b3a1f;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
.latest-brief-meta {
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: var(--panel-bg, #161616);
}
.latest-brief-greeting {
font-family: 'Source Serif 4', Georgia, serif;
font-style: italic;
font-size: 14px;
color: var(--text-primary, #eee);
line-height: 1.3;
}
.latest-brief-cta {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--accent, #4ade80);
white-space: nowrap;
}
.latest-brief-card--composing {
padding: 32px 20px;
text-align: center;
background: var(--panel-bg, #161616);
}
.latest-brief-logo {
width: 28px;
height: 28px;
margin: 0 auto 12px;
opacity: 0.6;
color: var(--text-muted, #888);
}
.latest-brief-logo svg,
.latest-brief-empty-title {
display: block;
}
.latest-brief-empty-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
color: var(--text-primary, #eee);
}
.latest-brief-empty-body {
font-size: 12px;
color: var(--text-muted, #888);
line-height: 1.5;
max-width: 36ch;
margin: 0 auto;
}
.latest-brief-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-muted, #888);
font-size: 12px;
}