Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Natalie
050c739e7e feat(web): add BackfillView for Wave B cohort re-engagement
Some checks failed
CI / verify (push) Failing after 3m36s
- 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>
2026-06-29 21:54:24 -04:00

View 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 710pm 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>
);
}