diff --git a/components/home-content.tsx b/components/home-content.tsx index 931f6cb..df50ec3 100644 --- a/components/home-content.tsx +++ b/components/home-content.tsx @@ -1,11 +1,22 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { format } from "date-fns"; import { toZonedTime } from "date-fns-tz"; import { ScheduleForm } from "@/components/schedule/schedule-form"; import { getSchedules, deleteSchedule, type ScheduleResponse } from "@/lib/actions/schedules"; +// The database only changes as fast as the cron tick that writes it (every +// 1 minute), so polling faster than this buys nothing. Background tabs are +// throttled by the browser for free. +const POLL_INTERVAL_MS = 10_000; + +// A schedule read finishes in well under 100ms, so an indicator tied directly +// to the request would flash for a single frame every poll - jitter rather +// than feedback. Holding it this long makes each poll read as one deliberate +// pulse. Only affects the indicator; fetched data still renders immediately. +const MIN_INDICATOR_MS = 400; + function StatusBadge({ status, runConclusion, @@ -58,6 +69,49 @@ function StatusBadge({ ); } +function RefreshIcon({ className }: { className: string }) { + return ( + + + + ); +} + +function SectionHeader({ + label, + count, + isUpdating, +}: { + label: string; + count: number; + isUpdating: boolean; +}) { + return ( +

+ + {label} ({count}) + + {isUpdating && ( + // aria-hidden deliberately: this repeats on every poll, and a live + // region announcing "updating" every 10 seconds would bury the thing + // that actually changed. The status badges carry that information. + + )} +

+ ); +} + function ScheduleCard({ schedule, onDelete, @@ -175,13 +229,41 @@ export function HomeContent() { const [editingSchedule, setEditingSchedule] = useState(null); const [schedules, setSchedules] = useState([]); const [isLoading, setIsLoading] = useState(true); + // True for every read - the manual click and the background poll alike - so + // the header indicator reflects any refresh, not just an explicit one. + const [isFetching, setIsFetching] = useState(false); + // Collapses overlapping fetches onto one request, so a slow response can + // never land after a newer one and repaint "running…" over an + // already-resolved "succeeded". Holds the promise rather than a boolean so + // a caller that arrives mid-flight waits for the real result - that's what + // keeps the Refresh spinner honest when a click races the poll timer. + const inFlight = useRef | null>(null); - const fetchSchedules = useCallback(async () => { - const result = await getSchedules(); - if (result.success) { - setSchedules(result.schedules); - } - setIsLoading(false); + const fetchSchedules = useCallback(() => { + if (inFlight.current) return inFlight.current; + + setIsFetching(true); + const startedAt = Date.now(); + + const request = (async () => { + try { + const result = await getSchedules(); + if (result.success) { + setSchedules(result.schedules); + } + } finally { + inFlight.current = null; + setIsLoading(false); + + setTimeout( + () => setIsFetching(false), + Math.max(0, MIN_INDICATOR_MS - (Date.now() - startedAt)), + ); + } + })(); + + inFlight.current = request; + return request; }, []); useEffect(() => { @@ -190,6 +272,23 @@ export function HomeContent() { })(); }, [fetchSchedules]); + // Anything not in a terminal state can still change on its own, so keep + // polling. A dashboard where every schedule has settled starts no timer and + // issues no requests. + const isActive = schedules.some( + (schedule) => + schedule.status === "pending" || + schedule.status === "processing" || + (schedule.status === "triggered" && schedule.runConclusion === null), + ); + + useEffect(() => { + if (!isActive) return; + + const timer = setInterval(fetchSchedules, POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [isActive, fetchSchedules]); + function handleScheduleCreated() { fetchSchedules(); } @@ -266,27 +365,41 @@ export function HomeContent() {

Scheduled Workflows

- + Refresh + + + {pendingSchedules.length > 0 && (
-

- Pending ({pendingSchedules.length}) -

+
{pendingSchedules.map((schedule) => ( 0 && (
-

- History ({completedSchedules.length}) -

+
{completedSchedules.map((schedule) => (