diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx index 33b92a0..70c3cee 100644 --- a/adminsite/app/(customer)/billing/page.tsx +++ b/adminsite/app/(customer)/billing/page.tsx @@ -6,7 +6,8 @@ import { NotConnectedPanel } from "@/components/NotConnected"; import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; import { PageHeader } from "@/components/PageHeader"; import { ManageBillingButton } from "@/components/ManageBillingButton"; -import { formatDate } from "@/lib/format"; +import { TermSpark } from "@/components/TermBar"; +import { formatDate, licenceState } from "@/lib/format"; export default function BillingPage() { const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions }); @@ -22,6 +23,21 @@ export default function BillingPage() { // difference between "professional · annual" and knowing which install that is. const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name; + /* + * A subscription reports when the period ends but not when it began, so the + * start is derived from the term. Only the two terms we actually sell are + * handled — anything else returns null and the row falls back to the date + * alone, because a bar drawn from a guessed span is worse than no bar. + */ + const periodStart = (end: string, term: string): string | null => { + const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0; + if (!months) return null; + const d = new Date(end); + if (Number.isNaN(d.getTime())) return null; + d.setMonth(d.getMonth() - months); + return d.toISOString(); + }; + return (
- {rows.map((s) => ( - - {nameFor(s.instance_id) ?? Not linked yet} - {s.tier.replace("_", " ")} - {s.term} - {s.status} - {formatDate(s.current_period_end)} - - ))} + {rows.map((s) => { + const start = periodStart(s.current_period_end, s.term); + return ( + + {nameFor(s.instance_id) ?? Not linked yet} + {s.tier.replace("_", " ")} + {s.term} + {s.status} + +
+ {start && } + {formatDate(s.current_period_end)} +
+ + + ); + })}
diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index 7230201..4f5e7ee 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -9,6 +9,7 @@ import { LicenceDelivery } from "@/components/LicenceDelivery"; import { MembersPanel } from "@/components/MembersPanel"; import { RelinkPanel } from "@/components/RelinkPanel"; import { StatePill } from "@/components/StatePill"; +import { TermBar } from "@/components/TermBar"; import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; import { PageHeader } from "@/components/PageHeader"; import { formatDate, licenceState, limitLabel } from "@/lib/format"; @@ -136,6 +137,35 @@ export default function InstancePage() { } > + {/* + * The term leads. This screen is about one licence, and the rail + * already carried its issue and expiry dates as two lines of + * text — which is the arithmetic this bar does for the reader. + */} + {lic && ( +
+
+

Licence

+ + {lic.tier.replace("_", " ")} · {cloud ? "Cloud" : "Self-hosted"} + +
+ + + + {state === "warn" && ( +

+ Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early. +

+ )} + {state === "expired" && ( +

+ Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed. +

+ )} +
+ )} + {cloud ? ( ) : ( diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index 311e36b..73727a5 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -34,21 +34,55 @@ export default function OverviewPage() { const live = data.instances.filter((i) => i.status !== "deleted"); - // Work the customer has to do, gathered across every instance. This is the - // only account-level view of it each record only knows about itself. + /* + * Work the customer has to do, gathered across every instance. This is the + * only account-level view of it — each record only knows about itself. + * + * Each item carries the way out of it. It used to be a list of sentences in + * the rail, which told someone their licence was expiring and then made + * them go and find the instance that owned it; the fix for every one of + * these is one click, so the click belongs on the row. + */ const attention = live.flatMap((i) => { const lic = byInstance.get(i.instance_id); const state = licenceState(lic?.expires_at, Boolean(lic)); - if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }]; - if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }]; - if (state === "warn") + const name = i.name || "An instance"; + + if (state === "none") return [ { id: i.instance_id, - text: `${i.name} expires`, - note: `${daysRemaining(lic!.expires_at)}d`, + text: `${name} is waiting for an install ID`, + note: "You have paid for this. Paste the UUID from the install to get your licence.", + href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase", + action: i.status === "awaiting_link" ? "Link install" : "Get a licence", + tag: "", }, ]; + if (state === "expired") + return [ + { + id: i.instance_id, + text: `${name} has expired`, + note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.", + href: `/instances/${i.instance_id}`, + action: "Renew", + tag: "now", + }, + ]; + if (state === "warn") { + const d = daysRemaining(lic!.expires_at); + return [ + { + id: i.instance_id, + text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`, + note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.", + href: `/instances/${i.instance_id}`, + action: "Renew", + tag: `${d}d`, + }, + ]; + } return []; }); @@ -71,32 +105,43 @@ export default function OverviewPage() { /> {live.length === 0 ? ( -
-

No instances yet

-

- Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page. -

-
- Buy a plan + /* + * An empty screen is an invitation to act, and the two ways in + * are genuinely different products — we host it, or you do. One + * button and a paragraph explaining the other option made the + * self-hosted path read as an afterthought, which it is not. + */ +
+
+

No instances yet

+

An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.

+ +
+
+

Cloud

+

We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.

+
+ Create a cloud instance +
+
+
+

Self-hosted

+

You host it. Get the licence here, then paste your install’s ID to bind it.

+
+ + License my own install + +
+
+
+ +

The Free tier covers 5 servers and needs no card.

) : ( - {attention.length > 0 && ( - -
    - {attention.map((a) => ( -
  • - {a.text} - {a.note && {a.note}} -
  • - ))} -
-
- )} -
    {(people.data ?? []).slice(0, 5).map((p) => ( @@ -147,6 +192,38 @@ export default function OverviewPage() { } > + {/* + * First in the main column, not in the rail. This is the + * reason the page is open; the rail is for things that are + * merely true. It disappears entirely when there is nothing + * in it rather than saying "all clear", which is a line + * nobody needs to read twice a week. + */} + {attention.length > 0 && ( +
    +
    +

    Needs you

    + + {attention.length} {attention.length === 1 ? "item" : "items"} + +
    +
      + {attention.map((a) => ( +
    • +
      + + {a.text} + {a.tag && {a.tag}} + + {a.note} +
      + {a.action} +
    • + ))} +
    +
    + )} + {live.map((i, n) => { const lic = byInstance.get(i.instance_id); const state = licenceState(lic?.expires_at, Boolean(lic)); diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx index e946ec1..8d3c087 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx @@ -8,6 +8,8 @@ import clsx from "clsx"; import { api, type Deployment, type InjectionState } from "@/lib/api"; import { Ledger } from "@/components/Ledger"; import { PageHeader } from "@/components/PageHeader"; +import { TermBar } from "@/components/TermBar"; +import { licenceState } from "@/lib/format"; import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator"; import { IssuePanel } from "./IssuePanel"; @@ -32,6 +34,7 @@ export default function StaffInstancePage() { if (isLoading || !data) return

    Loading…

    ; const inj = data.injection.state ? INJECTION[data.injection.state] : undefined; + const current = data.licenses.find((l) => !l.superseded_by); return (
    @@ -59,6 +62,21 @@ export default function StaffInstancePage() { {data.injection.applicable && inj &&

    {inj.label}

    }
    + {/* + * The live licence is the one nothing has superseded, which is the + * record's own statement of the fact — not its position in the + * array, which is the server's ordering and not a guarantee. + */} + {current && ( +
    +
    +

    Current licence

    + {current.license_id} +
    + +
    + )} +

    Licence history

    diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx index 63c3058..4c7fd3c 100644 --- a/adminsite/app/(staff)/staff/licenses/page.tsx +++ b/adminsite/app/(staff)/staff/licenses/page.tsx @@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; import { api, type Tier } from "@/lib/api"; -import { formatDate } from "@/lib/format"; +import { formatDate, licenceState } from "@/lib/format"; import { PageHeader } from "@/components/PageHeader"; +import { TermSpark } from "@/components/TermBar"; export default function LicensesPage() { const [tier, setTier] = useState<"" | Tier>(""); @@ -60,6 +61,7 @@ export default function LicensesPage() { Instance Tier Reason + Term Expires State @@ -83,6 +85,17 @@ export default function LicensesPage() { {l.tier.replace("_", " ")} {l.reason.replace("_", " ")} + {/* A superseded row's term is not a countdown to + anything — it ended when its successor was + issued, so drawing a bar for it would invite + a comparison that means nothing. */} + + {l.superseded_by ? ( + superseded + ) : ( + + )} + {formatDate(l.expires_at)} diff --git a/adminsite/app/accept-invite/page.tsx b/adminsite/app/accept-invite/page.tsx index c5e138b..be2d7e4 100644 --- a/adminsite/app/accept-invite/page.tsx +++ b/adminsite/app/accept-invite/page.tsx @@ -1,12 +1,12 @@ "use client"; import { useMutation } from "@tanstack/react-query"; -import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useState } from "react"; import { ApiError, api } from "@/lib/api"; import { Button } from "@/components/Button"; import { Field } from "@/components/Field"; +import { AuthMessage, AuthShell } from "@/components/AuthShell"; function AcceptForm() { const token = useSearchParams().get("token") ?? ""; @@ -17,60 +17,65 @@ function AcceptForm() { const accept = useMutation({ mutationFn: () => api.acceptInvite(token, password), onSuccess: () => setDone(true), - onError: (e) => - setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."), + onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."), }); - if (!token) return

    That link is missing its token.

    ; + if (!token) + return ( + + ); + if (done) return ( -
    -

    You're in

    -

    Sign in with your email address and new password.

    - - Sign in - -
    + ); return ( -
    { - e.preventDefault(); - setError(null); - accept.mutate(); - }} + -

    Choose a password

    -

    - This password signs you into Vantage HQ and into every instance you are given - access to. Nobody who invited you can see it. -

    - setPassword(e.target.value)} - required - minLength={12} - hint="At least 12 characters." - error={error ?? undefined} - /> - - +
    { + e.preventDefault(); + setError(null); + accept.mutate(); + }} + > +

    This password signs you into Vantage HQ and into every instance you are given access to.

    + setPassword(e.target.value)} + required + minLength={12} + hint="At least 12 characters." + error={error ?? undefined} + /> + + +
    ); } export default function AcceptInvitePage() { return ( -
    - Loading…

    }> - -
    -
    + }> + + ); } diff --git a/adminsite/app/login/page.tsx b/adminsite/app/login/page.tsx index 2ed74aa..403f1d4 100644 --- a/adminsite/app/login/page.tsx +++ b/adminsite/app/login/page.tsx @@ -6,6 +6,7 @@ import { API_BASE, ApiError, NotConnected, api } from "@/lib/api"; import { NotConnectedPanel } from "@/components/NotConnected"; import { Button } from "@/components/Button"; import { Field } from "@/components/Field"; +import { AuthShell } from "@/components/AuthShell"; const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, ""); @@ -38,82 +39,56 @@ export default function LoginPage() { if (offline) return ( -
    + -
    + ); return ( -
    - {/* The masthead's lockup, unlinked: there is nowhere to go yet. */} -
    - - Vantage - - HQ - - -

    Sign in

    -

    - Licences · instances · billing -

    -
    + +
    + setEmail(e.target.value)} /> + setPassword(e.target.value)} + error={error ?? undefined} + /> + + + -
    -
    - setEmail(e.target.value)} - /> - setPassword(e.target.value)} - error={error ?? undefined} - /> - - - + {SITE_URL && ( + <> +
    - {SITE_URL && ( - <> -
    - - {/* Signup lives on the marketing site's /start, not here. */} -

    - No account?{" "} - - Create one - -

    - - )} -
    -
    - ); -} - -function Main({ children }: { children: React.ReactNode }) { - return ( -
    - {children} -
    + {/* Signup lives on the marketing site's /start, not here. */} +

    + No account?{" "} + + Create one + +

    + + )} + ); } diff --git a/adminsite/app/verify/page.tsx b/adminsite/app/verify/page.tsx index 56d9dd2..dabdc4f 100644 --- a/adminsite/app/verify/page.tsx +++ b/adminsite/app/verify/page.tsx @@ -2,9 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; -import Link from "next/link"; import { Suspense, useEffect } from "react"; import { api } from "@/lib/api"; +import { AuthMessage, AuthShell } from "@/components/AuthShell"; + +const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, ""); function Verify() { const router = useRouter(); @@ -25,50 +27,59 @@ function Verify() { router.replace(`/accept-invite?token=${encodeURIComponent(token)}`); } }, [needsPassword, token, router]); - if (needsPassword) return ; + if (needsPassword) return ; if (!token) return ( - ); - if (isLoading) return ; + + if (isLoading) return ; + if (error || !data?.verified) return ( - ); return ( -
    -

    Email verified

    -

    Your account is ready.

    - + + New to Vantage? The{" "} + + getting started guide + {" "} + walks through your first instance. + + ) : undefined + } + > +

    Sign in to create your first instance. The Free tier covers 5 servers and needs no card.

    + Sign in - -
    - ); -} - -function Message({ title, body }: { title: string; body: string }) { - return ( -
    -

    {title}

    -

    {body}

    -
    +
    +
    ); } export default function VerifyPage() { return ( -
    - - - -
    + }> + + ); } diff --git a/adminsite/components/AuthShell.tsx b/adminsite/components/AuthShell.tsx new file mode 100644 index 0000000..9be6f10 --- /dev/null +++ b/adminsite/components/AuthShell.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; + +/* + * The frame for every screen you can reach without a session: sign in, email + * verification, and accepting an invitation. + * + * These three had drifted into three different layouts. Sign in was a centred + * 26rem card with the lockup above it; verify and accept-invite were bare + * left-aligned text on the full 1200px rail, with no masthead, no panel and no + * brand anywhere on the page. Those two are the first screens a new customer + * ever sees — arriving from an email, on a domain they have not visited before + * — and they were the two that did not say whose product this is. + * + * There is no AppBar here on purpose: it carries navigation and an account + * menu, and none of it works without a session. + */ +export function AuthShell({ + title, + lede, + children, + footnote, +}: { + title: string; + lede?: React.ReactNode; + children?: React.ReactNode; + /** Sits outside the panel: orientation, not part of the task. */ + footnote?: React.ReactNode; +}) { + return ( +
    + {/* The masthead's lockup, unlinked: there is nowhere to go yet. */} +
    + + Vantage + HQ + +

    {title}

    + {lede &&

    {lede}

    } +
    + + {children &&
    {children}
    } + + {footnote &&
    {footnote}
    } +
    + ); +} + +/* + * A terminal state — verified, expired, already used, invalid. Always says what + * happened and what to do next: a dead end that only reports the failure leaves + * someone holding an email they cannot act on. + */ +export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) { + return ( + +

    {body}

    + {action && ( + + {action.label} + + )} +
    + ); +} diff --git a/adminsite/components/InstanceRecord.tsx b/adminsite/components/InstanceRecord.tsx index 8ff6755..e735b02 100644 --- a/adminsite/components/InstanceRecord.tsx +++ b/adminsite/components/InstanceRecord.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { api, type Instance, type License } from "@/lib/api"; import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format"; import { StatePill } from "./StatePill"; +import { TermBar } from "./TermBar"; import { Button, LinkButton } from "./Button"; const STRIPE = { @@ -35,7 +36,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen = const state = licenceState(license?.expires_at, Boolean(license)); const days = license ? daysRemaining(license.expires_at) : 0; const cloud = instance.deployment === "cloud"; - const termDays = instance.tier === "free" ? 30 : 365; const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null; const [open, setOpen] = useState(defaultOpen); @@ -102,22 +102,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
- {license && state !== "expired" && ( -
-
- {days} days remaining - Renews {formatDate(license.expires_at)} -
-
-
-
-
- )} + {/* The term is drawn for an expired licence too. The old bar hid + itself once it lapsed, which removed the measurement at exactly + the moment it started mattering. */} + {license && } {state === "expired" && (
diff --git a/adminsite/components/Panel.tsx b/adminsite/components/Panel.tsx new file mode 100644 index 0000000..49406dd --- /dev/null +++ b/adminsite/components/Panel.tsx @@ -0,0 +1,91 @@ +import clsx from "clsx"; + +/* + * The surface every screen is built from. + * + * Before this there were four panel treatments in the app: `rounded border + * border-rule bg-panel p-5` with an `

`, the same thing + * with `text-[0.95rem] font-medium`, a bare `
` + * with no border at all, and a table wrapper that was a panel in everything but + * name. They were all trying to be the same object. + * + * The header is title-left, meta-right. Meta is the keyed idiom — mono, small, + * tracked, dimmed — because it is always a count, a scope or an identifier, + * never prose. + */ +export function Panel({ + title, + meta, + actions, + tone, + children, + bodyless, + className, +}: { + title?: string; + meta?: React.ReactNode; + actions?: React.ReactNode; + /** Draws the panel's own border in a state colour. For a panel that IS the warning. */ + tone?: "warn" | "expired"; + children: React.ReactNode; + /** Skip the padded body — for a panel whose content is a full-bleed table. */ + bodyless?: boolean; + className?: string; +}) { + const head = title || meta || actions; + + return ( +
+ {head && ( +
+ {title &&

{title}

} +
+ {meta && {meta}} + {actions} +
+
+ )} + {bodyless ? children :
{children}
} +
+ ); +} + +/* + * An aside that is part of the argument rather than beside it: the consequence + * of the action on screen, or the constraint the reader is about to hit. The + * left rule carries the tone, so the note reads as annotation and never as a + * second panel competing with the one it sits in. + */ +export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/* + * An empty screen is an invitation to act. Every one of these says what the + * thing is before offering to make one — "No licences match those filters" on + * its own tells someone the filter worked, not what to do about it. + */ +export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) { + return ( +
+

{title}

+ {body &&

{body}

} + {action &&
{action}
} +
+ ); +} diff --git a/adminsite/components/Queue.tsx b/adminsite/components/Queue.tsx index cb58231..64e08e5 100644 --- a/adminsite/components/Queue.tsx +++ b/adminsite/components/Queue.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import clsx from "clsx"; +import type { ReactNode } from "react"; const TONE = { expired: "border-l-expired text-expired", @@ -16,7 +17,11 @@ export function Queue({ title: string; count: number; tone: keyof typeof TONE; - items: { label: string; href: string; meta: string }[]; + /* `meta` is a node rather than a string so a queue about time can carry the + * term measurement itself. A tier name told the reader what the instance + * was; the queue is sorted by how soon it lapses, and that was the one + * figure the row did not show. */ + items: { label: string; href: string; meta: ReactNode }[]; }) { return (
(
  • - + {i.label} - {i.meta} + {i.meta}
  • ))} diff --git a/adminsite/components/Table.tsx b/adminsite/components/Table.tsx new file mode 100644 index 0000000..3be9bc6 --- /dev/null +++ b/adminsite/components/Table.tsx @@ -0,0 +1,83 @@ +import clsx from "clsx"; +import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; + +/* + * One table treatment for the whole console. + * + * There were four: billing, licences, accounts and catalogue each wrote their + * own thead, and they disagreed about the head's type size, its tracking, + * whether it sat on --panel-2, and whether numbers were tabular. Catalogue's + * heads were sentence-case body text. A registry whose columns are set four + * ways does not read as one product. + * + * The head is the keyed idiom — mono, small, uppercase, widely tracked — which + * is what a column head is: a key above a value, exactly as the record line is + * a key beside one. + */ + +export function Table({ className, children, ...props }: HTMLAttributes) { + return ( +
    + + {children} +
    +
    + ); +} + +export function THead({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +export function TBody({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +export function TR({ className, children, ...props }: HTMLAttributes) { + return ( + + {children} + + ); +} + +interface CellProps { + /** Right-aligns the cell. For quantities and money, which read down the column. */ + numeric?: boolean; +} + +export function TH({ className, numeric, children, ...props }: ThHTMLAttributes & CellProps) { + return ( + + {children} + + ); +} + +export function TD({ className, numeric, children, ...props }: TdHTMLAttributes & CellProps) { + return ( + + {children} + + ); +} + +/** The secondary line under a cell's main value — an ID, a deployment, a date. */ +export function Sub({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} diff --git a/adminsite/components/TermBar.tsx b/adminsite/components/TermBar.tsx new file mode 100644 index 0000000..86ab0c9 --- /dev/null +++ b/adminsite/components/TermBar.tsx @@ -0,0 +1,99 @@ +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 = { + 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 ( +
    +
    + + {/* The span still to come, drawn as absence rather than as a + second colour: it is the thing being bought. */} + + +
    + +
    + Issued {formatDate(issuedAt)} + {remaining} + Expires {formatDate(expiresAt)} +
    +
    + ); +} + +/* + * 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 ( + + + + + + {days <= 0 ? `−${Math.abs(days)}d` : `${days}d`} + + ); +}