239 lines
7.5 KiB
TypeScript
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)
|
|
};
|
|
}
|