From d436aa0d21c0816bf1115abdb6436b4efc7ef040 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Sat, 1 Aug 2026 19:23:19 -0700 Subject: [PATCH] feat: update workflow status in place without a page reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule status only changed on a manual browser refresh, so watching a dispatched run meant reloading the page repeatedly. Poll getSchedules() every 10s while any schedule is non-terminal - pending, processing, or triggered with no conclusion yet. Once everything has settled no timer is started and no requests are issued. Polling faster would not help: the cron tick that writes these rows runs once a minute, so the badge is at most ~70s behind the GitHub run either way. Also adds a Refresh button and an "updating…" indicator beside the Pending and History headers, shown for the background poll as well as the manual click. Two details worth noting: - fetchSchedules holds the in-flight promise rather than a boolean, so overlapping calls collapse onto one request. A slow response can therefore never land after a newer one and repaint "running…" over an already-resolved "succeeded", and a click racing the poll timer still awaits a real result. - The indicator is held for a 400ms floor. A schedule read finishes well under 100ms, so without it the indicator would flash for a single frame every poll. Data still renders as soon as it arrives. The indicator is aria-hidden: it repeats every 10s, and a live region announcing it that often would bury the badge change that actually matters. Co-Authored-By: Claude Opus 5 (1M context) --- components/home-content.tsx | 167 ++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 26 deletions(-) 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) => (