import type { HTMLAttributes, ReactNode } from "react";
import { cx } from "../../utils/cx.utils";
import { PatternOverlay } from "../pattern-overlay/pattern-overlay.component";
import "./last-updated.component.css";
/** Freshness levels computed by {@link LastUpdated}. */
export type LastUpdatedStatus = "fresh" | "stale" | "expired";
/** Overridable labels shown for each {@link LastUpdatedStatus}. */
export type LastUpdatedStatusLabels = Record;
/** Default labels used when `statusLabels` is not provided. */
const DEFAULT_STATUS_LABELS: LastUpdatedStatusLabels = {
fresh: "fresh",
stale: "stale",
expired: "expired",
};
/** Seconds after which a `date` is considered `"stale"` if `stale` is not provided. */
const DEFAULT_STALE_SECONDS = 60;
/** Seconds after which a `date` is considered `"expired"` if `expired` is not provided. */
const DEFAULT_EXPIRED_SECONDS = 300;
/** Props accepted by {@link LastUpdated}. */
export interface LastUpdatedProps extends Omit, "children"> {
/** Point in time the wrapped value was last updated. */
date: Date;
/** Value whose freshness is being described, shown next to the status badge. */
children: ReactNode;
/** Seconds since `date` after which the status becomes `"stale"`. Defaults to `60`. */
stale?: number;
/** Seconds since `date` after which the status becomes `"expired"`. Defaults to `300`. */
expired?: number;
/** Overrides for the `"fresh"`/`"stale"`/`"expired"` badge labels. */
statusLabels?: Partial;
/** Renders a border around the component. Defaults to `true`. */
withBorder?: boolean;
/**
* Overrides the elapsed-minutes text shown below the value, given the
* number of whole minutes since `date`. Defaults to `"1 minute ago"` /
* `"N minutes ago"`.
*/
formatMinutesAgo?: (minutes: number) => string;
}
function defaultFormatMinutesAgo(minutes: number): string {
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
}
function getMinutesAgo(date: Date): number {
return Math.max(0, Math.floor((Date.now() - date.getTime()) / 60_000));
}
function getStatus(date: Date, staleSeconds: number, expiredSeconds: number): LastUpdatedStatus {
const elapsedSeconds = (Date.now() - date.getTime()) / 1000;
if (elapsedSeconds >= expiredSeconds) {
return "expired";
}
if (elapsedSeconds >= staleSeconds) {
return "stale";
}
return "fresh";
}
/**
* Wraps a value with a status indicator (`"fresh"` | `"stale"` |
* `"expired"`) computed from how long ago `date` occurred, compared against
* `stale` and `expired` thresholds in seconds (defaulting to `60` and
* `300`). The indicator is shown after the value as a thin-bordered swatch
* — unfilled for `"fresh"`, diagonal stripes for `"stale"`, filled black
* for `"expired"` — connected to its text label. Status labels can be
* overridden via `statusLabels`. Below the value, shows the elapsed time
* since `date` (e.g. `"5 minutes ago"`), auto-pluralized and overridable
* via `formatMinutesAgo`. Has a solid `2px` black border around the whole
* component by default, disable via `withBorder={false}`.
*
* @example
* ```tsx
* $42.00
*
* $42.00
*
* ```
*/
export function LastUpdated({
className,
date,
children,
stale = DEFAULT_STALE_SECONDS,
expired = DEFAULT_EXPIRED_SECONDS,
statusLabels,
withBorder = true,
formatMinutesAgo = defaultFormatMinutesAgo,
...rest
}: LastUpdatedProps) {
const status = getStatus(date, stale, expired);
const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };
return (
{children}
{status === "stale" && (
)}
{labels[status]}
{formatMinutesAgo(getMinutesAgo(date))}
);
}