feat: migrate task jobs to workflow notifications + timeline

This commit is contained in:
2026-03-02 14:29:31 -05:00
parent 36c4ed2ee2
commit d81a681905
33 changed files with 2437 additions and 292 deletions

View File

@@ -8,23 +8,20 @@ import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
deleteHolding,
queuePortfolioInsights,
queuePriceRefresh,
upsertHolding
} from '@/lib/api';
import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import {
holdingsQueryOptions,
latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions,
taskQueryOptions
portfolioSummaryQueryOptions
} from '@/lib/query/options';
type FormState = {
@@ -58,7 +55,6 @@ export default function PortfolioPage() {
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
const loadPortfolio = useCallback(async () => {
@@ -95,20 +91,6 @@ export default function PortfolioPage() {
}
}, [isPending, isAuthenticated, loadPortfolio]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
await loadPortfolio();
}
});
const liveTask = polledTask ?? activeTask;
const allocationData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
@@ -147,11 +129,10 @@ export default function PortfolioPage() {
const queueRefresh = async () => {
try {
const { task } = await queuePriceRefresh();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queuePriceRefresh();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
}
@@ -159,11 +140,10 @@ export default function PortfolioPage() {
const queueInsights = async () => {
try {
const { task } = await queuePortfolioInsights();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queuePortfolioInsights();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
}
@@ -190,16 +170,6 @@ export default function PortfolioPage() {
</>
)}
>
{liveTask ? (
<Panel title="Task Runner" subtitle={liveTask.id}>
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
<StatusPill status={liveTask.status} />
</div>
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9f9f]">{liveTask.error}</p> : null}
</Panel>
) : null}
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>