@eifi1/ui-kit 0.46.0 → 0.46.1

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.
Files changed (75) hide show
  1. package/README.md +10 -9
  2. package/dist/admin/user-roster.d.ts +6 -2
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/components/data-table-row-actions.d.ts +22 -5
  6. package/dist/components/data-table-row-actions.js +14 -5
  7. package/dist/components/data-table-row-actions.js.map +1 -1
  8. package/dist/components/data-table.d.ts +16 -1
  9. package/dist/components/data-table.js +2 -0
  10. package/dist/components/data-table.js.map +1 -1
  11. package/dist/components/row-actions.d.ts +12 -5
  12. package/dist/components/row-actions.js +11 -4
  13. package/dist/components/row-actions.js.map +1 -1
  14. package/dist/components/translation-review.js +3 -1
  15. package/dist/components/translation-review.js.map +1 -1
  16. package/dist/data-table.d.ts +1 -0
  17. package/dist/data-table.js +2 -0
  18. package/dist/data-table.js.map +1 -1
  19. package/dist/eslint/index.js +1 -1
  20. package/dist/i18n/locales/en.js.map +2 -2
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +4 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/notifications/device-zone-report.d.ts +7 -0
  25. package/dist/notifications/device-zone-report.js +8 -1
  26. package/dist/notifications/device-zone-report.js.map +1 -1
  27. package/dist/notifications/feedback-notification-types.d.ts +14 -1
  28. package/dist/notifications/feedback-notification-types.js +5 -1
  29. package/dist/notifications/feedback-notification-types.js.map +1 -1
  30. package/dist/notifications/notification-bell.d.ts +1 -1
  31. package/dist/notifications/notification-bell.js.map +1 -1
  32. package/dist/notifications/notification-inbox.d.ts +12 -3
  33. package/dist/notifications/notification-inbox.js +2 -2
  34. package/dist/notifications/notification-inbox.js.map +1 -1
  35. package/dist/notifications/notifications-labels.d.ts +3 -1
  36. package/dist/notifications/notifications-labels.js.map +1 -1
  37. package/dist/notifications/time-zone-select.d.ts +5 -4
  38. package/dist/notifications/time-zone-select.js +2 -7
  39. package/dist/notifications/time-zone-select.js.map +1 -1
  40. package/dist/offline/local-first.d.ts +9 -0
  41. package/dist/offline/local-first.js +14 -0
  42. package/dist/offline/local-first.js.map +1 -1
  43. package/dist/offline.d.ts +1 -1
  44. package/dist/offline.js +2 -1
  45. package/dist/offline.js.map +1 -1
  46. package/dist/sync/db.d.ts +17 -0
  47. package/dist/sync/db.js +10 -1
  48. package/dist/sync/db.js.map +1 -1
  49. package/dist/sync/engine.d.ts +11 -9
  50. package/dist/sync/engine.js.map +1 -1
  51. package/dist/sync/hooks.d.ts +5 -0
  52. package/dist/sync/hooks.js.map +1 -1
  53. package/dist/sync.d.ts +2 -2
  54. package/dist/sync.js +5 -2
  55. package/dist/sync.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/admin/user-roster.tsx +12 -3
  58. package/src/components/data-table-row-actions.tsx +33 -9
  59. package/src/components/data-table.tsx +17 -0
  60. package/src/components/row-actions.tsx +33 -9
  61. package/src/components/translation-review.tsx +3 -1
  62. package/src/data-table.ts +3 -0
  63. package/src/index.ts +10 -3
  64. package/src/notifications/device-zone-report.ts +14 -1
  65. package/src/notifications/feedback-notification-types.ts +19 -1
  66. package/src/notifications/notification-bell.tsx +1 -1
  67. package/src/notifications/notification-inbox.tsx +15 -5
  68. package/src/notifications/notifications-labels.ts +3 -1
  69. package/src/notifications/time-zone-select.tsx +6 -10
  70. package/src/offline/local-first.ts +24 -0
  71. package/src/offline.ts +1 -1
  72. package/src/sync/db.ts +22 -0
  73. package/src/sync/engine.ts +11 -9
  74. package/src/sync/hooks.ts +5 -0
  75. package/src/sync.ts +2 -2
@@ -522,10 +522,20 @@ function InlineAction({
522
522
  );
523
523
  }
524
524
 
525
- /** The actions column's hidden header, in the provider's words. */
526
- function RowActionsHeader({ text, labels }: { text?: string; labels?: LabelOverride<RowActionsLabels> }) {
525
+ /** The actions column's heading, in the provider's words: on screen since 0.46.1 (keksdose
526
+ * dev#598 rework), a screen reader's name only with `hidden`. */
527
+ function RowActionsHeader({
528
+ text,
529
+ labels,
530
+ hidden,
531
+ }: {
532
+ text?: string;
533
+ labels?: LabelOverride<RowActionsLabels>;
534
+ hidden?: boolean;
535
+ }) {
527
536
  const resolved = useKitLabels("rowActions", DEFAULT_ROW_ACTIONS_LABELS, labels);
528
- return <span className="sr-only">{text ?? resolved.actions}</span>;
537
+ const word = text ?? resolved.actions;
538
+ return hidden ? <span className="sr-only">{word}</span> : <>{word}</>;
529
539
  }
530
540
 
531
541
  export interface RowActionsColumnOptions<T> {
@@ -546,10 +556,17 @@ export interface RowActionsColumnOptions<T> {
546
556
  /**
547
557
  * The column's name as plain text — the column-settings checklist and the phone
548
558
  * card's label read it (`DataTableColumn.headerText`). Pass the app's own word; left
549
- * out, the header shows the provider's `rowActions.actions` to a screen reader and
550
- * the plain-text places fall back to the column's `key`.
559
+ * out, the heading shows the provider's `rowActions.actions` and the plain-text
560
+ * places fall back to the column's `key`.
551
561
  */
552
562
  headerText?: string;
563
+ /**
564
+ * The heading (0.46.1, keksdose dev#598 rework — Marcel: a VISIBLE "Actions" heading on
565
+ * every table with an actions column). Default: `headerText`, else the provider's
566
+ * `rowActions.actions` ("Actions"), shown, end-aligned over the buttons. A node
567
+ * replaces it; `false` keeps the pre-0.46.1 heading, named for a screen reader only.
568
+ */
569
+ header?: ReactNode | false;
553
570
  labels?: LabelOverride<RowActionsLabels>;
554
571
  /** Anything else for the column (`mobileHidden`, `cellProps`…), merged last. */
555
572
  column?: Partial<DataTableColumn<T>>;
@@ -561,8 +578,8 @@ export interface RowActionsColumnOptions<T> {
561
578
  *
562
579
  * columns={[...columns, rowActionsColumn({ actions: (r) => [...], name: (r) => r.name })]}
563
580
  *
564
- * The header is visually hidden (the icons say what the column is), the column hugs its
565
- * content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the
581
+ * The heading says "Actions" on screen (0.46.1; `header` changes or hides it), the
582
+ * column hugs its content at the row's end, and `noRowLink` keeps a `rowHref` anchor off it, so the
566
583
  * buttons keep their own clicks. On a phone it is a line of the card like any column;
567
584
  * set `column: { mobileHidden: true }` to drop it there (with `mobileDetailsInRow` it
568
585
  * then shows in the opened row instead).
@@ -576,12 +593,18 @@ export function rowActionsColumn<T>({
576
593
  glyphSize,
577
594
  tooltipSide,
578
595
  headerText,
596
+ header,
579
597
  labels,
580
598
  column,
581
599
  }: RowActionsColumnOptions<T>): DataTableColumn<T> {
582
600
  return {
583
601
  key,
584
- header: <RowActionsHeader text={headerText} labels={labels} />,
602
+ header:
603
+ header === false || header == null ? (
604
+ <RowActionsHeader text={headerText} labels={labels} hidden={header === false} />
605
+ ) : (
606
+ header
607
+ ),
585
608
  headerText,
586
609
  cell: (row) => (
587
610
  <RowActions
@@ -595,7 +618,8 @@ export function rowActionsColumn<T>({
595
618
  />
596
619
  ),
597
620
  className: "w-px whitespace-nowrap text-end",
598
- headClassName: "w-px",
621
+ // The heading over the buttons, at the row's end (0.46.1).
622
+ headClassName: "w-px text-end",
599
623
  noRowLink: true,
600
624
  ...column,
601
625
  };
@@ -838,7 +838,9 @@ export function TranslationReviewPanel({
838
838
  } else if (editable) {
839
839
  columns.push({
840
840
  key: "approve",
841
- header: <span className="sr-only">{labels.approve}</span>,
841
+ // On screen, as every actions column's heading is (0.46.1, keksdose dev#598 rework).
842
+ header: rowActionLabels.actions,
843
+ headerText: rowActionLabels.actions,
842
844
  cell: (r) =>
843
845
  r.status === "approved" || r.text === "" ? null : (
844
846
  <IconButton
package/src/data-table.ts CHANGED
@@ -27,6 +27,9 @@ export type {
27
27
  DataTableRowAction,
28
28
  DataTableDesktopSwipeActions,
29
29
  } from "./components/data-table";
30
+ // 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own
31
+ // derivation, for an app's hand-built actions column.
32
+ export { swipePlanRowActions } from "./components/data-table-row-actions";
30
33
  export * from "./components/data-table-cells";
31
34
  export { useTableUrlState, readTableUrlState } from "./components/use-table-state";
32
35
  export type {
package/src/index.ts CHANGED
@@ -971,15 +971,20 @@ export { NotificationTypesSetting } from "./notifications/notification-types-set
971
971
  export type { NotificationTypesSettingProps } from "./notifications/notification-types-setting";
972
972
  export { QuietHoursSetting } from "./notifications/quiet-hours-setting";
973
973
  export type { QuietHoursSettingProps } from "./notifications/quiet-hours-setting";
974
- export { TimeZoneSelect, timeZoneOptions, deviceTimeZone } from "./notifications/time-zone-select";
974
+ export { TimeZoneSelect, timeZoneOptions } from "./notifications/time-zone-select";
975
975
  export type { TimeZoneSelectProps } from "./notifications/time-zone-select";
976
976
  // 0.46 (docs/notifications-harmonization.md §10.1, §10.3): the inbox list, the bell and
977
977
  // the device-zone report (the account's zone is automatic only, amended 2026-10-20).
978
978
  export { NotificationInbox, INBOX_BADGE_INTERVAL } from "./notifications/notification-inbox";
979
- export type { NotificationInboxItem, NotificationInboxProps } from "./notifications/notification-inbox";
979
+ export type {
980
+ NotificationInboxAction,
981
+ NotificationInboxItem,
982
+ NotificationInboxProps,
983
+ } from "./notifications/notification-inbox";
980
984
  export { NotificationBell } from "./notifications/notification-bell";
981
985
  export type { NotificationBellProps } from "./notifications/notification-bell";
982
- export { useDeviceZoneReport } from "./notifications/device-zone-report";
986
+ // `deviceTimeZone` lives beside the report since 0.46.1 (keksdose N6; sign-up needs it).
987
+ export { useDeviceZoneReport, deviceTimeZone } from "./notifications/device-zone-report";
983
988
  export type { DeviceZoneReportOptions } from "./notifications/device-zone-report";
984
989
  export {
985
990
  usePushDevice,
@@ -1016,8 +1021,10 @@ export type { PushDevicesSettingProps } from "./notifications/push-devices-setti
1016
1021
  export { UnsubscribePanel, unsubscribeTokenType } from "./notifications/unsubscribe-panel";
1017
1022
  export type { UnsubscribePanelProps } from "./notifications/unsubscribe-panel";
1018
1023
  // 0.38.0 (docs/feedback-harmonization.md §9.11): the two feedback types' setting words.
1024
+ // 0.46.1 (keksdose N9): `useFeedbackNotificationTypesLabels`, the words resolved, for `adminGroup`.
1019
1025
  export {
1020
1026
  useFeedbackNotificationTypes,
1027
+ useFeedbackNotificationTypesLabels,
1021
1028
  DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS,
1022
1029
  } from "./notifications/feedback-notification-types";
1023
1030
  export type {
@@ -1,7 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
 
3
3
  import { settle } from "../account/account-parts";
4
- import { deviceTimeZone } from "./time-zone-select";
5
4
 
6
5
  export interface DeviceZoneReportOptions {
7
6
  /** The account's `device_timezone` from `/auth/me`; `undefined` while it loads (no
@@ -17,6 +16,20 @@ export interface DeviceZoneReportOptions {
17
16
  account?: string | number | null;
18
17
  }
19
18
 
19
+ /**
20
+ * This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
21
+ * the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
22
+ * only), and the one sign-up sends (`RegisterRequest.device_timezone`). Here since 0.46.1
23
+ * (keksdose N6); the deprecated `time-zone-select` module re-exports it until 0.47.
24
+ */
25
+ export function deviceTimeZone(): string {
26
+ try {
27
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
28
+ } catch {
29
+ return "UTC";
30
+ }
31
+ }
32
+
20
33
  /** The (account, stored, device) triples already reported in this start of the app. */
21
34
  const reportedThisStart = new Set<string>();
22
35
 
@@ -30,7 +30,8 @@ export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment
30
30
  * - **"New feedback and reworks"** (`feedback_received`, 0.46, feedback §11): to the
31
31
  * admins, when someone files a report or sends one back for rework. (A crash is mailed
32
32
  * only inside the bundle where an app runs the queue, so the text names no crash.)
33
- * `labels.adminGroup` ("Admin") is a heading an app may put over it.
33
+ * `labels.adminGroup` ("Admin") is a heading an app may put over it — read it with
34
+ * {@link useFeedbackNotificationTypesLabels} (0.46.1) for the app's own `groups`.
34
35
  *
35
36
  * Every entry is always returned: the card draws a type only when the server answered
36
37
  * it (notifications §2.1), so kastlan, which does not declare `feedback_comment` while its
@@ -51,3 +52,20 @@ export function useFeedbackNotificationTypes(
51
52
  [words],
52
53
  );
53
54
  }
55
+
56
+ /**
57
+ * The `feedbackNotificationTypes` words, resolved — English, then the provider, then
58
+ * `labels` (0.46.1, keksdose N9): what an app reads to head its own `groups` with
59
+ * `adminGroup` ("Admin") in the provider's locale, as {@link useFeedbackNotificationTypes}
60
+ * labels the entries.
61
+ *
62
+ * ```tsx
63
+ * const { adminGroup } = useFeedbackNotificationTypesLabels();
64
+ * groups={[...appGroups, { id: "admin", label: adminGroup, types: ["feedback_received"] }]}
65
+ * ```
66
+ */
67
+ export function useFeedbackNotificationTypesLabels(
68
+ labels?: LabelOverride<FeedbackNotificationTypesLabels>,
69
+ ): FeedbackNotificationTypesLabels {
70
+ return useKitLabels("feedbackNotificationTypes", DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS, labels);
71
+ }
@@ -20,7 +20,7 @@ export interface NotificationBellProps<Id extends string | number = string | num
20
20
  /** Every unread row of the account (`unread_count`): the badge. */
21
21
  unreadCount: number;
22
22
  /** Open a row: the app marks it read and routes to `item.url` (kastlan switches the
23
- * acting company first). The panel closes. */
23
+ * acting company first, to `item.company_id`). The panel closes. */
24
24
  onOpen: (item: NotificationInboxItem<Id>) => void;
25
25
  /** The app's inbox page or card, for "All notifications". */
26
26
  href: string;
@@ -38,8 +38,15 @@ export interface NotificationInboxItem<Id extends string | number = string | num
38
38
  /** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on
39
39
  * the row — kastlan's inbox holds every company's rows. */
40
40
  company?: string | null;
41
+ /** 0.46.1 (server-kit 0.16.1 `InboxItem.company_id`): the same company by its id, so
42
+ * `onOpen` switches to it exactly (two companies may share a name). */
43
+ company_id?: number | string | null;
41
44
  }
42
45
 
46
+ /** The inbox request that failed, as `describeError` gets it (0.46.1): one row marked read,
47
+ * "Mark all as read", or "Clear all". */
48
+ export type NotificationInboxAction = "read" | "readAll" | "clear";
49
+
43
50
  export interface NotificationInboxProps<Id extends string | number = string | number>
44
51
  extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
45
52
  /** The rows, newest first; `undefined` while the first page loads. */
@@ -53,7 +60,7 @@ export interface NotificationInboxProps<Id extends string | number = string | nu
53
60
  onClear: () => Promise<unknown> | void;
54
61
  /**
55
62
  * Open a row: the kit marks it read first, then calls this, and the app routes to
56
- * `item.url` (kastlan switches the acting company first). Every row is a button then.
63
+ * `item.url` (kastlan switches the acting company, `item.company_id`, first). Every row is a button then.
57
64
  * Left out, a row with a `url` is a real link — an in-app path through the provider's
58
65
  * router link, an absolute URL in a new tab — that marks itself read on a click or a
59
66
  * middle click (keksdose #451), and a row without one marks itself read.
@@ -72,8 +79,11 @@ export interface NotificationInboxProps<Id extends string | number = string | nu
72
79
  formatDate?: (iso: string) => string;
73
80
  /** The heading. Default `labels.inboxTitle` ("Inbox"). */
74
81
  title?: ReactNode;
75
- /** The app's own words for a failed request, or `undefined` for the kit's `failed`. */
76
- describeError?: (error: unknown) => ReactNode | undefined;
82
+ /** The app's own words for a failed request, or `undefined` for the kit's `failed`.
83
+ * `action` (0.46.1) says which request failed: `"read"` (one row), `"readAll"` ("Mark
84
+ * all as read") or `"clear"` ("Clear all") — so a failed mark-read is not worded as a
85
+ * failed clear. */
86
+ describeError?: (error: unknown, action: NotificationInboxAction) => ReactNode | undefined;
77
87
  labels?: LabelOverride<NotificationsLabels>;
78
88
  }
79
89
 
@@ -141,7 +151,7 @@ export function NotificationInbox<Id extends string | number = string | number>(
141
151
  (error: unknown) => {
142
152
  if (!mounted.current) return;
143
153
  setBusy(null);
144
- setFailure(describeFailure(describeError?.(error), labels.failed));
154
+ setFailure(describeFailure(describeError?.(error, which === "read" ? "readAll" : "clear"), labels.failed));
145
155
  },
146
156
  );
147
157
  };
@@ -150,7 +160,7 @@ export function NotificationInbox<Id extends string | number = string | number>(
150
160
  const markOne = (item: NotificationInboxItem<Id>) => {
151
161
  if (item.read_at !== null) return;
152
162
  settle(() => onMarkRead([item.id])).catch((error: unknown) => {
153
- if (mounted.current) setFailure(describeFailure(describeError?.(error), labels.failed));
163
+ if (mounted.current) setFailure(describeFailure(describeError?.(error, "read"), labels.failed));
154
164
  });
155
165
  };
156
166
 
@@ -64,7 +64,9 @@ export interface NotificationsLabels {
64
64
  quietUntil: string;
65
65
  /** One time set, the other not: quiet hours fail open until both are. */
66
66
  quietIncomplete: string;
67
- /** The zone picker's label. */
67
+ /** The zone picker's label.
68
+ * @deprecated 0.46 — the zone is automatic (§10.3): only the deprecated `TimeZoneSelect` reads it, the
69
+ * 0.46 card does not; removed in 0.47. Kept so an app's override still type-checks. */
68
70
  timeZone: string;
69
71
  /** @deprecated 0.46 — the zone is automatic (§10.3), the card offers no device zone; removed in 0.47.
70
72
  * Unused, kept so an app's override still type-checks. */
@@ -5,21 +5,17 @@ import type { LabelOverride } from "../i18n/kit-labels";
5
5
  import type { ComboOption } from "../components/combobox-core";
6
6
  import { InlineEntityCombobox } from "../components/inline-entity-combobox";
7
7
  import type { CommitScope } from "../components/write-lock";
8
+ import { deviceTimeZone as zoneOfDevice } from "./device-zone-report";
8
9
  import { useNotificationsLabels } from "./notification-parts";
9
10
  import type { NotificationRegionLabels, NotificationsLabels } from "./notifications-labels";
10
11
 
11
12
  /**
12
- * This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
13
- * the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
14
- * only).
13
+ * This device's IANA zone — the same function as the package root's, which lives beside
14
+ * `useDeviceZoneReport` now (0.46.1).
15
+ * @deprecated 0.46 — import `deviceTimeZone` from `@eifi1/ui-kit` (notifications/device-zone-report); this
16
+ * module is removed in 0.47.
15
17
  */
16
- export function deviceTimeZone(): string {
17
- try {
18
- return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
19
- } catch {
20
- return "UTC";
21
- }
22
- }
18
+ export const deviceTimeZone: () => string = zoneOfDevice;
23
19
 
24
20
  /** Every zone the runtime lists; `[]` where `Intl.supportedValuesOf` is missing. */
25
21
  function runtimeZones(): string[] {
@@ -23,6 +23,30 @@ export function stampAfter(previous: unknown): string {
23
23
  return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
24
24
  }
25
25
 
26
+ /**
27
+ * A name-based UUID, version 5 (RFC 4122 §4.3: SHA-1 of the namespace's 16 bytes and the
28
+ * name's UTF-8), the same as Python's `uuid.uuid5(uuid.UUID(namespace), name)` — for an id
29
+ * the device and the server derive alike (§2: the per-owner vote id
30
+ * `uuid5(namespace, "<item>:<invitation>")`). Lower case, hyphenated. `namespace` is a UUID,
31
+ * hyphenated or not, in either case; anything else is a `TypeError`. Uses `crypto.subtle`
32
+ * (a secure context: https or localhost).
33
+ */
34
+ export async function uuid5(namespace: string, name: string): Promise<string> {
35
+ const hex = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(namespace)
36
+ ? namespace.replaceAll("-", "")
37
+ : null;
38
+ if (!hex) throw new TypeError(`uuid5: the namespace must be a UUID, not ${JSON.stringify(namespace)}`);
39
+ const nameBytes = new TextEncoder().encode(name);
40
+ const input = new Uint8Array(16 + nameBytes.length);
41
+ for (let i = 0; i < 16; i += 1) input[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
42
+ input.set(nameBytes, 16);
43
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", input)).slice(0, 16);
44
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
45
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // the RFC 4122 variant
46
+ const out = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
47
+ return `${out.slice(0, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}-${out.slice(16, 20)}-${out.slice(20)}`;
48
+ }
49
+
26
50
  /**
27
51
  * The connection, not the server, failed: the request had no answer (an axios-like error
28
52
  * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as
package/src/offline.ts CHANGED
@@ -29,4 +29,4 @@ export {
29
29
  } from "./offline/shared-computer";
30
30
  export type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from "./offline/shared-computer";
31
31
  // 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.
32
- export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first";
32
+ export { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from "./offline/local-first";
package/src/sync/db.ts CHANGED
@@ -79,6 +79,28 @@ export const SYNC_DB_STORES = {
79
79
  meta: "key",
80
80
  } as const;
81
81
 
82
+ /**
83
+ * The `rows` schema with an `[entity+pk]` index (0.46.1), opt-in: the D16 pages start from a
84
+ * server id (`/meters/7`) and {@link rowByPk} finds its copy row by this index. Not in
85
+ * {@link SYNC_DB_STORES}, so no database on a device changes until its app names it in a
86
+ * version of its own — kastlan, whose devices are on v2: `upgrades: [..., { version: 3,
87
+ * stores: SYNC_DB_PK_INDEX }]`. A database new to the devices may take it at once:
88
+ * `extraStores: { ...SYNC_DB_PK_INDEX }`.
89
+ */
90
+ export const SYNC_DB_PK_INDEX = {
91
+ rows: "[entity+id], entity, [entity+pk]",
92
+ } as const;
93
+
94
+ /**
95
+ * The copy row of the server's id `pk` in `entity` (0.46.1): by the `[entity+pk]` index when
96
+ * the database has it ({@link SYNC_DB_PK_INDEX}), else by a scan of the entity's rows.
97
+ * `undefined` when the copy holds no such row.
98
+ */
99
+ export async function rowByPk(db: SyncDb, entity: string, pk: number): Promise<SyncedRow | undefined> {
100
+ if (db.rows.schema.idxByName["[entity+pk]"]) return db.rows.where("[entity+pk]").equals([entity, pk]).first();
101
+ return db.rows.where("entity").equals(entity).filter((row) => row.pk === pk).first();
102
+ }
103
+
82
104
  /**
83
105
  * Open (lazily, on first use) the sync database `name`: the engine's tables plus the app's
84
106
  * `extraStores`, at version 1 or the last of `upgrades`. One database per account (kastlan:
@@ -40,12 +40,13 @@ export interface SyncStepResult {
40
40
  counts?: Partial<SyncCounts>;
41
41
  }
42
42
 
43
- /** Work after the push/pull, such as kastlan's photo upload. */
44
- export interface SyncStep {
43
+ /** Work after the push/pull, such as kastlan's photo upload. `D` is the app's database type
44
+ * (`createSyncDb<HandoverDb>`), so a step reaches the app's own tables without a cast. */
45
+ export interface SyncStep<D extends SyncDb = SyncDb> {
45
46
  name: string;
46
- run(db: SyncDb, ctx: SyncStepContext): Promise<SyncStepResult>;
47
+ run(db: D, ctx: SyncStepContext): Promise<SyncStepResult>;
47
48
  /** Its counts read from the device between rounds (a photo taken offline shows at once). */
48
- counts?(db: SyncDb): Promise<Partial<SyncCounts>>;
49
+ counts?(db: D): Promise<Partial<SyncCounts>>;
49
50
  }
50
51
 
51
52
  export interface SyncEngineState {
@@ -68,8 +69,9 @@ export interface SyncRoundResult {
68
69
  error?: unknown;
69
70
  }
70
71
 
71
- export interface SyncEngineOptions {
72
- db: SyncDb;
72
+ export interface SyncEngineOptions<D extends SyncDb = SyncDb> {
73
+ /** The database; its type `D` is the one the `steps` get. */
74
+ db: D;
73
75
  /** The scopes this session's role holds, in round order (kastlan: handover, meetings, meters,
74
76
  * maintenance, contacts). Read at the start of every round: roles are per company. */
75
77
  scopes: () => readonly string[];
@@ -85,7 +87,7 @@ export interface SyncEngineOptions {
85
87
  holdCodes?: readonly string[];
86
88
  /** Ask for a paged pull and follow its cursor (D12). */
87
89
  paged?: boolean;
88
- steps?: readonly SyncStep[];
90
+ steps?: readonly SyncStep<D>[];
89
91
  /** No round while this holds (kastlan: a practice tour fakes the server's answers). */
90
92
  paused?: () => boolean;
91
93
  /** The poll (30 s) and the debounce after a write (800 ms). */
@@ -186,7 +188,7 @@ const toWire = ({ entity, id, op, data, updated_at }: OutboxChange): SyncWireCha
186
188
  * engine on the new database and stops the old one). Nothing runs until `start()`
187
189
  * (`useSyncWorker`), and `start()` does nothing on a computer marked shared.
188
190
  */
189
- export function createSyncEngine(options: SyncEngineOptions): SyncEngine {
191
+ export function createSyncEngine<D extends SyncDb = SyncDb>(options: SyncEngineOptions<D>): SyncEngine {
190
192
  const { db } = options;
191
193
  const isOffline = options.isOffline ?? isOfflineError;
192
194
  const planCode = options.planCode ?? defaultPlanCode;
@@ -674,7 +676,7 @@ export function createSyncEngine(options: SyncEngineOptions): SyncEngine {
674
676
  * changing values (scopes, the client) through functions. A database object opened afresh
675
677
  * under the same name replaces the kept engine (the old one is stopped).
676
678
  */
677
- export function syncEngineFor(options: SyncEngineOptions): SyncEngine {
679
+ export function syncEngineFor<D extends SyncDb = SyncDb>(options: SyncEngineOptions<D>): SyncEngine {
678
680
  const name = options.db.name;
679
681
  const found = kept.get(name);
680
682
  if (found && found.db === options.db) return found.engine;
package/src/sync/hooks.ts CHANGED
@@ -33,6 +33,11 @@ const idle = () => IDLE;
33
33
  * and in flight (and the steps' pending), `waitingForPlan` = `waiting`, `blocked` = `blocked`
34
34
  * + `rejected`, `lastSyncedAt` from `meta`. `onSync` is "Sync now". Without an engine, an
35
35
  * idle status that follows the browser's connection.
36
+ *
37
+ * With a `null` engine (signed out, or a computer marked shared) `status.online` follows
38
+ * `navigator.onLine` and its `online`/`offline` events, so an app may lean on
39
+ * `status.online` for its own offline lock (kastlan's offline write lock) whether or not an
40
+ * engine runs.
36
41
  */
37
42
  export function useSyncStatus(engine: SyncEngine | null): { status: SyncStatus; onSync: () => Promise<void> } {
38
43
  const state = useSyncExternalStore(
package/src/sync.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // The only entry point that imports `dexie` — an optional peer for that reason. It never loads
5
5
  // `/offline`'s TanStack and idb-keyval peers: the leaf utilities both entries export live in
6
6
  // one module of their own (src/offline/local-first.ts). NOT re-exported from the main barrel.
7
- export { createSyncDb, SYNC_DB_STORES } from "./sync/db";
7
+ export { createSyncDb, rowByPk, SYNC_DB_PK_INDEX, SYNC_DB_STORES } from "./sync/db";
8
8
  export type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from "./sync/db";
9
9
  export { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine";
10
10
  export type {
@@ -26,4 +26,4 @@ export type {
26
26
  SyncResponseBody,
27
27
  SyncWireChange,
28
28
  } from "./sync/wire";
29
- export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first";
29
+ export { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from "./offline/local-first";