@anchrd/intel-ui 0.23.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
- import type { ResourceGrant, ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
3
+ import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
@@ -11,20 +11,20 @@ import {
11
11
  FileArchive,
12
12
  FolderDown,
13
13
  FolderUp,
14
+ History,
14
15
  Link2,
15
16
  Pencil,
16
17
  Share2,
17
18
  ShieldCheck,
18
- Trash2,
19
19
  } from "lucide-react";
20
20
  import type * as React from "react";
21
21
  import { useState } from "react";
22
+ import { AccessSummary } from "@/access-summary/access-summary.tsx";
22
23
  import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
23
24
  import {
24
25
  DropdownMenu,
25
26
  DropdownMenuContent,
26
27
  DropdownMenuItem,
27
- DropdownMenuSeparator,
28
28
  DropdownMenuSub,
29
29
  DropdownMenuSubContent,
30
30
  DropdownMenuSubTrigger,
@@ -37,8 +37,8 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
37
37
  import { Modal } from "@/modal/modal.tsx";
38
38
  import { useIntelRouterContext } from "@/router/router-context.ts";
39
39
  import { selectedFrom } from "@/router/selection-search.ts";
40
+ import { RelativeTime } from "@/time/relative-time.tsx";
40
41
  import { useDateTime } from "@/time/time-context.tsx";
41
- import { useUserName } from "@/user-name/user-name.ts";
42
42
 
43
43
  /**
44
44
  * What a resource's own actions are, at the place the resource stands (#24).
@@ -143,6 +143,7 @@ export function ResourceMenu({
143
143
  const [renaming, setRenaming] = useState(false);
144
144
  const [sharing, setSharing] = useState(false);
145
145
  const [linksOpen, setLinksOpen] = useState(false);
146
+ const [versionsOpen, setVersionsOpen] = useState(false);
146
147
  const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
147
148
  const pathname = useRouterState({ select: (state) => state.location.pathname });
148
149
  // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
@@ -315,35 +316,26 @@ export function ResourceMenu({
315
316
  className={
316
317
  variant === "row"
317
318
  ? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
318
- : "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
319
+ : "inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
319
320
  }
320
321
  >
321
322
  <Ellipsis aria-hidden="true" className="size-4" />
322
323
  </DropdownMenuTrigger>
323
- <DropdownMenuContent align="start" className="w-52">
324
+ <DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
324
325
  <DropdownMenuItem onSelect={() => setRenaming(true)}>
325
326
  <Pencil aria-hidden="true" />
326
327
  {i18n.t("resource.rename")}
327
328
  </DropdownMenuItem>
328
- {/* ⚠️ #455: the hint is the actual change of the ticket. Dragging in the tree has been
329
- built and shipped since 2026-08-02 Jack asked for it anyway, so the feature was not
330
- the problem, its discoverability was. Nothing about a row says it is movable before you
331
- touch it: `cursor-grab` is only visible once the pointer is already on it, and the root
332
- drop target appears only while something is being carried.
333
-
334
- A menu is where you learn what can be done with a thing the same place an application
335
- names its keyboard shortcuts. No handle and no extra icon (Jack's instruction), and
336
- readable without the pointer ever touching the row.
337
-
338
- ⚠️ The dialog behind it stays the main path, not the fallback: touch has no
339
- `dragstart`, and nothing can be dragged by keyboard at all. Dragging is the shortcut
340
- for the mouse — the hint says exactly that, in the place that is reachable both ways. */}
341
- <DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
329
+ {/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
330
+ shortcut; it does not need a second label beside this action. */}
331
+ {/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
332
+ on a resource's own title line, so there is no rendered tree level to name. Passing the
333
+ record's parent instead would claim to know a level — and for a shared row it would
334
+ name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
335
+ the absence and cleans up both levels such a row can be standing in. */}
336
+ <DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
342
337
  <CornerLeftUp aria-hidden="true" />
343
- {i18n.t("tree.move.action")}
344
- <span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
345
- {i18n.t("tree.move.hint")}
346
- </span>
338
+ {i18n.t("resource.move")}
347
339
  </DropdownMenuItem>
348
340
  {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
349
341
  one (#136). The entry sits with rename and move because it acts on the whole thing,
@@ -357,7 +349,7 @@ export function ResourceMenu({
357
349
  {isTable ? (
358
350
  <DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
359
351
  <Download aria-hidden="true" />
360
- {i18n.t("node.table.download")}
352
+ {i18n.t("resource.downloadCsv")}
361
353
  </DropdownMenuItem>
362
354
  ) : null}
363
355
  {/* The other half of the same round trip, next to it rather than in the tree's plus
@@ -385,14 +377,13 @@ export function ResourceMenu({
385
377
  {node === null ? (
386
378
  <DropdownMenuItem onSelect={() => validate.mutate()}>
387
379
  <ShieldCheck aria-hidden="true" />
388
- {i18n.t("flows.validate")}
380
+ {i18n.t("resource.validate")}
389
381
  </DropdownMenuItem>
390
382
  ) : null}
391
- {retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
392
383
  {linkable ? (
393
384
  <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
394
385
  <Link2 aria-hidden="true" />
395
- {i18n.t("node.links")}
386
+ {i18n.t("resource.links")}
396
387
  </DropdownMenuItem>
397
388
  ) : null}
398
389
  {/* A flow has no share of its own: it is reached through the folder it is filed in, and a
@@ -401,17 +392,19 @@ export function ResourceMenu({
401
392
  {node ? (
402
393
  <DropdownMenuItem onSelect={() => setSharing(true)}>
403
394
  <Share2 aria-hidden="true" />
404
- {i18n.t("node.share")}
395
+ {i18n.t("resource.share")}
405
396
  </DropdownMenuItem>
406
397
  ) : null}
398
+ {(node === null || node.kind !== "folder") && (
399
+ <DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
400
+ <History aria-hidden="true" />
401
+ {i18n.t("node.versions")}
402
+ </DropdownMenuItem>
403
+ )}
407
404
  {/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
408
405
  loses is reach — it leaves the tree, cannot be started, and stops resolving as another
409
406
  flow's callee. */}
410
- <DropdownMenuSeparator />
411
- <DropdownMenuItem
412
- onSelect={() => archive.mutate()}
413
- className="text-destructive focus:text-destructive"
414
- >
407
+ <DropdownMenuItem onSelect={() => archive.mutate()}>
415
408
  <Archive aria-hidden="true" />
416
409
  {i18n.t("resource.archive")}
417
410
  </DropdownMenuItem>
@@ -498,10 +491,70 @@ export function ResourceMenu({
498
491
  ) : null}
499
492
  {sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
500
493
  {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
494
+ {versionsOpen ? (
495
+ <VersionHistory target={target} close={() => setVersionsOpen(false)} />
496
+ ) : null}
501
497
  </>
502
498
  );
503
499
  }
504
500
 
501
+ function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
502
+ const { data } = useIntelRouterContext();
503
+ const i18n = useI18n();
504
+ const id = idOf(target);
505
+ const versions = useQuery({
506
+ queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
507
+ queryFn: async () => {
508
+ const result =
509
+ target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
510
+ return result.items.map((version) => ({
511
+ id: version.id,
512
+ sequence: version.sequence,
513
+ createdAt: version.createdAt,
514
+ }));
515
+ },
516
+ });
517
+ return (
518
+ <aside
519
+ aria-label={i18n.t("node.versions")}
520
+ className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
521
+ >
522
+ <div className="flex items-start justify-between gap-4">
523
+ <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
524
+ <button
525
+ type="button"
526
+ onClick={close}
527
+ aria-label={i18n.t("common.close")}
528
+ className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
529
+ >
530
+ ×
531
+ </button>
532
+ </div>
533
+ {versions.isPending ? (
534
+ <p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
535
+ ) : null}
536
+ {versions.isError ? (
537
+ <p role="alert" className="mt-4 text-sm text-destructive">
538
+ {i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
539
+ </p>
540
+ ) : null}
541
+ <ol className="mt-4 space-y-3">
542
+ {versions.data?.map((version) => (
543
+ <li key={version.id} className="rounded-md border p-3 text-sm">
544
+ <span className="font-medium">
545
+ {i18n.t("node.version", { sequence: version.sequence })}
546
+ </span>
547
+ <RelativeTime
548
+ value={version.createdAt}
549
+ className="mt-1 block text-xs text-muted-foreground"
550
+ />
551
+ </li>
552
+ ))}
553
+ </ol>
554
+ </aside>
555
+ );
556
+ }
557
+
505
558
  // One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
506
559
  function MenuFailure({ children }: { children: React.ReactNode }) {
507
560
  return (
@@ -638,54 +691,6 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
638
691
  // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
639
692
  // server's answer, not this component's: `execute` never appears on a document, because a document
640
693
  // has nothing to run.
641
- /**
642
- * One row of the grant list.
643
- *
644
- * ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
645
- * is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
646
- * beside the word "Read" — which tells the reader nothing about who they let in and puts an
647
- * identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
648
- * ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
649
- *
650
- * ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
651
- * could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
652
- * stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
653
- * answering and this row needs no change.
654
- */
655
- function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
656
- const i18n = useI18n();
657
- const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
658
- // ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
659
- // decision rather than an oversight (#431, review finding). The grant is bound to the address —
660
- // `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
661
- // Putting a name there would claim a binding that does not exist, and it would be wrong the moment
662
- // somebody else verifies that address.
663
- const who =
664
- grant.principal.type === "email"
665
- ? grant.principal.email
666
- : grant.principal.type === "user"
667
- ? (userName ?? i18n.t("node.someUser"))
668
- : i18n.t("node.organization");
669
- return (
670
- <li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
671
- <span className="min-w-0 truncate">
672
- {who}
673
- <span className="ml-2 text-xs text-muted-foreground">
674
- {i18n.t(`node.verb.${grant.verb}`)}
675
- </span>
676
- </span>
677
- <button
678
- type="button"
679
- onClick={() => onRevoke(grant.id)}
680
- aria-label={i18n.t("node.revokeShare")}
681
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
682
- >
683
- <Trash2 aria-hidden="true" className="size-4" />
684
- </button>
685
- </li>
686
- );
687
- }
688
-
689
694
  /**
690
695
  * Which of the three principals the dialog can SET (#431).
691
696
  *
@@ -753,7 +758,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
753
758
  // before, and the user would read a permission picture that is not the one in force. Of all the
754
759
  // things to be silently wrong about, access is the worst.
755
760
  onSettled: async () => {
756
- await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
761
+ await Promise.all([
762
+ queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
763
+ queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
764
+ ]);
757
765
  },
758
766
  });
759
767
  const revoke = useMutation({
@@ -764,7 +772,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
764
772
  idempotencyKey: crypto.randomUUID(),
765
773
  }),
766
774
  onSettled: async () => {
767
- await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
775
+ await Promise.all([
776
+ queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
777
+ queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
778
+ ]);
768
779
  },
769
780
  });
770
781
 
@@ -820,21 +831,17 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
820
831
  <p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
821
832
  </div>
822
833
  )}
823
- <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
824
- {grants.data?.items.map((grant) => (
825
- <GrantRow
826
- key={grant.id}
827
- grant={grant}
834
+ {grants.data && grants.data.items.length > 0 ? (
835
+ <div className="mb-5">
836
+ <AccessSummary
837
+ grants={grants.data.items}
828
838
  onRevoke={(grantId) => {
829
839
  share.reset();
830
840
  revoke.mutate(grantId);
831
841
  }}
832
842
  />
833
- ))}
834
- {grants.data?.items.length === 0 && (
835
- <li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
836
- )}
837
- </ul>
843
+ </div>
844
+ ) : null}
838
845
  <form
839
846
  onSubmit={(event) => {
840
847
  event.preventDefault();
@@ -860,7 +867,7 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
860
867
  name="share-principal"
861
868
  checked={principalKind === kind}
862
869
  onChange={() => setPrincipalKind(kind)}
863
- aria-describedby={kind === "email" ? "share-email-hint" : "share-organization-hint"}
870
+ aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
864
871
  className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
865
872
  />
866
873
  <span>
@@ -882,19 +889,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
882
889
  required
883
890
  value={email}
884
891
  onChange={(event) => setEmail(event.target.value)}
885
- aria-describedby="share-email-hint"
886
892
  className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
887
893
  />
888
894
  </label>
889
- {/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
890
- address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
891
- and a dialog that stayed silent about that let a typo become a grant that is valid,
892
- permanent and completely without effect, with nobody on either side to notice. Naming
893
- the uncertainty is the honest half of what the ticket asks for; the other half needs
894
- Gate. */}
895
- <p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
896
- {i18n.t("node.emailHint")}
897
- </p>
898
895
  </div>
899
896
  ) : (
900
897
  /* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
@@ -8,61 +8,42 @@ import { Modal } from "@/modal/modal.tsx";
8
8
  * Saving, in the title line beside the name of the thing being saved (#24).
9
9
  *
10
10
  * ⚠️ Unsaved is a state that must survive colour blindness, so it is never carried by colour alone.
11
- * At rest the button is a quiet icon; with something to save it fills, grows a dot and spells the
12
- * word out. Three changes at once, of which two are readable without seeing any colour at all.
11
+ * The button only exists while there is work to save; it fills, grows a dot and spells the word
12
+ * out. Three signals at once, of which two are readable without seeing any colour at all.
13
13
  *
14
- * ⚠️ The accessible name names the state as well "Saved" and "Save unsaved changes" are two
15
- * different pieces of information, and a screen reader gets neither the fill nor the dot. The
16
- * visible word stays a prefix of it, so speech input still reaches the button by what it reads.
17
- *
18
- * ⚠️ At rest there are TWO states, not one, and `stored` is what tells them apart (#432). A
19
- * document that has never been written has nothing to save either — and calling that "Saved" is the
20
- * one sentence an editor must never say untruthfully: whoever reads it closes the tab. The button
21
- * was already unclickable in that state, which is exactly why it went unnoticed for so long; the
22
- * tooltip and the accessible name kept claiming a version that does not exist. "Nothing to save"
23
- * says less and stays true in both readings.
14
+ * ⚠️ The accessible name names the state as well. A screen reader gets neither the fill nor the
15
+ * dot, and saving in progress must not sound like another available action. The visible word stays
16
+ * a prefix of the name, so speech input still reaches the button by what it reads.
24
17
  */
25
18
  export function SaveButton({
26
19
  dirty,
27
20
  saving,
28
- stored,
29
21
  onSave,
30
22
  }: {
31
23
  dirty: boolean;
32
24
  saving: boolean;
33
- /** Whether what is being edited has content on the server at all. */
34
- stored: boolean;
35
25
  onSave(): void;
36
26
  }) {
37
27
  const i18n = useI18n();
38
- const label = i18n.t(dirty ? "common.saveDirty" : stored ? "common.saved" : "common.saveNothing");
28
+ if (!dirty && !saving) return null;
29
+ const label = i18n.t(saving ? "common.saving" : "common.saveDirty");
39
30
  return (
40
31
  <TooltipProvider delayDuration={300}>
41
32
  <Tooltip>
42
- {/* ⚠️ The wrapper is the trigger, not the button. At rest the button is disabled, and a
43
- disabled control fires no pointer events — a tooltip hung on it would stay shut in the
44
- one state where the button carries no words at all. */}
33
+ {/* The wrapper remains the trigger while saving, when the button is deliberately disabled. */}
45
34
  <TooltipTrigger asChild>
46
35
  <span className="inline-flex">
47
36
  <button
48
37
  type="button"
49
- disabled={!dirty || saving}
38
+ disabled={saving}
50
39
  onClick={onSave}
51
40
  aria-label={label}
52
41
  data-dirty={dirty}
53
- className={
54
- dirty
55
- ? "inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
56
- : "inline-flex size-8 items-center justify-center rounded-md border bg-background text-muted-foreground outline-none disabled:opacity-60"
57
- }
42
+ className="inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
58
43
  >
59
- {dirty ? (
60
- <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
61
- ) : null}
44
+ <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
62
45
  <Save aria-hidden="true" className="size-4 shrink-0" />
63
- {dirty ? (
64
- <span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
65
- ) : null}
46
+ <span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
66
47
  </button>
67
48
  </span>
68
49
  </TooltipTrigger>
@@ -0,0 +1,41 @@
1
+ import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
2
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
3
+ import { cn } from "@/lib/utils";
4
+ import { useDateTime } from "./time-context.tsx";
5
+
6
+ const ClockContext = createContext<number | null>(null);
7
+
8
+ export function RelativeClockProvider({ children }: { children: ReactNode }) {
9
+ const [now, setNow] = useState(Date.now);
10
+ useEffect(() => {
11
+ const timer = window.setInterval(() => setNow(Date.now()), 1_000);
12
+ return () => window.clearInterval(timer);
13
+ }, []);
14
+ return <ClockContext value={now}>{children}</ClockContext>;
15
+ }
16
+
17
+ export function RelativeTime({ value, className }: { value: string; className?: string }) {
18
+ const clock = useContext(ClockContext);
19
+ const dateTime = useDateTime();
20
+ const now = clock ?? Date.now();
21
+ return (
22
+ <TooltipProvider delayDuration={300}>
23
+ <Tooltip>
24
+ <TooltipTrigger asChild>
25
+ <time
26
+ dateTime={value}
27
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: The exact timestamp tooltip must be keyboard reachable, including inside clickable rows.
28
+ tabIndex={0}
29
+ className={cn(
30
+ "rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
31
+ className,
32
+ )}
33
+ >
34
+ {dateTime.relative(value, now)}
35
+ </time>
36
+ </TooltipTrigger>
37
+ <TooltipContent>{dateTime.at(value)}</TooltipContent>
38
+ </Tooltip>
39
+ </TooltipProvider>
40
+ );
41
+ }
@@ -4,6 +4,7 @@ import {
4
4
  DeviceZone,
5
5
  formatDate,
6
6
  formatDateTime,
7
+ formatRelativeTime,
7
8
  readZoneChoice,
8
9
  rememberZoneChoice,
9
10
  type TimeZoneStore,
@@ -58,7 +59,11 @@ export function useTimeZone(): ZoneSelection | null {
58
59
  * AND zone itself, and the first one that forgets shows the device's zone again — which is the state
59
60
  * before #467, only harder to find.
60
61
  */
61
- export function useDateTime(): { at: (value: string) => string; on: (value: string) => string } {
62
+ export function useDateTime(): {
63
+ at: (value: string) => string;
64
+ on: (value: string) => string;
65
+ relative: (value: string, now: number) => string;
66
+ } {
62
67
  const i18n = useI18n();
63
68
  const zone = useContext(ZoneContext);
64
69
  const choice = zone?.current ?? DeviceZone;
@@ -66,6 +71,7 @@ export function useDateTime(): { at: (value: string) => string; on: (value: stri
66
71
  () => ({
67
72
  at: (value: string) => formatDateTime(value, i18n.locale, choice),
68
73
  on: (value: string) => formatDate(value, i18n.locale, choice),
74
+ relative: (value: string, now: number) => formatRelativeTime(value, i18n.locale, choice, now),
69
75
  }),
70
76
  [i18n.locale, choice],
71
77
  );
package/src/time/time.ts CHANGED
@@ -115,6 +115,31 @@ export function formatDate(value: string, locale: string, choice: ZoneChoice): s
115
115
  return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
116
116
  }
117
117
 
118
+ const Minute = 60_000;
119
+ const Hour = 60 * Minute;
120
+ const Day = 24 * Hour;
121
+ const Month = 30 * Day;
122
+
123
+ /** A compact list value. Thirty days is the explicit month boundary; calendar labels stay in the tooltip. */
124
+ export function formatRelativeTime(
125
+ value: string,
126
+ locale: string,
127
+ choice: ZoneChoice,
128
+ now: number,
129
+ ): string {
130
+ const date = new Date(value);
131
+ const target = date.getTime();
132
+ if (Number.isNaN(target)) return value;
133
+ const difference = target - now;
134
+ const distance = Math.abs(difference);
135
+ const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" });
136
+ if (distance < Minute) return relative.format(0, "second");
137
+ if (distance < Hour) return relative.format(Math.trunc(difference / Minute), "minute");
138
+ if (distance < Day) return relative.format(Math.trunc(difference / Hour), "hour");
139
+ if (distance < Month) return relative.format(Math.trunc(difference / Day), "day");
140
+ return formatDate(value, locale, choice);
141
+ }
142
+
118
143
  // ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
119
144
  // entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
120
145
  // missing here is added once it has been missed three times; until then the device's zone is the