supermemory/apps/web/components/settings/sync-history-panel.tsx
MaheshtheDev 1706752668 mobile responsiveness pass + connections reauth fix (#959)
Nova mobile pass
- viewport: viewportFit cover for iOS safe-area-inset
- safe-area utilities (pb-safe, pt-safe, bottom-safe-5, scroll-fade-x) in globals.css
- chat FAB pinned above iPhone home indicator; chat sidebar widths responsive across sm/md/lg with min() clamps
- chat input CoT panel max-h capped via min(60dvh, 420px)
- header tab strip swapped from visible scrollbar to scroll-fade-x mask + snap-x
- nova empty state uses svh on mobile, dvh from sm up

Add-memory modal rebuilt for mobile
- mobile shell switched from fullscreen Dialog to vaul Drawer at 85svh with swipe-down dismissal and scaled background
- in-modal header removed; tabs moved to the bottom of the sheet for thumb reach
- four tab compactLabels: Note, Links, Files, Connections
- desktop tabs now render only when !isMobile (no DOM duplication)
- note/link content state lifted to parent so switching tabs preserves typed input
- NoteContent snapshots initialContent via lazy useState so the editor isn't reset on every keystroke
- shared Drawer base uses rounded-t-xl
- removed legacy pt-4 on tab content for mobile

Connections — replace expiresAt with sync-run health
- new useConnectionHealth hook reads the latest sync run and matches auth-failure patterns; backend errorKind field still needed (TODO)
- regex tightened so 401/403 require co-occurring auth/token/grant context; refresh_token requires expired/revoked/invalid/missing qualifier
- badge label changed Disconnected -> Needs reauth
- Reconnect button replaces the sync action when needsReauth, kicks off the same OAuth flow
- per-row reconnect tracking via mutation.variables instead of a single shared id (no race when multiple rows clicked)
- fallback toast when authLink is missing so the spinner can't get stuck
- sync history panel timeline capped at max-h-260 with internal scroll
- useSyncRuns no longer refetches on mount; cache (30s) actually applies, cutting N requests per modal open
2026-05-17 21:49:23 +00:00

269 lines
7.1 KiB
TypeScript

"use client"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { useSyncRuns } from "@/hooks/use-sync-runs"
import type { SyncRun } from "@/hooks/use-sync-runs"
import {
formatRelativeTime,
TRIGGER_TYPE_LABELS,
} from "@/components/settings/sync-utils"
const STATUS_COLORS: Record<string, { dot: string; text: string }> = {
completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" },
failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" },
running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" },
}
function pluralize(count: number, noun: string) {
return `${count} ${noun}${count === 1 ? "" : "s"}`
}
/** Calendar-day bucket label for grouping runs in the timeline. */
function dayLabel(date: string) {
const d = new Date(date)
if (Number.isNaN(d.getTime())) return "Unknown"
const today = new Date()
const startOfDay = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const diffDays = Math.round((startOfDay(today) - startOfDay(d)) / 86_400_000)
if (diffDays <= 0) return "Today"
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays} days ago`
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
function StatTile({ value, label }: { value: string; label: string }) {
return (
<div className="flex-1 bg-[#0D0F14] rounded-[10px] border border-[rgba(82,89,102,0.2)] px-3 py-2 flex flex-col gap-0.5">
<span
className={cn(
dmSans125ClassName(),
"text-[15px] font-medium text-[#FAFAFA] tabular-nums",
)}
>
{value}
</span>
<span className={cn(dmSans125ClassName(), "text-[11px] text-[#737373]")}>
{label}
</span>
</div>
)
}
function SummaryStats({ runs }: { runs: SyncRun[] }) {
const totalItems = runs.reduce((sum, r) => sum + r.itemsProcessed, 0)
const finished = runs.filter((r) => r.status !== "running")
const succeeded = finished.filter((r) => r.status === "completed").length
const successRate =
finished.length > 0 ? Math.round((succeeded / finished.length) * 100) : null
return (
<div className="flex items-stretch gap-2">
<StatTile
value={String(runs.length)}
label={runs.length === 1 ? "sync" : "syncs"}
/>
<StatTile
value={String(totalItems)}
label={totalItems === 1 ? "item" : "items"}
/>
<StatTile
value={successRate === null ? "—" : `${successRate}%`}
label="success"
/>
</div>
)
}
function TimelineRow({ run, isLast }: { run: SyncRun; isLast: boolean }) {
const colors = STATUS_COLORS[run.status] ?? {
dot: "bg-[#4BA0FA] animate-pulse",
text: "text-[#4BA0FA]",
}
const triggerLabel = TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType
const statusLabel = run.status.charAt(0).toUpperCase() + run.status.slice(1)
return (
<div className="flex gap-2.5">
{/* Timeline rail: dot + connecting line */}
<div className="flex flex-col items-center pt-1.5">
<div className={cn("size-[7px] rounded-full shrink-0", colors.dot)} />
{!isLast && <div className="w-px flex-1 bg-[#1A1D24] mt-1" />}
</div>
{/* Content */}
<div className={cn("flex-1 min-w-0", isLast ? "pb-0" : "pb-3")}>
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5 min-w-0">
<span
className={cn(
dmSans125ClassName(),
"text-[13px] font-medium shrink-0",
colors.text,
)}
>
{statusLabel}
</span>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] truncate",
)}
>
· {triggerLabel}
</span>
</div>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] shrink-0",
)}
>
{formatRelativeTime(run.startedAt)}
</span>
</div>
{(run.itemsProcessed > 0 || run.itemsFailed > 0) && (
<div
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] mt-0.5",
)}
>
{pluralize(run.itemsProcessed, "item")} processed
{run.itemsFailed > 0 && (
<span className="text-[#EF4444]">
{" "}
· {run.itemsFailed} failed
</span>
)}
</div>
)}
{run.error && (
<p
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#EF4444]/80 break-words line-clamp-3 mt-0.5",
)}
>
{run.error}
</p>
)}
</div>
</div>
)
}
function Timeline({ runs }: { runs: SyncRun[] }) {
// Group consecutive runs by calendar-day label, preserving server order.
const groups: { label: string; runs: SyncRun[] }[] = []
for (const run of runs) {
const label = dayLabel(run.startedAt)
const last = groups.at(-1)
if (last && last.label === label) {
last.runs.push(run)
} else {
groups.push({ label, runs: [run] })
}
}
return (
<div className="flex flex-col gap-3">
{groups.map((group, gi) => (
<div key={`${group.label}-${gi}`} className="flex flex-col gap-1.5">
<span
className={cn(
dmSans125ClassName(),
"text-[10px] uppercase tracking-wide text-[#525966]",
)}
>
{group.label}
</span>
<div className="flex flex-col">
{group.runs.map((run, i) => (
<TimelineRow
key={run.id}
run={run}
isLast={i === group.runs.length - 1}
/>
))}
</div>
</div>
))}
</div>
)
}
interface SyncHistoryPanelProps {
connectionId: string
/** Only fetch / render when expanded. */
isOpen: boolean
}
/** Inline sync-history view (stats strip + timeline) rendered inside an expanded connection row. */
export function SyncHistoryPanel({
connectionId,
isOpen,
}: SyncHistoryPanelProps) {
const {
data: syncRuns,
isLoading,
error,
refetch,
} = useSyncRuns(isOpen ? connectionId : "")
if (!isOpen) return null
const hasRuns = !isLoading && !error && syncRuns && syncRuns.length > 0
return (
<div className="flex flex-col gap-3">
{isLoading && (
<div className="flex items-center justify-center py-6">
<div className="size-5 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
</div>
)}
{error && !isLoading && (
<div className="flex items-center justify-center gap-2 py-6">
<span
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
>
Failed to load sync history
</span>
<button
type="button"
onClick={() => refetch()}
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#4BA0FA] hover:text-[#4BA0FA]/80 underline cursor-pointer",
)}
>
Try again
</button>
</div>
)}
{!isLoading && !error && syncRuns && syncRuns.length === 0 && (
<div className="flex items-center justify-center py-6">
<span
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
>
No syncs yet runs will appear here.
</span>
</div>
)}
{hasRuns && (
<>
<SummaryStats runs={syncRuns} />
<div className="max-h-[260px] overflow-y-auto scrollbar-thin pr-1 -mr-1">
<Timeline runs={syncRuns} />
</div>
</>
)}
</div>
)
}