@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.
- package/README.md +10 -9
- package/dist/admin/user-roster.d.ts +6 -2
- package/dist/admin/user-roster.js +7 -3
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +22 -5
- package/dist/components/data-table-row-actions.js +14 -5
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table.d.ts +16 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/row-actions.d.ts +12 -5
- package/dist/components/row-actions.js +11 -4
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/translation-review.js +3 -1
- package/dist/components/translation-review.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js +2 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +7 -0
- package/dist/notifications/device-zone-report.js +8 -1
- package/dist/notifications/device-zone-report.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +14 -1
- package/dist/notifications/feedback-notification-types.js +5 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +1 -1
- package/dist/notifications/notification-bell.js.map +1 -1
- package/dist/notifications/notification-inbox.d.ts +12 -3
- package/dist/notifications/notification-inbox.js +2 -2
- package/dist/notifications/notification-inbox.js.map +1 -1
- package/dist/notifications/notifications-labels.d.ts +3 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -4
- package/dist/notifications/time-zone-select.js +2 -7
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/offline/local-first.d.ts +9 -0
- package/dist/offline/local-first.js +14 -0
- package/dist/offline/local-first.js.map +1 -1
- package/dist/offline.d.ts +1 -1
- package/dist/offline.js +2 -1
- package/dist/offline.js.map +1 -1
- package/dist/sync/db.d.ts +17 -0
- package/dist/sync/db.js +10 -1
- package/dist/sync/db.js.map +1 -1
- package/dist/sync/engine.d.ts +11 -9
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/hooks.d.ts +5 -0
- package/dist/sync/hooks.js.map +1 -1
- package/dist/sync.d.ts +2 -2
- package/dist/sync.js +5 -2
- package/dist/sync.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/user-roster.tsx +12 -3
- package/src/components/data-table-row-actions.tsx +33 -9
- package/src/components/data-table.tsx +17 -0
- package/src/components/row-actions.tsx +33 -9
- package/src/components/translation-review.tsx +3 -1
- package/src/data-table.ts +3 -0
- package/src/index.ts +10 -3
- package/src/notifications/device-zone-report.ts +14 -1
- package/src/notifications/feedback-notification-types.ts +19 -1
- package/src/notifications/notification-bell.tsx +1 -1
- package/src/notifications/notification-inbox.tsx +15 -5
- package/src/notifications/notifications-labels.ts +3 -1
- package/src/notifications/time-zone-select.tsx +6 -10
- package/src/offline/local-first.ts +24 -0
- package/src/offline.ts +1 -1
- package/src/sync/db.ts +22 -0
- package/src/sync/engine.ts +11 -9
- package/src/sync/hooks.ts +5 -0
- package/src/sync.ts +2 -2
|
@@ -522,10 +522,20 @@ function InlineAction({
|
|
|
522
522
|
);
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
-
/** The actions column's
|
|
526
|
-
|
|
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
|
-
|
|
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
|
|
550
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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:
|
package/src/sync/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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";
|