S4 (a1): the media engine — VOICE templates, /desk, Ghost drafts
4a VOICE v1.1 committed (board start); lint is EXECUTABLE — banned list + no-exclamation law enforced in the engine (throws in test, drops in prod) and locked by tests. Curly-apostrophe variants covered. 4b mediaEngine: deterministic templates (MORNING WIRE, SIGNAL, STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH) filled ONLY from snapshot/ledger/streaks JSON. Record percentages never render under n>=20 (counts + 'Record building' below). Stark layer = curated committed library (content/stark-lines.json), day-rotated selection — selected, never generated. 4c /desk (founder-only: requireAuth + DESK_OWNERS email allowlist, deny-by-default): all formats as text + <=280-char pre-segmented tweets with per-tweet copy buttons + char counts, wire/numbers-only variants, DATA BRIEF block (structured day numbers) with copy-for-claude.ai. ntfy ping after the day's first snapshot: 'Desk pack ready'. 4d ghostPublisher: DRAFTS ONLY (status:'draft' test-locked), env-gated no-op, HS256 JWT via node crypto (zero new deps). POST /api/internal/ghost/drafts saves slate preview + settle drafts. Nothing anywhere auto-posts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "THE STARK LAYER library (VOICE v1.1). Curated by Kev + Claude, committed like code. Templates SELECT from these — they never generate wit. One line per post max, only beside a number. No exclamation points.",
|
||||
"morning": [
|
||||
"The model collects debts.",
|
||||
"The slate doesn't know we're watching. It will.",
|
||||
"Quants get floors. We got a terminal.",
|
||||
"The board posted. So did we."
|
||||
],
|
||||
"settle_winning": [
|
||||
"The Ledger doesn't gloat. It just doesn't delete.",
|
||||
"Same format tomorrow, whatever it says.",
|
||||
"The record talks. We just post it."
|
||||
],
|
||||
"settle_losing": [
|
||||
"It's on the Ledger like everything else.",
|
||||
"Same format as the wins. That's the point.",
|
||||
"The model logs its misses. The industry deletes theirs."
|
||||
],
|
||||
"steam": [
|
||||
"Someone's watching. Good.",
|
||||
"The market chased the read.",
|
||||
"Three minutes after the wire posted. Noted."
|
||||
],
|
||||
"streak": [
|
||||
"Most hot streaks are soft schedules wearing a costume.",
|
||||
"The lens checks what the streak was built on. Calendars lie.",
|
||||
"A streak is a claim. The matchup is the cross-examination."
|
||||
],
|
||||
"identity": [
|
||||
"One terminal, built in Detroit.",
|
||||
"Garage-built. Out-reading the tower.",
|
||||
"The books employ floors full of quants. Seems fair."
|
||||
]
|
||||
}
|
||||
@@ -132,6 +132,8 @@ app.use('/api/props', propsRoutes);
|
||||
app.use('/api/players', require('./routes/players'));
|
||||
// Session 60 (night2/F) — per-user utility reads (Settings scan meter).
|
||||
app.use('/api/user', require('./routes/user'));
|
||||
// Session 63 (A1-S4) — the founder's media desk (email-allowlisted).
|
||||
app.use('/api/desk', require('./routes/desk'));
|
||||
app.use('/api/waitlist', waitlistRoutes);
|
||||
app.use('/api/pipeline', pipelineRoutes);
|
||||
app.use('/api/share-card', shareCardRoutes);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/desk (Session 63 / A1-S4) — Kev's copy-paste arsenal.
|
||||
*
|
||||
* GET /pack — the assembled daily media pack + DATA BRIEF.
|
||||
* Auth: requireAuth + email allowlist (env DESK_OWNERS, comma-separated).
|
||||
* Unset allowlist → 403 for everyone (deny by default; the desk is the
|
||||
* founder's surface, not a tier feature).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { assembleDeskPack } = require('../services/deskService');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 20 }));
|
||||
|
||||
function isOwner(req) {
|
||||
const owners = String(process.env.DESK_OWNERS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
const email = String(req.user?.email || '').toLowerCase();
|
||||
return owners.length > 0 && email && owners.includes(email);
|
||||
}
|
||||
|
||||
router.get('/pack', requireAuth, async (req, res) => {
|
||||
if (!isOwner(req)) {
|
||||
return res.status(403).json({ error: 'The desk is the publisher’s surface. Set DESK_OWNERS to grant access.' });
|
||||
}
|
||||
try {
|
||||
const pack = await assembleDeskPack();
|
||||
res.set('Cache-Control', 'private, max-age=60');
|
||||
return res.json(pack);
|
||||
} catch (err) {
|
||||
console.error('[desk/pack]', err.message);
|
||||
return res.status(200).json({ generated_at: null, formats: {}, data_brief: null, error: 'assembly failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -180,6 +180,25 @@ router.post('/outcomes/all', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's
|
||||
* desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS
|
||||
* ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost.
|
||||
*/
|
||||
router.post('/ghost/drafts', async (req, res) => {
|
||||
try {
|
||||
const { assembleDeskPack } = require('../services/deskService');
|
||||
const ghost = require('../services/ghostPublisher');
|
||||
const pack = await assembleDeskPack();
|
||||
const result = await ghost.saveDailyDrafts(pack);
|
||||
return res.json({ ok: result.ok, result });
|
||||
} catch (err) {
|
||||
const message = err && err.message ? err.message : String(err);
|
||||
console.error('[internal/ghost/drafts] failed:', message);
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
|
||||
* odds-only refresh: STEAM/VALUE movement, public revisions, closing
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* deskService — assembles Kev's daily copy-paste arsenal (Session 63 / A1-S4).
|
||||
*
|
||||
* Pulls the day's REAL pipeline data (snapshots, streaks+lens, ledger
|
||||
* aggregate + settled rows) and fills the VOICE v1.1 templates. Output is
|
||||
* the /desk pack: every format as text + pre-segmented tweets (≤280) +
|
||||
* 2 copy variants (with/without the Stark line) + the DATA BRIEF (structured
|
||||
* numbers Kev pastes into claude.ai for freeform writing).
|
||||
*
|
||||
* NOTHING auto-posts. This service only assembles.
|
||||
*/
|
||||
|
||||
const media = require('./mediaEngine');
|
||||
|
||||
const SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
|
||||
|
||||
function variants(text) {
|
||||
if (!text) return [];
|
||||
// Variant B: the same post without its Stark line (pure numbers). The
|
||||
// Stark lines are the only non-data lines, all sourced from the library.
|
||||
const starkSet = new Set(Object.values(media.__internals.STARK).flat());
|
||||
const stripped = text.split('\n').filter((l) => !starkSet.has(l)).join('\n');
|
||||
const out = [{ label: 'wire', text, tweets: media.segmentThread(text) }];
|
||||
if (stripped !== text) out.push({ label: 'numbers only', text: stripped, tweets: media.segmentThread(stripped) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the pack. deps injectable: cacheGet, loadRosterLogs,
|
||||
* computeStreaks/computeFormHeat/applyLens, ledger (getModelAggregate + sb
|
||||
* reads), now.
|
||||
*/
|
||||
async function assembleDeskPack(opts = {}) {
|
||||
const deps = {
|
||||
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
|
||||
loadRosterLogs: opts.loadRosterLogs || require('./rosterLogs').loadRosterLogs,
|
||||
streaks: opts.streaks || require('./streaksService'),
|
||||
lens: opts.lens || require('./streakLens'),
|
||||
ledger: opts.ledger || require('./ledgerService'),
|
||||
now: opts.now || (() => new Date().toISOString()),
|
||||
};
|
||||
const nowIso = deps.now();
|
||||
|
||||
// 1. Snapshots → counts, loudest signal, signals, archetype watch, dispatches.
|
||||
const counts = {};
|
||||
let allGrades = [];
|
||||
for (const sp of SPORTS) {
|
||||
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
|
||||
const grades = snap && Array.isArray(snap.grades) ? snap.grades : [];
|
||||
counts[sp] = grades.length;
|
||||
allGrades = allGrades.concat(grades.map((g) => ({ ...g, sport: sp })));
|
||||
}
|
||||
const graded = allGrades.filter((g) => g.grade && !g.outcome);
|
||||
graded.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
||||
const loudest = graded[0] || null;
|
||||
|
||||
// 2. Streaks through the lens (MLB primary; extendable).
|
||||
let streakRows = [];
|
||||
try {
|
||||
const roster = await deps.loadRosterLogs('mlb');
|
||||
const rows = [
|
||||
...deps.streaks.computeStreaks(roster, 'mlb', { limit: 6 }),
|
||||
...deps.streaks.computeFormHeat(roster, 'mlb', { limit: 3 }),
|
||||
];
|
||||
const sched = await deps.cacheGet(`schedule:mlb:${nowIso.slice(0, 10)}`);
|
||||
streakRows = deps.lens.applyLens(rows, { scheduleGames: Array.isArray(sched) ? sched : [], pitcherGames: [] });
|
||||
} catch { /* streak section degrades to absent */ }
|
||||
|
||||
// 3. Ledger — aggregate + yesterday's settled rows for the Settle/Receipts.
|
||||
const aggregate = await deps.ledger.getModelAggregate({});
|
||||
let settledRows = [];
|
||||
try {
|
||||
if (opts.fetchSettled) settledRows = await opts.fetchSettled();
|
||||
else {
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
if (deps.ledger.__internals.isConfigured()) {
|
||||
const sb = getSupabaseServiceClient();
|
||||
const { data } = await sb.from('ledger_entries')
|
||||
.select('player_name, stat, line, side, grade, graded_at, locked_odds, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at')
|
||||
.is('user_id', null).not('outcome', 'is', null)
|
||||
.order('settled_at', { ascending: false }).limit(30);
|
||||
settledRows = data || [];
|
||||
}
|
||||
}
|
||||
} catch { /* receipts degrade to absent */ }
|
||||
const misses = settledRows.filter((r) => r.outcome === 'miss');
|
||||
|
||||
// 4. Fill the formats. Absent data → absent format, never filler.
|
||||
const dispatches = graded
|
||||
.filter((g) => g.movement && (g.movement.kind === 'steam' || g.movement.kind === 'value'))
|
||||
.slice(0, 4)
|
||||
.map((g) => media.lineDispatch(g, nowIso))
|
||||
.filter(Boolean);
|
||||
|
||||
const pack = {
|
||||
generated_at: nowIso,
|
||||
formats: {
|
||||
morning_wire: variants(media.morningWire({ dateIso: nowIso, counts, loudest })),
|
||||
signals: graded.slice(0, 3).map((g) => variants(media.signal(g))).filter((v) => v.length),
|
||||
streak_watch: variants(media.streakWatch(streakRows, nowIso)),
|
||||
settle: variants(media.theSettle({ aggregate, misses, dateIso: nowIso })),
|
||||
receipts: settledRows.slice(0, 3).map((r) => variants(media.receipt(r))).filter((v) => v.length),
|
||||
archetype_watch: variants(media.archetypeWatch(graded, nowIso)),
|
||||
line_dispatches: dispatches.map((d) => variants(d)),
|
||||
},
|
||||
// 5. DATA BRIEF — the day's structured numbers for freeform writing.
|
||||
data_brief: {
|
||||
date: nowIso,
|
||||
slate_counts: counts,
|
||||
top_signals: graded.slice(0, 8).map((g) => ({
|
||||
player: g.player || g.player_name, sport: g.sport, stat: g.stat_type || g.stat,
|
||||
line: g.line, side: g.direction, grade: g.grade, confidence: g.confidence,
|
||||
archetype: g.archetype || null, odds: (g.gradedAt && g.gradedAt.odds) || null,
|
||||
movement: g.movement || null,
|
||||
})),
|
||||
streaks: streakRows.slice(0, 8).map((r) => ({ player: r.player, team: r.team, read: r.lens && r.lens.read })),
|
||||
record: aggregate,
|
||||
settled_today: settledRows.slice(0, 20),
|
||||
},
|
||||
};
|
||||
return pack;
|
||||
}
|
||||
|
||||
module.exports = { assembleDeskPack, __internals: { variants, SPORTS } };
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ghostPublisher (Session 63 / A1-S4d) — saves DRAFTS to the self-hosted
|
||||
* Ghost instance. DRAFTS ONLY — nothing anywhere auto-posts; Kev publishes.
|
||||
*
|
||||
* Zero new dependencies: the Ghost Admin API JWT is HS256 built with node
|
||||
* crypto. Env-gated (GHOST_URL + GHOST_ADMIN_API_KEY 'id:secret') — without
|
||||
* env every call is a documented no-op.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
||||
|
||||
/** Ghost Admin API JWT: HS256, kid = key id, aud '/admin/', 5-min expiry. */
|
||||
function ghostJwt(adminKey, nowSec = Math.floor(Date.now() / 1000)) {
|
||||
const [id, secret] = String(adminKey || '').split(':');
|
||||
if (!id || !secret) return null;
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id }));
|
||||
const payload = b64url(JSON.stringify({ iat: nowSec, exp: nowSec + 300, aud: '/admin/' }));
|
||||
const sig = crypto.createHmac('sha256', Buffer.from(secret, 'hex'))
|
||||
.update(`${header}.${payload}`).digest('base64url');
|
||||
return `${header}.${payload}.${sig}`;
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(process.env.GHOST_URL && process.env.GHOST_ADMIN_API_KEY);
|
||||
}
|
||||
|
||||
/** Save one draft. Returns { ok, id? } — never throws. */
|
||||
async function saveDraft({ title, html, tags = [] }, opts = {}) {
|
||||
if (!opts.force && !isConfigured()) return { ok: false, reason: 'ghost not configured' };
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const base = (opts.url || process.env.GHOST_URL || '').replace(/\/$/, '');
|
||||
const token = ghostJwt(opts.adminKey || process.env.GHOST_ADMIN_API_KEY, opts.nowSec);
|
||||
if (!token) return { ok: false, reason: 'bad admin key' };
|
||||
try {
|
||||
const res = await fetchImpl(`${base}/ghost/api/admin/posts/?source=html`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Ghost ${token}` },
|
||||
body: JSON.stringify({ posts: [{ title, html, status: 'draft', tags }] }),
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: `ghost ${res.status}` };
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: true, id: data.posts && data.posts[0] && data.posts[0].id };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Daily slate preview + weekly record review drafts from the desk pack. */
|
||||
async function saveDailyDrafts(pack, opts = {}) {
|
||||
if (!pack || !pack.formats) return { ok: false, reason: 'no pack' };
|
||||
const results = [];
|
||||
const wire = pack.formats.morning_wire && pack.formats.morning_wire[0];
|
||||
if (wire) {
|
||||
results.push(await saveDraft({
|
||||
title: `The Slate — ${new Date(pack.generated_at).toDateString()}`,
|
||||
html: `<pre>${escapeHtml(wire.text)}</pre>`,
|
||||
tags: ['slate'],
|
||||
}, opts));
|
||||
}
|
||||
const settle = pack.formats.settle && pack.formats.settle[0];
|
||||
if (settle) {
|
||||
results.push(await saveDraft({
|
||||
title: `The Settle — ${new Date(pack.generated_at).toDateString()}`,
|
||||
html: `<pre>${escapeHtml(settle.text)}</pre>`,
|
||||
tags: ['settle'],
|
||||
}, opts));
|
||||
}
|
||||
return { ok: results.every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
module.exports = { saveDraft, saveDailyDrafts, ghostJwt, isConfigured };
|
||||
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* mediaEngine — VOICE v1.1 template engine (Session 63 / A1-S4).
|
||||
*
|
||||
* Deterministic templates for the wire formats (MORNING WIRE, SIGNAL,
|
||||
* STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH),
|
||||
* filled ONLY from pipeline JSON (snapshot/ledger/streaks). Templates never
|
||||
* compose numbers — the Data Semantics Rule extends to marketing. Record
|
||||
* claims obey the product's n≥20 gate. Stark-layer lines are SELECTED from
|
||||
* the curated committed library (content/stark-lines.json), never generated.
|
||||
*
|
||||
* Every emitted string passes lintVoice() — the banned list + the
|
||||
* no-exclamation law are enforced at build time by tests and at runtime by
|
||||
* assembly (a template that fails lint throws in dev, drops the line in prod).
|
||||
*
|
||||
* NOTHING here posts anywhere. Output goes to /desk for Kev to publish.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STARK = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'content', 'stark-lines.json'), 'utf8'));
|
||||
|
||||
// ---- VOICE lint -----------------------------------------------------------
|
||||
const BANNED_PATTERNS = [
|
||||
/!/, // no exclamation points, ever
|
||||
/\block(s|ed)?\b/i, /🔒/,
|
||||
/🔥/, /💰/, /🚀/, /💯/,
|
||||
/we['’]?re so back/i, /it['’]?s so over/i,
|
||||
/\btail\b/i, /\bfade\b/i, // as calls to action — banned outright
|
||||
/can['’]?t lose/i, /free money/i, /guarantee/i,
|
||||
/RT if/i, /who['’]?s tailing/i, /\btailing\b/i,
|
||||
/\beating\b/i, /\bcooking\b/i, /\bGOAT\b/,
|
||||
];
|
||||
|
||||
/** Returns [] when clean, else the matched patterns (as strings). */
|
||||
function lintVoice(text) {
|
||||
const hits = [];
|
||||
for (const re of BANNED_PATTERNS) {
|
||||
if (re.test(String(text || ''))) hits.push(re.source);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function assertVoice(text, context) {
|
||||
const hits = lintVoice(text);
|
||||
if (hits.length > 0) {
|
||||
const msg = `[mediaEngine] VOICE lint failed (${context}): ${hits.join(', ')}`;
|
||||
if (process.env.NODE_ENV === 'test') throw new Error(msg);
|
||||
console.warn(msg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o');
|
||||
|
||||
function etTime(iso, withDay = false) {
|
||||
const d = iso ? new Date(iso) : new Date();
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const opts = withDay
|
||||
? { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric' }
|
||||
: { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' };
|
||||
return new Intl.DateTimeFormat('en-US', opts).format(d);
|
||||
}
|
||||
|
||||
/** Deterministic stark selection: day-of-year rotation, never random
|
||||
* (Data Semantics: reproducible output for the same inputs + date). */
|
||||
function starkLine(category, dateIso) {
|
||||
const lines = STARK[category] || [];
|
||||
if (lines.length === 0) return null;
|
||||
const d = dateIso ? new Date(dateIso) : new Date();
|
||||
const doy = Math.floor((d.getTime() - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86_400_000);
|
||||
return lines[doy % lines.length];
|
||||
}
|
||||
|
||||
/** Segment a post into ≤280-char tweets on line boundaries. */
|
||||
function segmentThread(text, limit = 280) {
|
||||
const lines = String(text || '').split('\n');
|
||||
const tweets = [];
|
||||
let cur = '';
|
||||
for (const line of lines) {
|
||||
const candidate = cur ? `${cur}\n${line}` : line;
|
||||
if (candidate.length > limit && cur) {
|
||||
tweets.push(cur);
|
||||
cur = line;
|
||||
} else {
|
||||
cur = candidate;
|
||||
}
|
||||
}
|
||||
if (cur.trim()) tweets.push(cur);
|
||||
return tweets;
|
||||
}
|
||||
|
||||
// ---- formats ---------------------------------------------------------------
|
||||
|
||||
/** THE MORNING WIRE. data = { dateIso, counts: {mlb, wnba, ...}, loudest: grade row|null } */
|
||||
function morningWire(data = {}) {
|
||||
const parts = Object.entries(data.counts || {})
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([sp, n]) => `${n} ${sp.toUpperCase()}.`);
|
||||
const lines = [`THE WIRE — ${etTime(data.dateIso, true)}`];
|
||||
if (parts.length > 0) lines.push(parts.join(' '));
|
||||
if (data.loudest && data.loudest.grade) {
|
||||
const l = data.loudest;
|
||||
const arch = l.archetype ? `a ${l.archetype} read` : `an ${l.grade} read`;
|
||||
lines.push(`Loudest signal: ${arch} — ${l.player} ${l.stat_type || l.stat} ${sideCh(l.direction)}${l.line} · ${l.grade}.`);
|
||||
}
|
||||
const stark = starkLine('morning', data.dateIso);
|
||||
if (stark) lines.push(stark);
|
||||
lines.push('Slate’s graded. 3 free reads a day.', 'vyndr.app');
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'morningWire');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** SIGNAL. g = one graded snapshot row (must carry gradedAt). */
|
||||
function signal(g = {}) {
|
||||
if (!g.player || !g.grade) return null;
|
||||
const lines = [
|
||||
`${g.player} — ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`,
|
||||
];
|
||||
if (g.archetype) lines.push(`${g.archetype} read.`);
|
||||
const odds = g.gradedAt && g.gradedAt.odds != null ? ` at ${g.gradedAt.odds}` : '';
|
||||
const ts = g.gradedAt && g.gradedAt.timestamp ? `Graded ${etTime(g.gradedAt.timestamp)} ET${odds}.` : null;
|
||||
if (ts) lines.push(ts);
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'signal');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** STREAK WATCH. rows = lens-applied streak rows. */
|
||||
function streakWatch(rows = [], dateIso) {
|
||||
const real = rows.filter((r) => r && r.lens && r.lens.read).slice(0, 4);
|
||||
if (real.length === 0) return null;
|
||||
const lines = [`STREAK WATCH — ${etTime(dateIso, true)}`];
|
||||
for (const r of real) lines.push(`${r.player}: ${r.lens.read}.`);
|
||||
const stark = starkLine('streak', dateIso);
|
||||
if (stark) lines.push(stark);
|
||||
lines.push('vyndr.app/explore');
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'streakWatch');
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SETTLE. agg = ledger model aggregate; misses = settled miss rows.
|
||||
* Record percentages ONLY at n≥20 (the aggregate already nulls them) —
|
||||
* below that, counts only, honestly labeled.
|
||||
*/
|
||||
function theSettle({ aggregate, misses = [], dateIso } = {}) {
|
||||
const a = aggregate || {};
|
||||
const lines = [`THE SETTLE — ${etTime(dateIso, true)}`];
|
||||
const tierA = a.by_tier && a.by_tier['A'];
|
||||
if (tierA && tierA.settled > 0) lines.push(`A-tier: ${tierA.hits}-${tierA.misses}`);
|
||||
if (a.settled > 0) lines.push(`All grades: ${a.hits}-${a.misses}${a.pushes ? ` (${a.pushes} push)` : ''}`);
|
||||
else lines.push('Nothing settled today.');
|
||||
if (a.hit_pct == null && a.settled > 0) {
|
||||
lines.push(`Record building — ${a.settled} settled, ${a.pending || 0} pending. Percentages post at 20.`);
|
||||
}
|
||||
const missRows = misses.filter((m) => m && m.player_name).slice(0, 6);
|
||||
if (missRows.length > 0) {
|
||||
lines.push(`Misses, by name: ${missRows.map((m) => `${m.player_name} ${humanStat(m.stat)} ${sideCh(m.side)}${m.line} ❌`).join(' · ')}`);
|
||||
}
|
||||
const stark = starkLine(missRows.length > 0 ? 'settle_losing' : 'settle_winning', dateIso);
|
||||
if (stark) lines.push(stark);
|
||||
lines.push('vyndr.app/ledger');
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'theSettle');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** RECEIPTS. row = settled ledger row with lock + close + outcome. */
|
||||
function receipt(row = {}) {
|
||||
if (!row.player_name || !row.outcome) return null;
|
||||
const s = sideCh(row.side);
|
||||
const lines = [
|
||||
`Posted ${etTime(row.graded_at)} ET: ${row.player_name} ${humanStat(row.stat)} ${s}${row.line} · ${row.grade}${row.locked_odds ? ` · ${row.locked_odds}` : ''}`,
|
||||
];
|
||||
if (row.closing_line != null && row.clv_result) {
|
||||
const clvNote = row.clv_result === 'beat' ? 'Market chased the read.'
|
||||
: row.clv_result === 'faded' ? 'Market went the other way.' : 'Close held.';
|
||||
lines.push(`Close: ${s}${row.closing_line}${row.closing_odds ? ` at ${row.closing_odds}` : ''}. ${clvNote}`);
|
||||
}
|
||||
const mark = row.outcome === 'hit' ? '✅' : row.outcome === 'miss' ? '❌' : 'push';
|
||||
lines.push(`Result: ${row.actual_value != null ? `${row.actual_value} ` : ''}${mark}`);
|
||||
if (row.clv_result === 'beat' && row.outcome === 'hit') {
|
||||
const stark = starkLine('steam', row.settled_at);
|
||||
if (stark) lines.push(stark);
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'receipt');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** ARCHETYPE WATCH. grades = snapshot rows. */
|
||||
function archetypeWatch(grades = [], dateIso) {
|
||||
const byArch = {};
|
||||
for (const g of grades) {
|
||||
if (!g.archetype || !g.grade) continue;
|
||||
byArch[g.archetype] = byArch[g.archetype] || [];
|
||||
byArch[g.archetype].push(g);
|
||||
}
|
||||
const top = Object.entries(byArch).sort((a, b) => b[1].length - a[1].length)[0];
|
||||
if (!top || top[1].length < 2) return null;
|
||||
const [arch, rows] = top;
|
||||
const lines = [
|
||||
`${arch} watch: ${rows.length} on tonight’s slate.`,
|
||||
...rows.slice(0, 4).map((g) => `${g.player} ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`),
|
||||
'vyndr.app',
|
||||
];
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'archetypeWatch');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** LINE DISPATCH. move = intraday movement row on a graded prop. */
|
||||
function lineDispatch(g = {}, nowIso) {
|
||||
const m = g.movement;
|
||||
if (!m || m.delta == null) return null;
|
||||
const s = sideCh(g.direction);
|
||||
const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line;
|
||||
const arrow = m.currentLine > locked ? '▲' : '▼';
|
||||
const kind = m.kind === 'steam' ? 'Steam.' : m.kind === 'value' ? 'Value — the read holds at the new number.' : 'Line moved.';
|
||||
const lines = [
|
||||
`${g.player} ${humanStat(g.stat_type || g.stat)}: ${s}${locked} → ${s}${m.currentLine} ${arrow}`,
|
||||
`${kind} ${etTime(nowIso)} ET.`,
|
||||
];
|
||||
const text = lines.join('\n');
|
||||
assertVoice(text, 'lineDispatch');
|
||||
return text;
|
||||
}
|
||||
|
||||
const STAT_HUMAN = {
|
||||
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
|
||||
strikeouts: 'Ks', stolen_bases: 'SB', earned_runs: 'ER', innings_pitched: 'IP',
|
||||
points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT', outs: 'Outs', doubles: '2B',
|
||||
};
|
||||
function humanStat(stat) {
|
||||
const k = String(stat || '').toLowerCase();
|
||||
return STAT_HUMAN[k] || k.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
lintVoice,
|
||||
segmentThread,
|
||||
starkLine,
|
||||
morningWire,
|
||||
signal,
|
||||
streakWatch,
|
||||
theSettle,
|
||||
receipt,
|
||||
archetypeWatch,
|
||||
lineDispatch,
|
||||
__internals: { BANNED_PATTERNS, etTime, humanStat, assertVoice, STARK },
|
||||
};
|
||||
@@ -110,6 +110,12 @@ function startSnapshotScheduler(opts = {}) {
|
||||
const results = await runAll();
|
||||
const ok = results.filter((r) => r.status === 'ok');
|
||||
console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded`);
|
||||
// Session 63 (A1-S4) — after the day's FIRST slot, tell Kev the media
|
||||
// pack is ready. One ping, only when something actually graded.
|
||||
if (h === HOURS_UTC[0] && ok.length > 0) {
|
||||
const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0);
|
||||
await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[snapshot] cron run failed:', e.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Session 63 (A1-S4) — the VOICE v1.1 template engine. Numbers only from
|
||||
// pipeline JSON; lint enforces the banned list + no-exclamation law; record
|
||||
// claims obey n≥20; Stark lines are SELECTED from the committed library.
|
||||
|
||||
const media = require('../../src/services/mediaEngine');
|
||||
const { lintVoice, segmentThread, starkLine, morningWire, signal, theSettle, receipt, streakWatch, archetypeWatch, lineDispatch } = media;
|
||||
|
||||
describe('lintVoice — the banned list is law', () => {
|
||||
test.each([
|
||||
['This one is a lock!', 2], // "lock" + exclamation
|
||||
['🔥 free money, who’s tailing', 3],
|
||||
['We’re so back 🚀', 2],
|
||||
])('%s → %i violations', (text, n) => {
|
||||
expect(lintVoice(text).length).toBeGreaterThanOrEqual(n);
|
||||
});
|
||||
test('clean wire copy passes', () => {
|
||||
expect(lintVoice('THE SETTLE — Jul 11\nA-tier: 6-2\nvyndr.app/ledger')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every template output passes its own lint', () => {
|
||||
const NOW = '2026-07-12T14:05:00.000Z';
|
||||
const GRADE = {
|
||||
player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over',
|
||||
grade: 'A', confidence: 78, archetype: 'BOMBER',
|
||||
gradedAt: { line: 1.5, odds: -120, timestamp: NOW },
|
||||
};
|
||||
|
||||
test('morning wire: real counts, stark from the library, no bans', () => {
|
||||
const text = morningWire({ dateIso: NOW, counts: { mlb: 15, wnba: 3, nba: 0 }, loudest: GRADE });
|
||||
expect(text).toContain('15 MLB.');
|
||||
expect(text).toContain('3 WNBA.');
|
||||
expect(text).not.toContain('0 NBA'); // zero sports never listed
|
||||
expect(text).toContain('Aaron Judge');
|
||||
expect(text).toContain('vyndr.app');
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('signal: player + line + grade + ET timestamp with real odds', () => {
|
||||
const text = signal(GRADE);
|
||||
expect(text).toContain('Aaron Judge — TB o1.5 · A');
|
||||
expect(text).toContain('BOMBER read.');
|
||||
expect(text).toContain('at -120');
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the settle: counts always, percentages NEVER under n≥20', () => {
|
||||
const agg = { settled: 8, hits: 5, misses: 3, pushes: 0, hit_pct: null, pending: 16, by_tier: { A: { settled: 4, hits: 3, misses: 1, hit_pct: null } } };
|
||||
const misses = [{ player_name: 'A’ja Wilson', stat: 'points', line: 23.5, side: 'over' }];
|
||||
const text = theSettle({ aggregate: agg, misses, dateIso: NOW });
|
||||
expect(text).toContain('All grades: 5-3');
|
||||
expect(text).toContain('A-tier: 3-1');
|
||||
expect(text).not.toContain('%'); // no percentage below the gate
|
||||
expect(text).toContain('Record building — 8 settled');
|
||||
expect(text).toContain('Wilson PTS o23.5 ❌'); // misses BY NAME
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('receipt: lock beside close beside result', () => {
|
||||
const row = {
|
||||
player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A',
|
||||
graded_at: NOW, locked_odds: '-120', closing_line: 1.5, closing_odds: '-145',
|
||||
clv: 0, clv_result: 'flat', outcome: 'hit', actual_value: 2, settled_at: NOW,
|
||||
};
|
||||
const text = receipt(row);
|
||||
expect(text).toContain('Posted');
|
||||
expect(text).toContain('Judge TB o1.5 · A · -120');
|
||||
expect(text).toContain('Close: o1.5 at -145');
|
||||
expect(text).toContain('Result: 2 ✅');
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('streak watch: lens reads only, absent lens rows dropped', () => {
|
||||
const rows = [
|
||||
{ player: 'Brice Turang', lens: { read: '12-game on-base streak; tonight @ Pittsburgh Pirates' } },
|
||||
{ player: 'No Lens Guy', lens: { read: null } },
|
||||
];
|
||||
const text = streakWatch(rows, NOW);
|
||||
expect(text).toContain('Turang');
|
||||
expect(text).not.toContain('No Lens Guy');
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('archetype watch needs ≥2 of a kind, else null', () => {
|
||||
expect(archetypeWatch([GRADE], NOW)).toBeNull();
|
||||
const two = archetypeWatch([GRADE, { ...GRADE, player: 'Shohei Ohtani' }], NOW);
|
||||
expect(two).toContain('BOMBER watch: 2');
|
||||
expect(lintVoice(two)).toEqual([]);
|
||||
});
|
||||
|
||||
test('line dispatch from a real movement row', () => {
|
||||
const text = lineDispatch({ ...GRADE, movement: { kind: 'steam', delta: 1, currentLine: 2.5 } }, NOW);
|
||||
expect(text).toContain('o1.5 → o2.5 ▲');
|
||||
expect(text).toContain('Steam.');
|
||||
expect(lintVoice(text)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stark layer — selected, never generated; deterministic', () => {
|
||||
test('same date → same line; comes from the committed library', () => {
|
||||
const a = starkLine('morning', '2026-07-12T10:00:00Z');
|
||||
const b = starkLine('morning', '2026-07-12T22:00:00Z');
|
||||
expect(a).toBe(b);
|
||||
expect(media.__internals.STARK.morning).toContain(a);
|
||||
});
|
||||
test('the whole library passes lint', () => {
|
||||
for (const lines of Object.values(media.__internals.STARK)) {
|
||||
if (!Array.isArray(lines)) continue;
|
||||
for (const l of lines) expect(lintVoice(l)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('segmentThread — ≤280 per tweet on line boundaries', () => {
|
||||
test('splits long threads and preserves every line', () => {
|
||||
const long = Array.from({ length: 30 }, (_, i) => `Line ${i} of the settle with some padding text`).join('\n');
|
||||
const tweets = segmentThread(long);
|
||||
expect(tweets.length).toBeGreaterThan(1);
|
||||
for (const t of tweets) expect(t.length).toBeLessThanOrEqual(280);
|
||||
expect(tweets.join('\n')).toBe(long);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deskService — pack assembly from pipeline data only', () => {
|
||||
const { assembleDeskPack } = require('../../src/services/deskService');
|
||||
test('assembles formats + data brief from injected caches', async () => {
|
||||
const NOW = '2026-07-12T14:05:00.000Z';
|
||||
const store = {
|
||||
'snapshot:mlb:latest': { grades: [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 80, archetype: 'BOMBER', gradedAt: { line: 1.5, odds: -120, timestamp: NOW } },
|
||||
{ player: 'Shohei Ohtani', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 76, archetype: 'BOMBER', gradedAt: { line: 0.5, odds: 130, timestamp: NOW } },
|
||||
] },
|
||||
};
|
||||
const pack = await assembleDeskPack({
|
||||
cacheGet: async (k) => store[k] ?? null,
|
||||
loadRosterLogs: async () => [],
|
||||
ledger: { getModelAggregate: async () => ({ settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null, pending: 24 }), __internals: { isConfigured: () => false } },
|
||||
fetchSettled: async () => [],
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(pack.formats.morning_wire[0].text).toContain('2 MLB.');
|
||||
expect(pack.formats.signals.length).toBe(2);
|
||||
expect(pack.formats.archetype_watch[0].text).toContain('BOMBER watch: 2');
|
||||
// variants: wire + numbers-only when a stark line was present
|
||||
expect(pack.formats.morning_wire.length).toBeGreaterThanOrEqual(1);
|
||||
expect(pack.data_brief.top_signals[0].player).toBe('Aaron Judge');
|
||||
expect(pack.data_brief.record.pending).toBe(24);
|
||||
// every emitted text passes lint
|
||||
const all = JSON.stringify(pack.formats);
|
||||
expect(all).not.toContain('!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ghostPublisher — drafts only, env-gated, no deps', () => {
|
||||
const ghost = require('../../src/services/ghostPublisher');
|
||||
test('JWT is a valid HS256 three-parter with the key id', () => {
|
||||
const t = ghost.ghostJwt('abc123:6465616462656566', 1_752_000_000);
|
||||
const [h, p] = t.split('.');
|
||||
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
|
||||
const payload = JSON.parse(Buffer.from(p, 'base64url').toString());
|
||||
expect(header.kid).toBe('abc123');
|
||||
expect(payload.aud).toBe('/admin/');
|
||||
expect(payload.exp - payload.iat).toBe(300);
|
||||
});
|
||||
test('unconfigured → no-op, never a throw', async () => {
|
||||
const r = await ghost.saveDraft({ title: 't', html: '<p>x</p>' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('ghost not configured');
|
||||
});
|
||||
test('posts DRAFT status only', async () => {
|
||||
let body = null;
|
||||
const r = await ghost.saveDraft({ title: 't', html: '<p>x</p>' }, {
|
||||
force: true, url: 'https://blog.test', adminKey: 'id:6162',
|
||||
fetchImpl: async (url, opts) => { body = JSON.parse(opts.body); return { ok: true, json: async () => ({ posts: [{ id: 'p1' }] }) }; },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(body.posts[0].status).toBe('draft'); // NOTHING auto-publishes
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/** Desk pack proxy (Session 63 / A1-S4) — forwards the founder's auth. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ error: 'auth required' }, { status: 401 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/desk/pack`, {
|
||||
headers: { Accept: 'application/json', Authorization: auth },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'desk unavailable' }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
|
||||
* Auth: gated route + backend email allowlist (DESK_OWNERS). Everything on
|
||||
* this page is assembled from the pipeline by the VOICE v1.1 template
|
||||
* engine; per-tweet copy buttons + character counts; the DATA BRIEF block
|
||||
* pastes into claude.ai for freeform writing. Nothing auto-posts.
|
||||
*/
|
||||
|
||||
interface Variant { label: string; text: string; tweets: string[] }
|
||||
interface DeskPack {
|
||||
generated_at: string | null;
|
||||
formats: {
|
||||
morning_wire?: Variant[];
|
||||
signals?: Variant[][];
|
||||
streak_watch?: Variant[];
|
||||
settle?: Variant[];
|
||||
receipts?: Variant[][];
|
||||
archetype_watch?: Variant[];
|
||||
line_dispatches?: Variant[][];
|
||||
};
|
||||
data_brief?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function CopyBtn({ text, label = 'COPY' }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
});
|
||||
}}
|
||||
className="mono"
|
||||
style={{ cursor: 'pointer', background: done ? 'var(--g-a)' : 'transparent', color: done ? '#06060B' : 'var(--text-1)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '4px 10px', fontSize: 10, fontWeight: 700, letterSpacing: '0.06em' }}
|
||||
>
|
||||
{done ? 'COPIED' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantBlock({ v }: { v: Variant }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span className="lbl">{v.label.toUpperCase()}</span>
|
||||
<CopyBtn text={v.text} label="COPY ALL" />
|
||||
</div>
|
||||
{v.tweets.map((t, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '8px 10px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 6 }}>
|
||||
<pre className="mono" style={{ margin: 0, flex: 1, whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-0)' }}>{t}</pre>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end', flexShrink: 0 }}>
|
||||
<CopyBtn text={t} />
|
||||
<span className="mono" style={{ fontSize: 9.5, color: t.length > 280 ? 'var(--miss)' : 'var(--text-2)' }}>{t.length}/280</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatCard({ title, variants }: { title: string; variants?: Variant[] }) {
|
||||
if (!variants || variants.length === 0) return null;
|
||||
return (
|
||||
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 12 }}>{title}</div>
|
||||
{variants.map((v, i) => <VariantBlock key={i} v={v} />)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeskPage() {
|
||||
const { session } = useAuth();
|
||||
const [pack, setPack] = useState<DeskPack | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'denied' | 'ready' | 'error'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
const token = session?.access_token;
|
||||
if (!token) return;
|
||||
let active = true;
|
||||
fetch('/api/desk/pack', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(async (r) => {
|
||||
if (!active) return;
|
||||
if (r.status === 403 || r.status === 401) { setStatus('denied'); return; }
|
||||
const data = (await r.json()) as DeskPack;
|
||||
setPack(data);
|
||||
setStatus(data && data.formats ? 'ready' : 'error');
|
||||
})
|
||||
.catch(() => { if (active) setStatus('error'); });
|
||||
return () => { active = false; };
|
||||
}, [session]);
|
||||
|
||||
if (status === 'denied') {
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>The desk is the publisher’s surface. DESK_OWNERS grants access.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (status !== 'ready' || !pack) {
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const f = pack.formats;
|
||||
return (
|
||||
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>THE DESK</div>
|
||||
<h1 className="mono" style={{ margin: 0, fontSize: 26, fontWeight: 800 }}>Today’s arsenal</h1>
|
||||
{pack.generated_at && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 6 }}>
|
||||
Assembled {new Date(pack.generated_at).toLocaleString('en-US', { timeZone: 'America/New_York' })} ET · nothing auto-posts
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<FormatCard title="THE MORNING WIRE" variants={f.morning_wire} />
|
||||
{(f.signals || []).map((v, i) => <FormatCard key={`sig-${i}`} title={`SIGNAL ${i + 1}`} variants={v} />)}
|
||||
<FormatCard title="STREAK WATCH" variants={f.streak_watch} />
|
||||
<FormatCard title="ARCHETYPE WATCH" variants={f.archetype_watch} />
|
||||
{(f.line_dispatches || []).map((v, i) => <FormatCard key={`ld-${i}`} title={`LINE DISPATCH ${i + 1}`} variants={v} />)}
|
||||
<FormatCard title="THE SETTLE" variants={f.settle} />
|
||||
{(f.receipts || []).map((v, i) => <FormatCard key={`rc-${i}`} title={`RECEIPT ${i + 1}`} variants={v} />)}
|
||||
|
||||
{/* DATA BRIEF — paste into claude.ai for freeform Read Room threads. */}
|
||||
{pack.data_brief != null && (
|
||||
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>DATA BRIEF</span>
|
||||
<CopyBtn text={JSON.stringify(pack.data_brief, null, 2)} label="COPY BRIEF" />
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>paste into claude.ai for Read Room + freeform</span>
|
||||
</div>
|
||||
<pre className="mono" style={{ margin: 0, maxHeight: 320, overflow: 'auto', fontSize: 11, lineHeight: 1.5, color: 'var(--text-1)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(pack.data_brief, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
gating them would be a monetization regression. We gate only the genuinely
|
||||
personal surfaces (a user's own ledger, bets, account, alerts). */
|
||||
const GATED_ROUTES = [
|
||||
'/desk', // Session 63 (A1-S4) — the founder's media surface (+ backend allowlist)
|
||||
'/ledger',
|
||||
'/tracker',
|
||||
'/account',
|
||||
|
||||
Reference in New Issue
Block a user