Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 141 additions & 26 deletions components/home-content.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -58,6 +69,49 @@ function StatusBadge({
);
}

function RefreshIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}

function SectionHeader({
label,
count,
isUpdating,
}: {
label: string;
count: number;
isUpdating: boolean;
}) {
return (
<h3 className="flex items-center gap-2 text-sm font-medium text-zinc-600 dark:text-zinc-400">
<span>
{label} ({count})
</span>
{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.
<span
aria-hidden="true"
className="flex items-center gap-1 text-xs font-normal text-zinc-400 dark:text-zinc-500"
>
<RefreshIcon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
updating…
</span>
)}
</h3>
);
}

function ScheduleCard({
schedule,
onDelete,
Expand Down Expand Up @@ -175,13 +229,41 @@ export function HomeContent() {
const [editingSchedule, setEditingSchedule] = useState<ScheduleResponse | null>(null);
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
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<Promise<void> | 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(() => {
Expand All @@ -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();
}
Expand Down Expand Up @@ -266,27 +365,41 @@ export function HomeContent() {
<h2 className="text-lg font-semibold text-zinc-900 dark:text-white">
Scheduled Workflows
</h2>
<button
onClick={handleOpenCreateModal}
className="flex h-9 items-center gap-2 rounded-lg bg-zinc-900 px-3 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
<div className="flex items-center gap-2">
<button
onClick={() => fetchSchedules()}
className="flex h-9 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800"
title="Refresh workflow status"
>
<RefreshIcon
className={`h-4 w-4 ${isFetching ? "animate-spin motion-reduce:animate-none" : ""}`}
/>
</svg>
New Schedule
</button>
Refresh
</button>
<button
onClick={handleOpenCreateModal}
className="flex h-9 items-center gap-2 rounded-lg bg-zinc-900 px-3 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
New Schedule
</button>
</div>
</div>

{pendingSchedules.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
Pending ({pendingSchedules.length})
</h3>
<SectionHeader
label="Pending"
count={pendingSchedules.length}
isUpdating={isFetching}
/>
<div className="flex flex-col gap-2">
{pendingSchedules.map((schedule) => (
<ScheduleCard
Expand All @@ -302,9 +415,11 @@ export function HomeContent() {

{completedSchedules.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
History ({completedSchedules.length})
</h3>
<SectionHeader
label="History"
count={completedSchedules.length}
isUpdating={isFetching}
/>
<div className="flex flex-col gap-2">
{completedSchedules.map((schedule) => (
<ScheduleCard
Expand Down