@eifi1/ui-kit 0.45.0 → 0.46.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.
Files changed (228) hide show
  1. package/README.md +23 -12
  2. package/dist/admin/admin-area-labels.d.ts +9 -0
  3. package/dist/admin/admin-area-labels.js +5 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +16 -1
  6. package/dist/admin/admin-overview.js +12 -1
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/mail-test-card.d.ts +3 -2
  9. package/dist/admin/mail-test-card.js +3 -1
  10. package/dist/admin/mail-test-card.js.map +1 -1
  11. package/dist/components/appearance-labels.d.ts +10 -0
  12. package/dist/components/appearance-labels.js +6 -1
  13. package/dist/components/appearance-labels.js.map +1 -1
  14. package/dist/components/data-table-column-settings.js +2 -2
  15. package/dist/components/data-table-column-settings.js.map +1 -1
  16. package/dist/components/data-table-head.js +2 -2
  17. package/dist/components/data-table-head.js.map +1 -1
  18. package/dist/components/data-table-row-actions.d.ts +18 -2
  19. package/dist/components/data-table-row-actions.js +86 -2
  20. package/dist/components/data-table-row-actions.js.map +1 -1
  21. package/dist/components/data-table-shared.js +9 -2
  22. package/dist/components/data-table-shared.js.map +1 -1
  23. package/dist/components/data-table.d.ts +43 -2
  24. package/dist/components/data-table.js +9 -4
  25. package/dist/components/data-table.js.map +1 -1
  26. package/dist/components/date-format-setting.d.ts +29 -0
  27. package/dist/components/date-format-setting.js +36 -0
  28. package/dist/components/date-format-setting.js.map +1 -0
  29. package/dist/components/swipeable-row.d.ts +8 -0
  30. package/dist/components/swipeable-row.js.map +1 -1
  31. package/dist/components/tabs.d.ts +3 -2
  32. package/dist/components/tabs.js +1 -1
  33. package/dist/components/tabs.js.map +1 -1
  34. package/dist/components/translation-review-editor.js +1 -0
  35. package/dist/components/translation-review-editor.js.map +1 -1
  36. package/dist/components/translation-review-labels.d.ts +5 -0
  37. package/dist/components/translation-review-labels.js +2 -0
  38. package/dist/components/translation-review-labels.js.map +1 -1
  39. package/dist/components/translation-review.d.ts +18 -8
  40. package/dist/components/translation-review.js +64 -7
  41. package/dist/components/translation-review.js.map +1 -1
  42. package/dist/components/write-lock-labels.js +2 -1
  43. package/dist/components/write-lock-labels.js.map +1 -1
  44. package/dist/components/write-lock.d.ts +33 -3
  45. package/dist/components/write-lock.js +9 -0
  46. package/dist/components/write-lock.js.map +1 -1
  47. package/dist/data-table.d.ts +1 -1
  48. package/dist/data-table.js.map +1 -1
  49. package/dist/eslint/index.js +1 -1
  50. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  51. package/dist/feedback/feedback-row-detail.js +17 -26
  52. package/dist/feedback/feedback-row-detail.js.map +1 -1
  53. package/dist/feedback/feedback-swipe.d.ts +18 -0
  54. package/dist/feedback/feedback-swipe.js +10 -1
  55. package/dist/feedback/feedback-swipe.js.map +1 -1
  56. package/dist/i18n/german.js +41 -5
  57. package/dist/i18n/german.js.map +1 -1
  58. package/dist/i18n/kit-labels.d.ts +12 -1
  59. package/dist/i18n/kit-labels.js +8 -2
  60. package/dist/i18n/kit-labels.js.map +1 -1
  61. package/dist/i18n/locales/en.js +40 -6
  62. package/dist/i18n/locales/en.js.map +2 -2
  63. package/dist/i18n/locales/es.js +41 -5
  64. package/dist/i18n/locales/es.js.map +1 -1
  65. package/dist/i18n/locales/fr.js +41 -5
  66. package/dist/i18n/locales/fr.js.map +1 -1
  67. package/dist/i18n/locales/hu.js +41 -5
  68. package/dist/i18n/locales/hu.js.map +1 -1
  69. package/dist/i18n/locales/it.js +41 -5
  70. package/dist/i18n/locales/it.js.map +1 -1
  71. package/dist/i18n/locales/zh.js +41 -5
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.js +3 -1
  74. package/dist/i18n/review.js.map +1 -1
  75. package/dist/index.d.ts +15 -2
  76. package/dist/index.js +35 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/lib/date-format.d.ts +81 -0
  79. package/dist/lib/date-format.js +56 -0
  80. package/dist/lib/date-format.js.map +1 -0
  81. package/dist/lib/format.d.ts +3 -0
  82. package/dist/lib/format.js +5 -4
  83. package/dist/lib/format.js.map +1 -1
  84. package/dist/notifications/device-zone-report.d.ts +27 -0
  85. package/dist/notifications/device-zone-report.js +24 -0
  86. package/dist/notifications/device-zone-report.js.map +1 -0
  87. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  88. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  89. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  90. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  91. package/dist/notifications/feedback-notification-types.js +2 -1
  92. package/dist/notifications/feedback-notification-types.js.map +1 -1
  93. package/dist/notifications/notification-bell.d.ts +33 -0
  94. package/dist/notifications/notification-bell.js +74 -0
  95. package/dist/notifications/notification-bell.js.map +1 -0
  96. package/dist/notifications/notification-inbox.d.ts +77 -0
  97. package/dist/notifications/notification-inbox.js +206 -0
  98. package/dist/notifications/notification-inbox.js.map +1 -0
  99. package/dist/notifications/notification-types-setting.d.ts +6 -4
  100. package/dist/notifications/notification-types-setting.js +103 -45
  101. package/dist/notifications/notification-types-setting.js.map +1 -1
  102. package/dist/notifications/notification-types.d.ts +19 -7
  103. package/dist/notifications/notifications-labels.d.ts +41 -7
  104. package/dist/notifications/notifications-labels.js +18 -1
  105. package/dist/notifications/notifications-labels.js.map +1 -1
  106. package/dist/notifications/push-devices-setting.d.ts +20 -3
  107. package/dist/notifications/push-devices-setting.js +93 -33
  108. package/dist/notifications/push-devices-setting.js.map +1 -1
  109. package/dist/notifications/push-worker.d.ts +108 -0
  110. package/dist/notifications/push-worker.js +62 -0
  111. package/dist/notifications/push-worker.js.map +1 -0
  112. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  113. package/dist/notifications/quiet-hours-setting.js +9 -41
  114. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  115. package/dist/notifications/time-zone-select.d.ts +5 -2
  116. package/dist/notifications/time-zone-select.js.map +1 -1
  117. package/dist/notifications/use-push-device.d.ts +4 -17
  118. package/dist/notifications/use-push-device.js +7 -15
  119. package/dist/notifications/use-push-device.js.map +1 -1
  120. package/dist/offline/local-first.d.ts +33 -0
  121. package/dist/offline/local-first.js +39 -0
  122. package/dist/offline/local-first.js.map +1 -0
  123. package/dist/offline/query-persistence.d.ts +26 -1
  124. package/dist/offline/query-persistence.js +27 -23
  125. package/dist/offline/query-persistence.js.map +1 -1
  126. package/dist/offline/service-worker-updates.d.ts +46 -0
  127. package/dist/offline/service-worker-updates.js +39 -0
  128. package/dist/offline/service-worker-updates.js.map +1 -0
  129. package/dist/offline.d.ts +5 -2
  130. package/dist/offline.js +11 -2
  131. package/dist/offline.js.map +1 -1
  132. package/dist/push-worker.d.ts +2 -0
  133. package/dist/push-worker.js +14 -0
  134. package/dist/push-worker.js.map +1 -0
  135. package/dist/shell/account-menu-text-size.d.ts +33 -0
  136. package/dist/shell/account-menu-text-size.js +51 -0
  137. package/dist/shell/account-menu-text-size.js.map +1 -0
  138. package/dist/shell/top-bar-actions.d.ts +6 -2
  139. package/dist/shell/top-bar-actions.js +1 -0
  140. package/dist/shell/top-bar-actions.js.map +1 -1
  141. package/dist/shell.d.ts +2 -0
  142. package/dist/shell.js +2 -0
  143. package/dist/shell.js.map +1 -1
  144. package/dist/sync/db.d.ts +76 -0
  145. package/dist/sync/db.js +24 -0
  146. package/dist/sync/db.js.map +1 -0
  147. package/dist/sync/engine.d.ts +124 -0
  148. package/dist/sync/engine.js +474 -0
  149. package/dist/sync/engine.js.map +1 -0
  150. package/dist/sync/hooks.d.ts +30 -0
  151. package/dist/sync/hooks.js +67 -0
  152. package/dist/sync/hooks.js.map +1 -0
  153. package/dist/sync/testing.d.ts +28 -0
  154. package/dist/sync/testing.js +41 -0
  155. package/dist/sync/testing.js.map +1 -0
  156. package/dist/sync/wire.d.ts +50 -0
  157. package/dist/sync/wire.js +2 -0
  158. package/dist/sync/wire.js.map +1 -0
  159. package/dist/sync.d.ts +7 -0
  160. package/dist/sync.js +22 -0
  161. package/dist/sync.js.map +1 -0
  162. package/dist/tour/tour-labels.js +1 -0
  163. package/dist/tour/tour-labels.js.map +1 -1
  164. package/dist/tour/tour.d.ts +30 -0
  165. package/dist/tour/tour.js +50 -6
  166. package/dist/tour/tour.js.map +1 -1
  167. package/package.json +19 -1
  168. package/src/admin/admin-area-labels.ts +13 -0
  169. package/src/admin/admin-overview.tsx +37 -2
  170. package/src/admin/mail-test-card.tsx +7 -3
  171. package/src/components/appearance-labels.ts +16 -0
  172. package/src/components/data-table-column-settings.tsx +2 -2
  173. package/src/components/data-table-head.tsx +2 -2
  174. package/src/components/data-table-row-actions.tsx +118 -2
  175. package/src/components/data-table-shared.tsx +21 -2
  176. package/src/components/data-table.tsx +51 -6
  177. package/src/components/date-format-setting.tsx +64 -0
  178. package/src/components/swipeable-row.tsx +8 -0
  179. package/src/components/tabs.tsx +4 -3
  180. package/src/components/translation-review-editor.tsx +2 -0
  181. package/src/components/translation-review-labels.ts +7 -0
  182. package/src/components/translation-review.tsx +102 -14
  183. package/src/components/write-lock-labels.ts +1 -0
  184. package/src/components/write-lock.tsx +36 -3
  185. package/src/data-table.ts +1 -0
  186. package/src/feedback/feedback-row-detail.tsx +18 -23
  187. package/src/feedback/feedback-swipe.tsx +28 -1
  188. package/src/i18n/german.ts +44 -4
  189. package/src/i18n/kit-labels.tsx +18 -1
  190. package/src/i18n/locales/en.ts +1 -0
  191. package/src/i18n/locales/es.ts +44 -4
  192. package/src/i18n/locales/fr.ts +44 -4
  193. package/src/i18n/locales/hu.ts +44 -4
  194. package/src/i18n/locales/it.ts +44 -4
  195. package/src/i18n/locales/zh.ts +44 -2
  196. package/src/i18n/review.ts +3 -1
  197. package/src/index.ts +42 -0
  198. package/src/lib/date-format.ts +151 -0
  199. package/src/lib/format.ts +10 -4
  200. package/src/notifications/device-zone-report.ts +54 -0
  201. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  202. package/src/notifications/feedback-notification-types.ts +12 -5
  203. package/src/notifications/notification-bell.tsx +116 -0
  204. package/src/notifications/notification-inbox.tsx +304 -0
  205. package/src/notifications/notification-types-setting.tsx +151 -55
  206. package/src/notifications/notification-types.ts +20 -8
  207. package/src/notifications/notifications-labels.ts +69 -11
  208. package/src/notifications/push-devices-setting.tsx +123 -34
  209. package/src/notifications/push-worker.ts +160 -0
  210. package/src/notifications/quiet-hours-setting.tsx +41 -58
  211. package/src/notifications/time-zone-select.tsx +5 -2
  212. package/src/notifications/use-push-device.ts +20 -33
  213. package/src/offline/local-first.ts +76 -0
  214. package/src/offline/query-persistence.ts +57 -24
  215. package/src/offline/service-worker-updates.ts +81 -0
  216. package/src/offline.ts +7 -1
  217. package/src/push-worker.ts +25 -0
  218. package/src/shell/account-menu-text-size.tsx +77 -0
  219. package/src/shell/top-bar-actions.tsx +6 -1
  220. package/src/shell.ts +3 -0
  221. package/src/sync/db.ts +103 -0
  222. package/src/sync/engine.ts +699 -0
  223. package/src/sync/hooks.ts +93 -0
  224. package/src/sync/testing.ts +73 -0
  225. package/src/sync/wire.ts +61 -0
  226. package/src/sync.ts +29 -0
  227. package/src/tour/tour-labels.ts +1 -0
  228. package/src/tour/tour.tsx +100 -7
@@ -3,7 +3,6 @@ import type { ComponentPropsWithRef, ReactNode } from "react";
3
3
 
4
4
  import type { LabelOverride } from "../i18n/kit-labels";
5
5
  import { cn } from "../lib/cn";
6
- import { Button } from "../components/button";
7
6
  import { Card } from "../components/card";
8
7
  import { TimeInput } from "../components/time-input";
9
8
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
@@ -13,9 +12,9 @@ import { settle, useMounted } from "../account/account-parts";
13
12
  import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
14
13
  import type { NotificationsLabels } from "./notifications-labels";
15
14
  import type { NotificationSettingsPatch } from "./notification-types";
16
- import { deviceTimeZone, TimeZoneSelect, zoneName } from "./time-zone-select";
15
+ import { zoneName } from "./time-zone-select";
17
16
 
18
- /** The zone the server applies while the account has none (§2.6): every app's. */
17
+ /** The zone the server applies while no device ever reported one (§10.3): every app's. */
19
18
  const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
20
19
 
21
20
  export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onChange"> {
@@ -23,17 +22,29 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
23
22
  start: string | null;
24
23
  /** The `GET`'s `quiet_hours_end`. */
25
24
  end: string | null;
26
- /** The `GET`'s `timezone`, an IANA name, or `null` (the app's zone applies). */
27
- timezone: string | null;
25
+ /** The zone the 0.36 card showed, an IANA name, or `null`.
26
+ * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
27
+ * Shown read-only while `zone` is left out; the card no longer offers a picker. */
28
+ timezone?: string | null;
28
29
  /**
29
- * Save one change — `PATCH /notifications/settings`: a time (`null` when cleared), with
30
- * the device's zone the first time one is entered while `timezone` is `null`; or
31
- * `{timezone}`. The field shows the new value while the call runs and goes back if it
30
+ * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
31
+ * `device_timezone`, the zone of the device last used; `null` while no device has
32
+ * reported one, and the card then names `fallbackZone`, the zone the server applies.
33
+ * Shown read-only once a time is set ("In your time zone (Europe / Zurich), which follows
34
+ * the device you last used."). No picker: the zone is never chosen.
35
+ */
36
+ zone?: string | null;
37
+ /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs
38
+ * the notifications tick); the description says so. Default `false`: dropped. */
39
+ held?: boolean;
40
+ /**
41
+ * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
42
+ * Never a zone. The field shows the new value while the call runs and goes back if it
32
43
  * rejects.
33
44
  */
34
45
  onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
35
- /** The zone shown, and the one the server applies, while `timezone` is `null`.
36
- * Default `"Europe/Zurich"`, every app's (decision 6). */
46
+ /** The zone named while neither `zone` nor `timezone` names one: the one the server
47
+ * applies. Default `"Europe/Zurich"`, every app's. */
37
48
  fallbackZone?: string;
38
49
  /** The app keeps an inbox, and the description says a held push still reaches it. */
39
50
  inbox?: boolean;
@@ -43,30 +54,32 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
43
54
  labels?: LabelOverride<NotificationsLabels>;
44
55
  }
45
56
 
46
- type QuietField = "quiet_hours_start" | "quiet_hours_end" | "timezone";
57
+ type QuietField = "quiet_hours_start" | "quiet_hours_end";
47
58
 
48
59
  /**
49
60
  * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local
50
61
  * times in the account's zone, which may cross midnight, when no push of a machine-sent
51
- * type is sent. A held push is dropped, not delayed; the inbox row and the mail go as
52
- * usual. keksdose's block, as a card of its own.
62
+ * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the
63
+ * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its
64
+ * own.
65
+ *
66
+ * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic
67
+ * only** — the zone of the device last used, else `Europe/Zurich`. The card names it
68
+ * read-only and never sends one; there is no picker and no "Use it".
53
69
  *
54
70
  * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
55
71
  * one is set the card says so: the window fails open until both are.
56
- * - **The zone** shows while a time is set. The first time entered while the account has
57
- * none sends this device's zone with it, so "22:00" means the person's 22:00
58
- * (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows
59
- * `fallbackZone` — the zone the server then applies — never the device's (keksdose's
60
- * card showed the device's zone while its server applied UTC).
61
- * - **"This device is in …"** with "Use it" when the device's zone is not the one in
62
- * effect. Never written on its own: a zone is written by a person's pick (§2.6).
72
+ * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
73
+ * `fallbackZone`.
63
74
  *
64
75
  * Under a write lock the fields are `commit` controls exempt from billing.
65
76
  */
66
77
  export function QuietHoursSetting({
67
78
  start,
68
79
  end,
69
- timezone,
80
+ timezone = null,
81
+ zone,
82
+ held = false,
70
83
  onChange,
71
84
  fallbackZone = DEFAULT_FALLBACK_ZONE,
72
85
  inbox = false,
@@ -77,7 +90,7 @@ export function QuietHoursSetting({
77
90
  ...rest
78
91
  }: QuietHoursSettingProps) {
79
92
  const labels = useNotificationsLabels(labelsProp);
80
- const overlay = useWriteOverlay<string | null>(`${start}|${end}|${timezone}`);
93
+ const overlay = useWriteOverlay<string | null>(`${start}|${end}`);
81
94
  const [failure, setFailure] = useState<ReactNode>(null);
82
95
  const mounted = useMounted();
83
96
  const id = useId();
@@ -86,9 +99,7 @@ export function QuietHoursSetting({
86
99
  overlay.has(field) ? (overlay.get(field) ?? null) : server;
87
100
  const startShown = shown("quiet_hours_start", start);
88
101
  const endShown = shown("quiet_hours_end", end);
89
- const zoneShown = shown("timezone", timezone);
90
- const effectiveZone = zoneShown ?? fallbackZone;
91
- const device = deviceTimeZone();
102
+ const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;
92
103
 
93
104
  const save = (patch: NotificationSettingsPatch) => {
94
105
  const fields = Object.keys(patch) as QuietField[];
@@ -109,9 +120,7 @@ export function QuietHoursSetting({
109
120
  const saveTime = (field: "quiet_hours_start" | "quiet_hours_end", value: string) => {
110
121
  const next = value || null;
111
122
  if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
112
- const patch: NotificationSettingsPatch = { [field]: next };
113
- if (next && zoneShown === null) patch.timezone = device;
114
- save(patch);
123
+ save({ [field]: next });
115
124
  };
116
125
 
117
126
  const anySet = Boolean(startShown || endShown);
@@ -121,7 +130,7 @@ export function QuietHoursSetting({
121
130
  <Card {...rest} className={cn("p-4 space-y-3", className)}>
122
131
  <div>
123
132
  <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>
124
- <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>
133
+ <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>
125
134
  </div>
126
135
  <div className="flex flex-wrap items-end gap-3">
127
136
  <TimeInput
@@ -149,35 +158,9 @@ export function QuietHoursSetting({
149
158
  </p>
150
159
  )}
151
160
  {anySet && (
152
- <div className="space-y-2">
153
- <TimeZoneSelect
154
- id={`${id}-zone`}
155
- value={effectiveZone}
156
- disabled={disabled}
157
- commit={COMMIT_EXCEPT_BILLING}
158
- onChange={(zone) => save({ timezone: zone })}
159
- className="max-w-72"
160
- labels={labelsProp}
161
- />
162
- {device !== effectiveZone && (
163
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
164
- <span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>
165
- {labels.deviceZone(zoneName(device, labels.regions))}
166
- </span>
167
- <Button
168
- type="button"
169
- size="sm"
170
- variant="link"
171
- disabled={disabled}
172
- commit={COMMIT_EXCEPT_BILLING}
173
- aria-describedby={`${id}-device`}
174
- onClick={() => save({ timezone: device })}
175
- >
176
- {labels.useDeviceZone}
177
- </Button>
178
- </div>
179
- )}
180
- </div>
161
+ <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone="">
162
+ {labels.quietZone(zoneName(accountZone, labels.regions))}
163
+ </p>
181
164
  )}
182
165
  <FailureLine>{failure}</FailureLine>
183
166
  </Card>
@@ -10,8 +10,8 @@ import type { NotificationRegionLabels, NotificationsLabels } from "./notificati
10
10
 
11
11
  /**
12
12
  * This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
13
- * the zone quiet hours are entered in the first time (§2.6), and the one the card offers
14
- * when it differs from the account's.
13
+ * the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
14
+ * only).
15
15
  */
16
16
  export function deviceTimeZone(): string {
17
17
  try {
@@ -40,6 +40,7 @@ function cityOf(rest: string): string {
40
40
  * rather than trailing the last group as if it were Pacific. `current` is kept even when
41
41
  * the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
42
42
  * own value would show an empty field.
43
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
43
44
  */
44
45
  export function timeZoneOptions(
45
46
  current: string,
@@ -73,6 +74,7 @@ export function zoneName(zone: string, regions: NotificationRegionLabels): strin
73
74
  return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;
74
75
  }
75
76
 
77
+ /** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */
76
78
  export interface TimeZoneSelectProps {
77
79
  /** The IANA zone. Not clearable: a zone can be changed, never emptied. */
78
80
  value: string;
@@ -95,6 +97,7 @@ export interface TimeZoneSelectProps {
95
97
  * a search box instead of a 400-row wheel. Rows are the city under its region, the region
96
98
  * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
97
99
  * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
100
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
98
101
  */
99
102
  export function TimeZoneSelect({
100
103
  value,
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { useMediaQuery } from "../hooks/use-media-query";
4
4
  import { httpStatusOf } from "../auth/auth-errors";
5
+ import { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from "./push-worker";
6
+ import type { PushSubscriptionInput } from "./push-worker";
7
+
8
+ export { PUSH_SUBSCRIPTION_CHANGED };
9
+ export type { PushSubscriptionInput };
5
10
 
6
11
  /**
7
12
  * This browser's Web Push subscription for the signed-in account
@@ -34,13 +39,6 @@ export class PushDeviceError extends Error {
34
39
  }
35
40
  }
36
41
 
37
- /** What `register` sends: `POST /notifications/devices` (§3.5). */
38
- export interface PushSubscriptionInput {
39
- endpoint: string;
40
- keys: { p256dh: string; auth: string };
41
- user_agent: string | null;
42
- }
43
-
44
42
  export interface UsePushDeviceOptions {
45
43
  /** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
46
44
  pushKey: () => Promise<string>;
@@ -92,14 +90,15 @@ export interface PushDevice {
92
90
  sendTest: () => Promise<{ sent: number }>;
93
91
  }
94
92
 
95
- /**
96
- * The window event every mounted {@link usePushDevice} re-reads its state on. Fired by
97
- * every enable and disable, and by `PushDevicesSetting` when it removes this device — so
98
- * an app's own card on the same hook (keksdose's check-up) stays in step. The hook that
99
- * fires it does not re-read: it knows what it changed. Namespaced for the kit;
100
- * keksdose's was `"keksdose:push-subscription-changed"`.
93
+ /*
94
+ * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted
95
+ * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by
96
+ * `PushDevicesSetting` when it removes this device — so an app's own card on the same
97
+ * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it
98
+ * knows what it changed. Since 0.46 also the message a service worker posts after a
99
+ * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced
100
+ * for the kit; keksdose's was `"keksdose:push-subscription-changed"`.
101
101
  */
102
- export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
103
102
 
104
103
  /** Tell every {@link usePushDevice} that this browser's subscription changed. */
105
104
  export function notifyPushSubscriptionChanged(): void {
@@ -151,25 +150,6 @@ async function activeRegistration(): Promise<ServiceWorkerRegistration> {
151
150
  return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
152
151
  }
153
152
 
154
- /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
155
- function keyBytes(key: string): Uint8Array<ArrayBuffer> {
156
- const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
157
- const raw = atob(base64);
158
- const bytes = new Uint8Array(new ArrayBuffer(raw.length));
159
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
160
- return bytes;
161
- }
162
-
163
- /** A subscription's register body, or `null` when the browser handed back an incomplete
164
- * one. */
165
- function inputOf(subscription: PushSubscription): PushSubscriptionInput | null {
166
- const json = subscription.toJSON();
167
- const p256dh = json.keys?.p256dh;
168
- const auth = json.keys?.auth;
169
- if (!json.endpoint || !p256dh || !auth) return null;
170
- return { endpoint: json.endpoint, keys: { p256dh, auth }, user_agent: navigator.userAgent || null };
171
- }
172
-
173
153
  const defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;
174
154
 
175
155
  /**
@@ -289,11 +269,18 @@ export function usePushDevice(options: UsePushDeviceOptions): PushDevice {
289
269
  const heard = () => {
290
270
  if (!announcing.current) void sync();
291
271
  };
272
+ // The worker's word after it re-registered a rotated subscription (0.46).
273
+ const fromWorker = (event: MessageEvent) => {
274
+ if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();
275
+ };
276
+ const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;
292
277
  void sync();
293
278
  window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
279
+ worker?.addEventListener?.("message", fromWorker);
294
280
  return () => {
295
281
  cancelled = true;
296
282
  window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
283
+ worker?.removeEventListener?.("message", fromWorker);
297
284
  };
298
285
  }, [supported]);
299
286
 
@@ -0,0 +1,76 @@
1
+ import { isTransientRefreshFailure } from "../lib/session-refresh";
2
+
3
+ /*
4
+ * The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a
5
+ * page and a sync engine both need, with no store behind them. One module that both
6
+ * `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads
7
+ * `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports
8
+ * no package at all (packaging-contract.test.ts holds that).
9
+ */
10
+
11
+ /**
12
+ * An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past
13
+ * `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).
14
+ *
15
+ * `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock
16
+ * with the server's. A row the server just wrote carries the server's time, so an edit
17
+ * stamped by a clock a second slow would lose and come straight back. The millisecond is
18
+ * enough although the server keeps microseconds: `Date.parse` truncates them.
19
+ */
20
+ export function stampAfter(previous: unknown): string {
21
+ const now = Date.now();
22
+ const prior = typeof previous === "string" ? Date.parse(previous) : Number.NaN;
23
+ return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
24
+ }
25
+
26
+ /**
27
+ * The connection, not the server, failed: the request had no answer (an axios-like error
28
+ * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as
29
+ * passing because the server was out of reach ({@link isTransientRefreshFailure}). A real
30
+ * HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.
31
+ */
32
+ export function isOfflineError(err: unknown): boolean {
33
+ if (isTransientRefreshFailure(err)) return true;
34
+ if (typeof err !== "object" || err === null) return false;
35
+ const bag = err as { isAxiosError?: unknown; response?: unknown };
36
+ if (bag.isAxiosError === true || "response" in bag) return bag.response == null;
37
+ return err instanceof TypeError;
38
+ }
39
+
40
+ /**
41
+ * Read from the server, and from the device's copy only when the connection failed
42
+ * ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer
43
+ * before it is returned (to refresh the copy, say); a throw from it counts as a failure of
44
+ * the online path. The plain form of keksdose's net.ts:58, without its sealed-household
45
+ * and cold-start branches (those stay keksdose's).
46
+ */
47
+ export async function readLocalFirst<T>(
48
+ online: () => Promise<T>,
49
+ offline: () => Promise<T>,
50
+ onOnline?: (result: T) => void | Promise<void>,
51
+ ): Promise<T> {
52
+ try {
53
+ const result = await online();
54
+ await onOnline?.(result);
55
+ return result;
56
+ } catch (err) {
57
+ if (!isOfflineError(err)) throw err;
58
+ return offline();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Write through the server first (`rest`), and queue the change on the device (`queue`)
64
+ * only when the connection failed; any other failure throws, nothing queued. Online writes
65
+ * stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both
66
+ * paths, so a REST create that timed out after the server applied it is not created twice
67
+ * (D16).
68
+ */
69
+ export async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {
70
+ try {
71
+ return await rest();
72
+ } catch (err) {
73
+ if (!isOfflineError(err)) throw err;
74
+ return queue();
75
+ }
76
+ }
@@ -71,6 +71,14 @@ export interface QueryPersistenceOptions {
71
71
  storage?: QueryPersistenceStorage;
72
72
  /** Default 1000 ms, as keksdose. */
73
73
  throttleTime?: number;
74
+ /**
75
+ * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`
76
+ * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).
77
+ * `false` keeps every mutation off the disk, and drops any an older build wrote on
78
+ * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie
79
+ * outbox), instead of overriding `dehydrateOptions` by hand.
80
+ */
81
+ persistMutations?: boolean;
74
82
  }
75
83
 
76
84
  /**
@@ -112,6 +120,50 @@ const idbStorage: QueryPersistenceStorage = {
112
120
  keys: () => keys(),
113
121
  };
114
122
 
123
+ export interface RemoveStaleQueryCopiesOptions {
124
+ /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */
125
+ storage?: QueryPersistenceStorage;
126
+ /** One key to spare whatever its age — the signed-in account's own
127
+ * (`QueryPersistence.removeStale` passes its key). */
128
+ keep?: string;
129
+ }
130
+
131
+ /**
132
+ * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)
133
+ * older than `maxAge`, with no persistence at hand — for the sign-in page, where no
134
+ * account is known. A session that ended with no user known (a refresh token refused at
135
+ * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without
136
+ * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh
137
+ * caches stay; an unreadable one is stale. Never throws.
138
+ * {@link QueryPersistence.removeStale} is this with `keep` = its own key.
139
+ */
140
+ export async function removeStaleQueryCopies(
141
+ app: string,
142
+ maxAge: number,
143
+ { storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},
144
+ ): Promise<void> {
145
+ const prefix = `${app}-query-cache-`;
146
+ try {
147
+ const now = Date.now();
148
+ for (const k of await storage.keys()) {
149
+ if (typeof k !== "string" || !k.startsWith(prefix) || k === keep) continue;
150
+ let timestamp = 0;
151
+ try {
152
+ const raw = await storage.getItem(k);
153
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
154
+ if (parsed && typeof parsed === "object" && typeof (parsed as PersistedClient).timestamp === "number") {
155
+ timestamp = (parsed as PersistedClient).timestamp;
156
+ }
157
+ } catch {
158
+ /* unreadable: stale */
159
+ }
160
+ if (now - timestamp > maxAge) await storage.removeItem(k);
161
+ }
162
+ } catch {
163
+ /* best-effort */
164
+ }
165
+ }
166
+
115
167
  /**
116
168
  * True when `data` still means the same thing after JSON.stringify and JSON.parse —
117
169
  * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a
@@ -162,6 +214,7 @@ export function createQueryPersistence({
162
214
  maxAge,
163
215
  storage = idbStorage,
164
216
  throttleTime = 1000,
217
+ persistMutations = true,
165
218
  }: QueryPersistenceOptions): QueryPersistence {
166
219
  const prefix = `${app}-query-cache-`;
167
220
  const key = `${prefix}${user}${company === undefined || company === null || company === "" ? "" : `-${company}`}`;
@@ -204,9 +257,9 @@ export function createQueryPersistence({
204
257
  };
205
258
 
206
259
  /** The write side for mutations: TanStack's default (paused ones), minus any whose
207
- * variables carry a scrubbed field (S6). */
260
+ * variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */
208
261
  const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>
209
- mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
262
+ persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
210
263
 
211
264
  const withScrubbedData = (entry: unknown): unknown => {
212
265
  const query = entry as { state?: { data?: unknown } };
@@ -239,7 +292,7 @@ export function createQueryPersistence({
239
292
  timestamp,
240
293
  buster: stored,
241
294
  clientState: {
242
- mutations: Array.isArray(clientState.mutations)
295
+ mutations: persistMutations && Array.isArray(clientState.mutations)
243
296
  ? clientState.mutations.filter(
244
297
  (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),
245
298
  )
@@ -291,27 +344,7 @@ export function createQueryPersistence({
291
344
  }
292
345
  };
293
346
 
294
- const removeStale = async () => {
295
- try {
296
- const now = Date.now();
297
- for (const k of await storage.keys()) {
298
- if (typeof k !== "string" || !k.startsWith(prefix) || k === key) continue;
299
- let timestamp = 0;
300
- try {
301
- const raw = await storage.getItem(k);
302
- const parsed: unknown = raw ? JSON.parse(raw) : null;
303
- if (parsed && typeof parsed === "object" && typeof (parsed as PersistedClient).timestamp === "number") {
304
- timestamp = (parsed as PersistedClient).timestamp;
305
- }
306
- } catch {
307
- /* unreadable: stale */
308
- }
309
- if (now - timestamp > maxAge) await storage.removeItem(k);
310
- }
311
- } catch {
312
- /* best-effort */
313
- }
314
- };
347
+ const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });
315
348
 
316
349
  return {
317
350
  key,
@@ -0,0 +1,81 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * vite-plugin-pwa's `registerSW` from `virtual:pwa-register` (`registerType: "prompt"`) —
5
+ * the app passes it in, since a virtual module is the app's build's, not the kit's. It
6
+ * answers the `updateSW(reloadPage)` function.
7
+ */
8
+ export type RegisterServiceWorker = (options: {
9
+ onNeedRefresh?: () => void;
10
+ onOfflineReady?: () => void;
11
+ }) => (reloadPage?: boolean) => Promise<void>;
12
+
13
+ /** What {@link createServiceWorkerUpdates} answers: one store per app, a module constant. */
14
+ export interface ServiceWorkerUpdates {
15
+ /** Registers the service worker — once, however often it is called. Call it at start,
16
+ * before the router renders, so the sign-in pages get the offline shell too. */
17
+ start: () => void;
18
+ /** Whether a new version waits. */
19
+ needRefresh: () => boolean;
20
+ /** For `useSyncExternalStore`. */
21
+ subscribe: (listener: () => void) => () => void;
22
+ /** Activates the waiting version and reloads (`updateSW(true)`). Before `start`, nothing. */
23
+ reload: () => void;
24
+ /** The person closed the prompt: the version keeps waiting, the prompt stays down. */
25
+ dismiss: () => void;
26
+ }
27
+
28
+ /**
29
+ * 0.46 (kastlan K3, docs/local-first-harmonization.md §1.5 D8): the service worker
30
+ * registered ONCE AT START, and its "a new version waits" state as a small store the
31
+ * prompt reads wherever it lives. vite-plugin-pwa's `useRegisterSW` registers when the
32
+ * component that calls it mounts: an app whose {@link PwaUpdatePrompt} sits in its
33
+ * signed-in shell (it reads the tour state, the route) would register only after sign-in,
34
+ * and the sign-in pages would lose the offline shell on a first visit.
35
+ *
36
+ * ```ts
37
+ * // shared/pwa/sw-updates.ts
38
+ * import { registerSW } from "virtual:pwa-register";
39
+ * export const swUpdates = createServiceWorkerUpdates(registerSW);
40
+ * // main.tsx, before render: swUpdates.start();
41
+ * // the shell: <PwaUpdatePrompt {...useServiceWorkerUpdate(swUpdates)} hold={tourRunning} />
42
+ * ```
43
+ */
44
+ export function createServiceWorkerUpdates(register: RegisterServiceWorker): ServiceWorkerUpdates {
45
+ let waiting = false;
46
+ let update: ((reloadPage?: boolean) => Promise<void>) | null = null;
47
+ const listeners = new Set<() => void>();
48
+ const emit = () => listeners.forEach((listener) => listener());
49
+ const set = (value: boolean) => {
50
+ if (waiting === value) return;
51
+ waiting = value;
52
+ emit();
53
+ };
54
+ return {
55
+ start: () => {
56
+ if (update) return;
57
+ update = register({ onNeedRefresh: () => set(true) });
58
+ },
59
+ needRefresh: () => waiting,
60
+ subscribe: (listener) => {
61
+ listeners.add(listener);
62
+ return () => {
63
+ listeners.delete(listener);
64
+ };
65
+ },
66
+ reload: () => {
67
+ void update?.(true);
68
+ },
69
+ dismiss: () => set(false),
70
+ };
71
+ }
72
+
73
+ /** {@link PwaUpdatePrompt}'s props from the store: `needRefresh`, `onReload`, `onDismiss`. */
74
+ export function useServiceWorkerUpdate(updates: ServiceWorkerUpdates): {
75
+ needRefresh: boolean;
76
+ onReload: () => void;
77
+ onDismiss: () => void;
78
+ } {
79
+ const needRefresh = useSyncExternalStore(updates.subscribe, updates.needRefresh, () => false);
80
+ return { needRefresh, onReload: updates.reload, onDismiss: updates.dismiss };
81
+ }
package/src/offline.ts CHANGED
@@ -6,17 +6,21 @@
6
6
  // `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and
7
7
  // `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which
8
8
  // would make them everyone's install; the packaging contract test holds that line.
9
- export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe } from "./offline/query-persistence";
9
+ export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from "./offline/query-persistence";
10
10
  export type {
11
11
  QueryPersistence,
12
12
  QueryPersistenceOptions,
13
13
  QueryPersistenceStorage,
14
+ RemoveStaleQueryCopiesOptions,
14
15
  ScrubField,
15
16
  } from "./offline/query-persistence";
16
17
  export { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog";
17
18
  export type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from "./offline/sign-out-pending-dialog";
18
19
  export { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./offline/pwa-update-prompt";
19
20
  export type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from "./offline/pwa-update-prompt";
21
+ // 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.
22
+ export { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/service-worker-updates";
23
+ export type { RegisterServiceWorker, ServiceWorkerUpdates } from "./offline/service-worker-updates";
20
24
  export {
21
25
  SharedComputerChoice,
22
26
  DEFAULT_SHARED_COMPUTER_LABELS,
@@ -24,3 +28,5 @@ export {
24
28
  setSharedComputer,
25
29
  } from "./offline/shared-computer";
26
30
  export type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from "./offline/shared-computer";
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";
@@ -0,0 +1,25 @@
1
+ // `@eifi1/ui-kit/push-worker` — the service worker's half of Web Push (0.46,
2
+ // docs/notifications-harmonization.md §10.6 rule 5): re-registering a subscription the browser
3
+ // rotated. A worker bundle imports from here, never from the main barrel, which reaches React
4
+ // and the DOM components:
5
+ //
6
+ // // sw.ts
7
+ // import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
8
+ //
9
+ // Worker-safe: this entry reaches no package and no module but src/notifications/push-worker.ts
10
+ // (packaging-contract.test.ts, scripts/check-module-graph.mjs, scripts/verify-package.mjs).
11
+ // The main barrel keeps re-exporting the same names, so an existing import keeps working.
12
+ export {
13
+ PUSH_SUBSCRIPTION_CHANGED,
14
+ handlePushSubscriptionChange,
15
+ pushSubscriptionInput,
16
+ resubscribeAfterChange,
17
+ } from "./notifications/push-worker";
18
+ export type {
19
+ PushReplaceInput,
20
+ PushResubscribeOutcome,
21
+ PushSubscriptionChangeEventLike,
22
+ PushSubscriptionChangeOptions,
23
+ PushSubscriptionInput,
24
+ PushWorkerScope,
25
+ } from "./notifications/push-worker";