Countdown Timer

Countdown and SLA timer for deadlines, escalations, and response windows.

Signaler un bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/countdown-timer.json
bash

Storybook

Explorez les variantes, controles et verifications d'accessibilite dans le playground Storybook interactif.

Voir dans Storybook

2 stories disponibles :

Code

"use client";

import * as React from "react";

import { cn } from "../../lib/utils";
import { Badge } from "../badge";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../card";

export type CountdownTimerProps = React.ComponentPropsWithoutRef<"div"> & {
  deadline: Date | number | string;
  description?: string;
  now?: Date | number | string;
  startedAt?: Date | number | string;
  tickMs?: number;
  title?: string;
  warningThresholdMs?: number;
};

type TimerSegment = {
  label: string;
  value: string;
};

function normalizeDate(input: Date | number | string): Date {
  if (input instanceof Date) {
    return new Date(input.getTime());
  }

  return new Date(input);
}

function useLiveDate(now: CountdownTimerProps["now"], tickMs: number) {
  const fixedNow = React.useMemo(
    () => (now ? normalizeDate(now) : undefined),
    [now],
  );
  const [liveNow, setLiveNow] = React.useState<Date>(fixedNow ?? new Date());

  React.useEffect(() => {
    if (fixedNow) {
      setLiveNow(fixedNow);
      return;
    }

    const interval = window.setInterval(() => {
      setLiveNow(new Date());
    }, tickMs);

    return () => {
      window.clearInterval(interval);
    };
  }, [fixedNow, tickMs]);

  return liveNow;
}

function getRemainingMs(deadline: Date, now: Date): number {
  return deadline.getTime() - now.getTime();
}

function getStatus(
  remainingMs: number,
  warningThresholdMs: number,
): {
  badgeVariant: "default" | "destructive" | "secondary";
  label: string;
  toneClassName: string;
} {
  if (remainingMs <= 0) {
    return {
      badgeVariant: "destructive",
      label: "Breached",
      toneClassName: "bg-destructive",
    };
  }

  if (remainingMs <= warningThresholdMs) {
    return {
      badgeVariant: "secondary",
      label: "At risk",
      toneClassName: "bg-amber-500",
    };
  }

  return {
    badgeVariant: "default",
    label: "On track",
    toneClassName: "bg-emerald-500",
  };
}

function formatSegments(milliseconds: number): TimerSegment[] {
  const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
  const days = Math.floor(totalSeconds / 86_400);
  const hours = Math.floor((totalSeconds % 86_400) / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  return [
    { label: "Days", value: String(days).padStart(2, "0") },
    { label: "Hours", value: String(hours).padStart(2, "0") },
    { label: "Minutes", value: String(minutes).padStart(2, "0") },
    { label: "Seconds", value: String(seconds).padStart(2, "0") },
  ];
}

const DEADLINE_FORMATTER = new Intl.DateTimeFormat("en-US", {
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  month: "short",
  timeZoneName: "short",
});

function formatDeadline(date: Date): string {
  return DEADLINE_FORMATTER.format(date);
}

function getProgress(deadline: Date, now: Date, startedAt?: Date): number {
  if (!startedAt) {
    return 0;
  }

  const total = deadline.getTime() - startedAt.getTime();

  if (total <= 0) {
    return 100;
  }

  const elapsed = now.getTime() - startedAt.getTime();

  return Math.min(100, Math.max(0, (elapsed / total) * 100));
}

function TimerSegments({ segments }: { segments: TimerSegment[] }) {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
      {segments.map((segment) => (
        <div
          className="rounded-lg border bg-background/80 px-3 py-4 text-center"
          key={segment.label}
        >
          <div className="text-2xl font-semibold tracking-tight">
            {segment.value}
          </div>
          <div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
            {segment.label}
          </div>
        </div>
      ))}
    </div>
  );
}

function TimerProgress({
  progress,
  remainingMs,
  segments,
  startedAt,
  toneClassName,
}: {
  progress: number;
  remainingMs: number;
  segments: TimerSegment[];
  startedAt?: Date;
  toneClassName: string;
}) {
  const progressWidth = startedAt ? progress : remainingMs <= 0 ? 100 : 0;
  const statusText = startedAt
    ? `${Math.round(progress)}% used`
    : segments.map((segment) => segment.value).join(":");

  return (
    <div className="space-y-2">
      <div className="flex items-center justify-between text-xs text-muted-foreground">
        <span>{startedAt ? "SLA elapsed" : "Time remaining"}</span>
        <span>{statusText}</span>
      </div>
      <div className="h-2 overflow-hidden rounded-full bg-muted">
        <div
          className={cn("h-full transition-all", toneClassName)}
          style={{ width: `${progressWidth}%` }}
        />
      </div>
    </div>
  );
}

export const CountdownTimer = React.forwardRef<
  HTMLDivElement,
  CountdownTimerProps
>(
  (
    {
      className,
      deadline,
      description,
      now,
      startedAt,
      tickMs = 1000,
      title = "Countdown timer",
      warningThresholdMs = 15 * 60 * 1000,
      ...props
    },
    ref,
  ) => {
    const deadlineDate = React.useMemo(
      () => normalizeDate(deadline),
      [deadline],
    );
    const startedAtDate = React.useMemo(
      () => (startedAt ? normalizeDate(startedAt) : undefined),
      [startedAt],
    );
    const liveNow = useLiveDate(now, tickMs);
    const remainingMs = getRemainingMs(deadlineDate, liveNow);
    const status = getStatus(remainingMs, warningThresholdMs);
    const segments = formatSegments(Math.abs(remainingMs));
    const progress = getProgress(deadlineDate, liveNow, startedAtDate);

    return (
      <Card className={cn("shadow-sm", className)} ref={ref} {...props}>
        <CardHeader className="space-y-2 pb-3">
          <div className="flex items-start justify-between gap-3">
            <div>
              <CardTitle className="text-base">{title}</CardTitle>
              <CardDescription>
                {description ?? `Deadline ${formatDeadline(deadlineDate)}`}
              </CardDescription>
            </div>
            <Badge variant={status.badgeVariant}>{status.label}</Badge>
          </div>
        </CardHeader>
        <CardContent className="space-y-4">
          <TimerSegments segments={segments} />
          <TimerProgress
            progress={progress}
            remainingMs={remainingMs}
            segments={segments}
            startedAt={startedAtDate}
            toneClassName={status.toneClassName}
          />
        </CardContent>
      </Card>
    );
  },
);

CountdownTimer.displayName = "CountdownTimer";
typescript

Dependances

  • @vllnt/ui@^0.2.1