Files
Neon-Desk/lib/server/task-notifications.ts

239 lines
7.5 KiB
TypeScript

import {
fallbackStageProgress,
stageLabel,
taskTypeLabel
} from '@/lib/task-workflow';
import type {
Task,
TaskNotificationAction,
TaskNotificationStat,
TaskNotificationView
} from '@/lib/types';
type TaskCore = Omit<Task, 'notification'>;
function asRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function asString(value: unknown) {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asNumber(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function formatInteger(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
}
function buildProgress(task: TaskCore) {
const currentProgress = task.stage_context?.progress ?? fallbackStageProgress(task);
if (!currentProgress || currentProgress.total <= 0) {
return null;
}
const current = Math.min(Math.max(Math.trunc(currentProgress.current), 0), Math.trunc(currentProgress.total));
const total = Math.max(Math.trunc(currentProgress.total), 1);
const percent = total > 0 ? Math.min(100, Math.max(0, Math.round((current / total) * 100))) : null;
return {
current,
total,
unit: currentProgress.unit,
percent
};
}
function makeStat(label: string, value: number | string | null | undefined): TaskNotificationStat | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'number') {
return { label, value: formatInteger(value) };
}
const normalized = value.trim();
return normalized ? { label, value: normalized } : null;
}
function buildStats(task: TaskCore): TaskNotificationStat[] {
const result = asRecord(task.result);
const counters = task.stage_context?.counters ?? {};
const stats: Array<TaskNotificationStat | null> = [];
switch (task.task_type) {
case 'sync_filings':
stats.push(
makeStat('Fetched', asNumber(result?.fetched) ?? counters.fetched ?? task.stage_context?.progress?.total ?? null),
makeStat('Inserted', asNumber(result?.inserted) ?? counters.inserted ?? null),
makeStat('Updated', asNumber(result?.updated) ?? counters.updated ?? null),
makeStat('Hydrated', asNumber(result?.taxonomySnapshotsHydrated) ?? counters.hydrated ?? null),
makeStat('Failed', asNumber(result?.taxonomySnapshotsFailed) ?? counters.failed ?? null)
);
break;
case 'refresh_prices':
stats.push(
makeStat('Tickers', asNumber(result?.totalTickers) ?? task.stage_context?.progress?.total ?? null),
makeStat('Updated', asNumber(result?.updatedCount) ?? counters.updatedCount ?? null),
makeStat('Holdings', counters.holdings ?? null)
);
break;
case 'analyze_filing':
stats.push(
makeStat('Ticker', asString(result?.ticker) ?? task.stage_context?.subject?.ticker ?? null),
makeStat('Form', asString(result?.filingType) ?? null),
makeStat('Model', asString(result?.model) ?? null)
);
break;
case 'index_search':
stats.push(
makeStat('Sources', asNumber(result?.sourcesCollected) ?? counters.sourcesCollected ?? task.stage_context?.progress?.total ?? null),
makeStat('Indexed', asNumber(result?.indexed) ?? counters.indexed ?? null),
makeStat('Chunks', asNumber(result?.chunksEmbedded) ?? counters.chunksEmbedded ?? null),
makeStat('Skipped', asNumber(result?.skipped) ?? counters.skipped ?? null),
makeStat('Deleted', asNumber(result?.deleted) ?? counters.deleted ?? null)
);
break;
case 'portfolio_insights': {
const summary = asRecord(result?.summary);
stats.push(
makeStat('Positions', asNumber(summary?.positions) ?? counters.holdings ?? null),
makeStat('Provider', asString(result?.provider) ?? null),
makeStat('Model', asString(result?.model) ?? null)
);
break;
}
}
if (stats.every((stat) => stat === null)) {
const fallbackStats: Array<TaskNotificationStat | null> = [];
for (const [label, value] of Object.entries(counters)) {
fallbackStats.push(makeStat(label, value));
}
return fallbackStats.filter((stat): stat is TaskNotificationStat => Boolean(stat));
}
return stats.filter((stat): stat is TaskNotificationStat => Boolean(stat));
}
function buildTaskHref(task: TaskCore) {
const result = asRecord(task.result);
const payload = asRecord(task.payload);
const ticker = asString(result?.ticker)
?? task.stage_context?.subject?.ticker
?? asString(payload?.ticker);
const accessionNumber = asString(result?.accessionNumber)
?? task.stage_context?.subject?.accessionNumber
?? asString(payload?.accessionNumber);
return {
ticker,
accessionNumber
};
}
function buildActions(task: TaskCore): TaskNotificationAction[] {
const { ticker, accessionNumber } = buildTaskHref(task);
const actions: TaskNotificationAction[] = [];
switch (task.task_type) {
case 'sync_filings':
actions.push({
id: 'open_filings',
label: 'Open filings',
href: ticker ? `/filings?ticker=${encodeURIComponent(ticker)}` : '/filings',
primary: true
});
break;
case 'analyze_filing':
if (ticker && accessionNumber) {
actions.push({
id: 'open_analysis_report',
label: 'Open summary',
href: `/analysis/reports/${encodeURIComponent(ticker)}/${encodeURIComponent(accessionNumber)}`,
primary: true
});
}
actions.push({
id: 'open_filings',
label: 'Open filings',
href: ticker ? `/filings?ticker=${encodeURIComponent(ticker)}` : '/filings',
primary: actions.length === 0
});
break;
case 'refresh_prices':
case 'portfolio_insights':
actions.push({
id: 'open_portfolio',
label: 'Open portfolio',
href: '/portfolio',
primary: true
});
break;
case 'index_search':
actions.push({
id: 'open_search',
label: 'Open search',
href: ticker ? `/search?ticker=${encodeURIComponent(ticker)}` : '/search',
primary: true
});
break;
}
actions.push({
id: 'open_details',
label: 'Open details',
href: null
});
return actions;
}
function buildStatusLine(task: TaskCore, progress: TaskNotificationView['progress']) {
switch (task.status) {
case 'queued':
return 'Queued for execution';
case 'running':
return progress?.percent !== null && progress?.percent !== undefined
? `Running ${stageLabel(task.stage).toLowerCase()} · ${progress.percent}%`
: `Running ${stageLabel(task.stage).toLowerCase()}`;
case 'completed':
return 'Finished successfully';
case 'failed':
return task.stage !== 'failed'
? `Failed during ${stageLabel(task.stage).toLowerCase()}`
: 'Failed';
}
}
export function buildTaskNotification(task: TaskCore): TaskNotificationView {
const progress = buildProgress(task);
const detailLine = task.stage_detail ?? task.error;
return {
title: taskTypeLabel(task.task_type),
statusLine: buildStatusLine(task, progress),
detailLine,
tone: task.status === 'failed' ? 'error' : task.status === 'completed' ? 'success' : 'info',
progress,
stats: buildStats(task),
actions: buildActions(task)
};
}