feat(web): add BackfillView for Wave B cohort re-engagement
Some checks are pending
CI / verify (push) Waiting to run
Some checks are pending
CI / verify (push) Waiting to run
- New view at web/src/views/BackfillView.tsx using ui/ primitives (Card, VStack/HStack, Button, Bars for progress, Toast etc). - Integrates usePoll(getBackfillCohorts) + createTask for run / run-all buttons (matches existing api + backend backfill tasks). - Headline, per-cohort coverage bars + status, BACKFILL LOG, warm scan + run actions, flashes exactly as prototype (Prospector.dc.html + PLAN). - Client computes aggregates; log local + server covered updates on create. - Verified: typecheck + `npm run build --workspace web` green in isolated worktree. - Per PLAN Wave B unit; created in dedicated worktree with no shared edits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6e72bf41f
commit
050c739e7e
1 changed files with 274 additions and 0 deletions
274
web/src/views/BackfillView.tsx
Normal file
274
web/src/views/BackfillView.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* BackfillView — cohort re-engagement for warm prospects (Wave B per PLAN.md).
|
||||
* Matches prototype markup/behavior (docs/prototype/Prospector.dc.html: BACKFILL_COHORTS, buildBackfill, runBackfill, runAllBackfill, scanBackfill, headline, per-cohort bars, BACKFILL LOG).
|
||||
* Uses ui/ primitives exclusively for containers/controls (Card, Title, Muted, VStack, HStack, Button, SectionLabel, ErrText, Toast, Bars for progress).
|
||||
* Polling: usePoll(getBackfillCohorts, 15000).
|
||||
* Run actions: createTask({ type: 'backfill', reason: `backfill ${key}: ${name}` }) — leverages existing /tasks + advancement for backfill (see tasks.service.ts, advancement.service.ts).
|
||||
* Local session log + timed flashes match prototype output format + messages.
|
||||
* Dynamic covered/pending from server; creating backfill tasks bumps recent counts (prototype parity via heuristics).
|
||||
* Header + actions + coverage + cohort list + log structure preserved.
|
||||
*
|
||||
* Created in isolated worktree only. No shared file modifications.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createTask, getBackfillCohorts, type BackfillCohort } from '../api';
|
||||
import { usePoll } from '../usePoll';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrText,
|
||||
HStack,
|
||||
Muted,
|
||||
SectionLabel,
|
||||
Title,
|
||||
Toast,
|
||||
Bars,
|
||||
VStack,
|
||||
} from '../ui';
|
||||
|
||||
export function BackfillView(): JSX.Element {
|
||||
const { data, error, loading } = usePoll(() => getBackfillCohorts(), 15000);
|
||||
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
// Auto-clear flash toasts (prototype flash behavior).
|
||||
useEffect(() => {
|
||||
if (!flash) return undefined;
|
||||
const h = window.setTimeout(() => setFlash(null), 4500);
|
||||
return () => {
|
||||
window.clearTimeout(h);
|
||||
};
|
||||
}, [flash]);
|
||||
|
||||
const cohorts: BackfillCohort[] = data?.cohorts ?? [];
|
||||
const total = cohorts.reduce((a, c) => a + c.total, 0);
|
||||
const reached = cohorts.reduce((a, c) => a + c.covered, 0);
|
||||
const pendingAll = total - reached;
|
||||
const headline = `${reached} / ${total} warm prospects reached since Jun 22`;
|
||||
const pctOverall = total > 0 ? Math.round((reached / total) * 100) : 0;
|
||||
|
||||
const progressRows = [
|
||||
{ key: 'reached', count: reached },
|
||||
{ key: 'pending', count: pendingAll },
|
||||
];
|
||||
|
||||
const formatHHMMSS = (d: Date): string =>
|
||||
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
async function runOne(cohort: BackfillCohort): Promise<void> {
|
||||
if (cohort.pending <= 0 || busy) return;
|
||||
if (!window.confirm(`Backfill ${cohort.pending} for “${cohort.name.split(' · ')[0]}”? This enqueues via the runner.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const hh = formatHHMMSS(new Date());
|
||||
try {
|
||||
await createTask({ type: 'backfill', reason: `backfill ${cohort.key}: ${cohort.name}` });
|
||||
const short = cohort.name.split(' · ')[0];
|
||||
const entry = `${hh} backfill ${short} → ${cohort.pending} enqueued via macsync outbox`;
|
||||
setLog((prev) => [...prev.slice(-5), entry]);
|
||||
setFlash(
|
||||
`Backfill “${short}” → ${cohort.pending} enqueued via ${cohort.campaign} · paced peak 7–10pm ET, dedup 72h.`,
|
||||
);
|
||||
} catch (e) {
|
||||
setFlash(`Error: ${errMsg(e)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(): Promise<void> {
|
||||
const todo = cohorts.filter((c) => c.pending > 0);
|
||||
if (todo.length === 0) {
|
||||
setFlash('Nothing pending — every cohort is already backfilled.');
|
||||
return;
|
||||
}
|
||||
const sum = todo.reduce((a, c) => a + c.pending, 0);
|
||||
if (!window.confirm(`Run backfill for all ${todo.length} pending cohorts (total ${sum})?`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const hh = formatHHMMSS(new Date());
|
||||
let enqueued = 0;
|
||||
try {
|
||||
for (const c of todo) {
|
||||
await createTask({ type: 'backfill', reason: `backfill ${c.key}: ${c.name}` });
|
||||
enqueued += c.pending;
|
||||
const short = c.name.split(' · ')[0];
|
||||
const entry = `${hh} backfill ${short} → ${c.pending} enqueued via macsync outbox`;
|
||||
setLog((prev) => [...prev.slice(-5), entry]);
|
||||
}
|
||||
setFlash(`Ran all cohorts → ${enqueued} enqueued via macsync outbox · paced, dedup 72h, MR-gate on sends.`);
|
||||
} catch (e) {
|
||||
setFlash(`Error during run all: ${errMsg(e)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function warmScan(): void {
|
||||
const leaving = cohorts.find((c) => c.key === 'leaving')?.pending ?? 0;
|
||||
const ghost = cohorts.find((c) => c.key === 'ghost')?.pending ?? 0;
|
||||
const ofcurious = cohorts.find((c) => c.key === 'ofcurious')?.pending ?? 0;
|
||||
const owed = cohorts.find((c) => c.key === 'owed')?.pending ?? 0;
|
||||
setFlash(
|
||||
`Warm scan via macsync · ${leaving} leaving-NY · ${ghost} ghost · ${ofcurious} OF-curious · ${owed} owed — cohorts refreshed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack $gap={8}>
|
||||
<Title>Backfill</Title>
|
||||
<ErrText>{error}</ErrText>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack $gap={8}>
|
||||
<Title>Backfill</Title>
|
||||
<Muted>Loading cohorts…</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack $gap={14}>
|
||||
{/* Header row (prototype top bar, no enclosing card) */}
|
||||
<HStack $justify="space-between" $wrap>
|
||||
<div>
|
||||
<Title>Backfill</Title>
|
||||
<Muted style={{ marginLeft: 8 }}>re-engage warm prospects who slipped through</Muted>
|
||||
</div>
|
||||
<HStack $gap={8}>
|
||||
<Button $variant="secondary" disabled={busy} onClick={warmScan}>
|
||||
⟳ Warm scan
|
||||
</Button>
|
||||
<Button $variant="primary" disabled={busy || pendingAll === 0} onClick={() => void runAll()}>
|
||||
Run all →
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Coverage card + overall progress + Bars usage for progress viz */}
|
||||
<Card>
|
||||
<VStack $gap={10}>
|
||||
<HStack $justify="space-between" $align="baseline">
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Coverage</span>
|
||||
<Muted style={{ fontSize: 11 }}>{headline}</Muted>
|
||||
</HStack>
|
||||
<div style={{ height: 9, background: '#101012', borderRadius: 5, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'block', height: '100%', background: '#34d399', width: `${pctOverall}%` }} />
|
||||
</div>
|
||||
<HStack $gap={16} style={{ fontSize: '11.5px', fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
||||
<span style={{ color: '#34d399' }}>{reached} reached</span>
|
||||
<span style={{ color: '#fbbf24' }}>{pendingAll} pending</span>
|
||||
<Muted>{total} warm total</Muted>
|
||||
</HStack>
|
||||
<Bars rows={progressRows} tone="success" />
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Per-cohort cards (prototype structure + colors) */}
|
||||
<VStack $gap={9}>
|
||||
{cohorts.map((c) => {
|
||||
const done = c.pending <= 0;
|
||||
const tone = c.key === 'leaving' ? '#fbbf24' : c.key === 'ghost' ? '#93c5fd' : c.key === 'ofcurious' ? '#c084fc' : '#34d399';
|
||||
const runLabel = done ? 'covered' : `Backfill ${c.pending} →`;
|
||||
return (
|
||||
<Card key={c.key}>
|
||||
<VStack $gap={9}>
|
||||
<HStack $justify="space-between" $align="flex-start" $gap={12}>
|
||||
<VStack $gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#e6e7ea' }}>{c.name}</div>
|
||||
<Muted style={{ fontSize: 11, lineHeight: 1.5 }}>{c.why}</Muted>
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || done}
|
||||
onClick={() => void runOne(c)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '7px 13px',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: busy || done ? 'default' : 'pointer',
|
||||
border: `1px solid ${done ? '#34343b' : tone + '88'}`,
|
||||
background: done ? '#242428' : tone + '22',
|
||||
color: done ? '#52525b' : tone,
|
||||
}}
|
||||
>
|
||||
{runLabel}
|
||||
</button>
|
||||
</HStack>
|
||||
<HStack $gap={9} $wrap>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '.06em',
|
||||
color: tone,
|
||||
background: '#101012',
|
||||
border: `1px solid ${tone}55`,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
{c.campaign}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#94959c', fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
||||
{c.covered} / {c.total} reached
|
||||
</span>
|
||||
</HStack>
|
||||
{/* Per-cohort progress bar (prototype exact) */}
|
||||
<div style={{ height: 7, background: '#101012', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'block', height: '100%', background: tone, width: `${c.pct}%` }} />
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{cohorts.length === 0 && <Muted>No cohorts returned.</Muted>}
|
||||
</VStack>
|
||||
|
||||
{/* Backfill log card */}
|
||||
<Card>
|
||||
<SectionLabel style={{ marginBottom: 9 }}>BACKFILL LOG</SectionLabel>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
maxHeight: 160,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'ui-monospace, Menlo, monospace',
|
||||
fontSize: 11,
|
||||
color: '#a1a1aa',
|
||||
}}
|
||||
>
|
||||
{log.length === 0 ? (
|
||||
<Muted>No backfill runs yet in this session.</Muted>
|
||||
) : (
|
||||
log.map((l, i) => (
|
||||
<div key={i} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{flash && <Toast>{flash}</Toast>}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue