Skip to content

Commit 736efb8

Browse files
author
marcus
committed
feat(sync): add live chunk progress bar and grouped backfill UX for supabase queue
1 parent 2740efb commit 736efb8

2 files changed

Lines changed: 73 additions & 11 deletions

File tree

src/app/api/sync/status/route.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@ import { requireAppUser } from "@/server/auth/user";
33
import { prisma } from "@/server/db/prisma";
44
import { getQueueBackend } from "@/server/queue/queue";
55

6-
export async function GET() {
6+
function parseSinceParam(request: Request): Date | null {
7+
const url = new URL(request.url);
8+
const since = url.searchParams.get("since");
9+
if (!since) return null;
10+
const parsed = new Date(since);
11+
return Number.isNaN(parsed.getTime()) ? null : parsed;
12+
}
13+
14+
export async function GET(request: Request) {
715
const { appUser } = await requireAppUser();
16+
const since = parseSinceParam(request);
817
const integrations = await prisma.integration.findMany({
918
where: { userId: appUser.id },
1019
select: { syncState: true, lastSyncedAt: true },
@@ -19,26 +28,36 @@ export async function GET() {
1928
.at(-1) ?? null;
2029

2130
if (getQueueBackend() === "supabase") {
22-
const [queuedJobCount, runningJobCount, failedJobCount, latestTerminal] = await Promise.all([
31+
const whereBase: any = { userId: appUser.id };
32+
if (since) whereBase.createdAt = { gte: since };
33+
34+
const [queuedJobCount, runningJobCount, failedJobCount, completedJobCount, latestTerminal] = await Promise.all([
35+
(prisma as any).syncJob.count({
36+
where: { ...whereBase, status: "QUEUED" },
37+
}),
2338
(prisma as any).syncJob.count({
24-
where: { userId: appUser.id, status: "QUEUED" },
39+
where: { ...whereBase, status: "RUNNING" },
2540
}),
2641
(prisma as any).syncJob.count({
27-
where: { userId: appUser.id, status: "RUNNING" },
42+
where: { ...whereBase, status: "FAILED" },
2843
}),
2944
(prisma as any).syncJob.count({
30-
where: { userId: appUser.id, status: "FAILED" },
45+
where: { ...whereBase, status: "COMPLETED" },
3146
}),
3247
(prisma as any).syncJob.findFirst({
3348
where: {
34-
userId: appUser.id,
49+
...whereBase,
3550
status: { in: ["COMPLETED", "FAILED"] },
3651
finishedAt: { not: null },
3752
},
3853
orderBy: { finishedAt: "desc" },
3954
select: { status: true, finishedAt: true },
4055
}),
4156
]);
57+
const activeJobCount = queuedJobCount + runningJobCount;
58+
const totalJobCount = queuedJobCount + runningJobCount + failedJobCount + completedJobCount;
59+
const finishedJobCount = failedJobCount + completedJobCount;
60+
const progressPercent = totalJobCount > 0 ? Math.round((finishedJobCount / totalJobCount) * 100) : null;
4261

4362
return NextResponse.json({
4463
runningCount,
@@ -47,7 +66,11 @@ export async function GET() {
4766
queuedJobCount,
4867
runningJobCount,
4968
failedJobCount,
50-
activeJobCount: queuedJobCount + runningJobCount,
69+
completedJobCount,
70+
activeJobCount,
71+
totalJobCount,
72+
finishedJobCount,
73+
progressPercent,
5174
latestJobStatus: latestTerminal?.status ?? null,
5275
latestJobFinishedAt: latestTerminal?.finishedAt?.toISOString() ?? null,
5376
});
@@ -60,7 +83,11 @@ export async function GET() {
6083
queuedJobCount: 0,
6184
runningJobCount: 0,
6285
failedJobCount: 0,
86+
completedJobCount: 0,
6387
activeJobCount: runningCount,
88+
totalJobCount: 0,
89+
finishedJobCount: 0,
90+
progressPercent: null,
6491
latestJobStatus: null,
6592
latestJobFinishedAt: null,
6693
});

src/components/dashboard-live-updater.tsx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useRouter } from "next/navigation";
4-
import { useEffect, useRef } from "react";
4+
import { useEffect, useRef, useState } from "react";
55
import { useAppToast } from "@/components/providers";
66
import { clearSyncWatch, getSyncWatchStartedAt, isSyncWatchActive } from "@/lib/sync-watch";
77

@@ -15,6 +15,9 @@ type SyncEventPayload = {
1515

1616
type SyncStatusPayload = {
1717
activeJobCount?: number;
18+
totalJobCount?: number;
19+
finishedJobCount?: number;
20+
progressPercent?: number | null;
1821
latestJobStatus?: "COMPLETED" | "FAILED" | null;
1922
latestJobFinishedAt?: string | null;
2023
};
@@ -27,6 +30,7 @@ export function DashboardLiveUpdater() {
2730
const latestTerminalRef = useRef<string | null>(null);
2831
const latestRefreshedTerminalRef = useRef<string | null>(null);
2932
const NO_ACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
33+
const [progress, setProgress] = useState<{ percent: number; finished: number; total: number } | null>(null);
3034

3135
useEffect(() => {
3236
const source = new EventSource("/api/sync/events");
@@ -79,11 +83,26 @@ export function DashboardLiveUpdater() {
7983
const pollId = setInterval(async () => {
8084
if (!isSyncWatchActive()) return;
8185
try {
82-
const response = await fetch("/api/sync/status", { cache: "no-store" });
86+
const watchStartedAt = getSyncWatchStartedAt();
87+
const query = watchStartedAt ? `?since=${encodeURIComponent(new Date(watchStartedAt).toISOString())}` : "";
88+
const response = await fetch(`/api/sync/status${query}`, { cache: "no-store" });
8389
if (!response.ok) return;
8490
const status = (await response.json()) as SyncStatusPayload;
85-
const watchStartedAt = getSyncWatchStartedAt();
8691
const activeJobCount = Number(status.activeJobCount ?? 0);
92+
const total = Number(status.totalJobCount ?? 0);
93+
const finished = Number(status.finishedJobCount ?? 0);
94+
const percent = Number.isFinite(Number(status.progressPercent))
95+
? Number(status.progressPercent ?? 0)
96+
: total > 0
97+
? Math.round((finished / total) * 100)
98+
: 0;
99+
if (isSyncWatchActive()) {
100+
setProgress({
101+
percent: Math.max(0, Math.min(100, percent)),
102+
finished: Math.max(0, finished),
103+
total: Math.max(0, total),
104+
});
105+
}
87106
const terminalTime = status.latestJobFinishedAt ? Date.parse(status.latestJobFinishedAt) : NaN;
88107
const terminalKey = status.latestJobFinishedAt ? `${status.latestJobStatus ?? "UNKNOWN"}:${status.latestJobFinishedAt}` : null;
89108
const isFromCurrentWatch =
@@ -134,6 +153,7 @@ export function DashboardLiveUpdater() {
134153
clearSyncWatch();
135154
hasAnnouncedStart.current = false;
136155
latestRefreshedTerminalRef.current = null;
156+
setProgress(null);
137157
router.refresh();
138158
return;
139159
}
@@ -144,6 +164,7 @@ export function DashboardLiveUpdater() {
144164
clearSyncWatch();
145165
hasAnnouncedStart.current = false;
146166
latestRefreshedTerminalRef.current = null;
167+
setProgress(null);
147168
}
148169
} catch {
149170
// ignore polling errors
@@ -156,5 +177,19 @@ export function DashboardLiveUpdater() {
156177
};
157178
}, [pushToast, router]);
158179

159-
return null;
180+
if (!progress || !isSyncWatchActive()) return null;
181+
182+
return (
183+
<div className="animate-in fade-in slide-in-from-top-1 rounded-lg border border-border/60 bg-card/70 p-3">
184+
<div className="mb-2 flex items-center justify-between gap-2 text-sm">
185+
<p className="font-medium">Sync progress</p>
186+
<p className="text-muted-foreground">
187+
{progress.total > 0 ? `${progress.finished}/${progress.total}` : "Preparing..."}{progress.percent}%
188+
</p>
189+
</div>
190+
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
191+
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${progress.percent}%` }} />
192+
</div>
193+
</div>
194+
);
160195
}

0 commit comments

Comments
 (0)