105 lines
4.6 KiB
TypeScript
105 lines
4.6 KiB
TypeScript
import clsx from "clsx";
|
||
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
|
||
|
||
/*
|
||
* A licence's life as a measured line: issued at the left, expiry at the right,
|
||
* today as a notch, the part you have not got yet hatched.
|
||
*
|
||
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
|
||
* date is still there, but a date alone makes the reader do the arithmetic that
|
||
* is the only question this product is ever asked — when does this stop
|
||
* working. The bar answers it before they read a word.
|
||
*
|
||
* The fill takes the state's colour, so the same vocabulary the pill uses
|
||
* carries through. State is never colour alone here either: the remaining span
|
||
* is hatched rather than tinted, the notch is a hard edge, and the days-left
|
||
* figure is written out.
|
||
*/
|
||
|
||
const TONE: Record<LicenceState, string> = {
|
||
valid: "text-valid",
|
||
warn: "text-warn",
|
||
expired: "text-expired",
|
||
none: "text-accent",
|
||
};
|
||
|
||
function span(issuedAt: string, expiresAt: string) {
|
||
const start = new Date(issuedAt).getTime();
|
||
const end = new Date(expiresAt).getTime();
|
||
const total = end - start;
|
||
// A licence issued and expiring at the same instant is not a real record,
|
||
// but it must not divide by zero on the way to being rendered.
|
||
if (!Number.isFinite(total) || total <= 0) return 100;
|
||
const elapsed = Date.now() - start;
|
||
return Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||
}
|
||
|
||
export function TermBar({
|
||
issuedAt,
|
||
expiresAt,
|
||
state,
|
||
className,
|
||
}: {
|
||
issuedAt: string;
|
||
expiresAt: string;
|
||
state: LicenceState;
|
||
className?: string;
|
||
}) {
|
||
const pct = span(issuedAt, expiresAt);
|
||
const days = daysRemaining(expiresAt);
|
||
const expired = days <= 0;
|
||
|
||
const remaining = expired
|
||
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
|
||
: `${days} ${days === 1 ? "day" : "days"} left`;
|
||
|
||
return (
|
||
<div className={clsx("grid gap-2", TONE[state], className)}>
|
||
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
|
||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
|
||
{/* The span still to come, drawn as absence rather than as a
|
||
second colour: it is the thing being bought. */}
|
||
<span
|
||
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
|
||
style={{ width: `${100 - pct}%` }}
|
||
/>
|
||
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
|
||
</div>
|
||
|
||
{/*
|
||
* On a phone the three ends stack, and the figure someone actually
|
||
* came for goes first — wrapping a justify-between row left "9 days
|
||
* left" marooned between two dates in the middle of the stack.
|
||
*/}
|
||
<div className="grid gap-1 sm:flex sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
|
||
<span className="order-1 font-mono text-[0.74rem] font-bold tabular-nums sm:order-2">{remaining}</span>
|
||
<span className="order-2 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3 sm:order-1">Issued {formatDate(issuedAt)}</span>
|
||
<span className="order-3 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/*
|
||
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
|
||
* the staff expiry queue are all lists of terms, and a list of dates cannot be
|
||
* scanned for "which of these is nearly out" — a list of bars can.
|
||
*
|
||
* It carries a text alternative rather than a title: the row it sits in is
|
||
* being read, not hovered.
|
||
*/
|
||
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
|
||
const pct = span(issuedAt, expiresAt);
|
||
const days = daysRemaining(expiresAt);
|
||
|
||
return (
|
||
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
|
||
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
|
||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
|
||
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
|
||
</span>
|
||
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `−${Math.abs(days)}d` : `${days}d`}</span>
|
||
</span>
|
||
);
|
||
}
|