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.
+
+
+ updating…
+
+ )}
+
+ );
+}
+
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() {