"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/*
* Progress Tracker – a checklist with a progress bar, in a single self-contained file.
* The styles are injected below, so no extra files are needed.
*/
export type ProgressStep = {
id: string;
label: string;
done?: boolean;
/** Small icon on the right, e.g. the app the step belongs to */
icon?: ReactNode;
};
export type ProgressTrackerProps = {
title?: string;
steps: ProgressStep[];
/** Ticks the open steps one after another, then starts over – stops as soon as someone interacts */
autoPlay?: boolean;
/** Pause between two automatic steps, in ms */
interval?: number;
onChange?: (steps: ProgressStep[]) => void;
className?: string;
};
export function ProgressTracker({
title = "Getting started",
steps: initial,
autoPlay = false,
interval = 2200,
onChange,
className,
}: ProgressTrackerProps) {
const [done, setDone] = useState(() => new Set(initial.filter((s) => s.done).map((s) => s.id)));
const [playing, setPlaying] = useState(autoPlay);
// Steps added later bring their own done state; steps that go away no longer count
const seen = useRef(new Set(initial.map((s) => s.id)));
const ids = initial.map((s) => s.id).join("|");
useEffect(() => {
const fresh = initial.filter((s) => !seen.current.has(s.id));
fresh.forEach((s) => seen.current.add(s.id));
if (fresh.some((s) => s.done)) setDone((prev) => new Set([...prev, ...fresh.filter((s) => s.done).map((s) => s.id)]));
}, [ids]); // eslint-disable-line react-hooks/exhaustive-deps
const total = initial.length;
const count = initial.filter((s) => done.has(s.id)).length;
const progress = total ? count / total : 0;
const toggle = (id: string) => {
const next = new Set(done);
if (next.has(id)) next.delete(id);
else next.add(id);
setDone(next);
onChange?.(initial.map((s) => ({ ...s, done: next.has(s.id) })));
};
// Auto play: tick the next open step; when all are done, start over
useEffect(() => {
if (!playing) return;
const open = initial.find((s) => !done.has(s.id));
const t = window.setTimeout(
() => {
if (open) toggle(open.id);
else setDone(new Set(initial.filter((s) => s.done).map((s) => s.id)));
},
open ? interval : interval * 2,
);
return () => clearTimeout(t);
}, [playing, done]); // eslint-disable-line react-hooks/exhaustive-deps
return (
setPlaying(false)}
onKeyDown={() => setPlaying(false)}
>
{/* React 19 hoists this into once, no matter how many trackers render */}
{title}
{num.format(count)}
/{num.format(total)}
{/* A new key replays the shine each time progress grows */}