@eifi1/ui-kit 0.45.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 (238) hide show
  1. package/README.md +26 -14
  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/admin/user-roster.d.ts +6 -2
  12. package/dist/admin/user-roster.js +7 -3
  13. package/dist/admin/user-roster.js.map +1 -1
  14. package/dist/components/appearance-labels.d.ts +10 -0
  15. package/dist/components/appearance-labels.js +6 -1
  16. package/dist/components/appearance-labels.js.map +1 -1
  17. package/dist/components/data-table-column-settings.js +2 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-head.js +2 -2
  20. package/dist/components/data-table-head.js.map +1 -1
  21. package/dist/components/data-table-row-actions.d.ts +35 -2
  22. package/dist/components/data-table-row-actions.js +98 -5
  23. package/dist/components/data-table-row-actions.js.map +1 -1
  24. package/dist/components/data-table-shared.js +9 -2
  25. package/dist/components/data-table-shared.js.map +1 -1
  26. package/dist/components/data-table.d.ts +58 -2
  27. package/dist/components/data-table.js +11 -4
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/date-format-setting.d.ts +29 -0
  30. package/dist/components/date-format-setting.js +36 -0
  31. package/dist/components/date-format-setting.js.map +1 -0
  32. package/dist/components/row-actions.d.ts +12 -5
  33. package/dist/components/row-actions.js +11 -4
  34. package/dist/components/row-actions.js.map +1 -1
  35. package/dist/components/swipeable-row.d.ts +8 -0
  36. package/dist/components/swipeable-row.js.map +1 -1
  37. package/dist/components/tabs.d.ts +3 -2
  38. package/dist/components/tabs.js +1 -1
  39. package/dist/components/tabs.js.map +1 -1
  40. package/dist/components/translation-review-editor.js +1 -0
  41. package/dist/components/translation-review-editor.js.map +1 -1
  42. package/dist/components/translation-review-labels.d.ts +5 -0
  43. package/dist/components/translation-review-labels.js +2 -0
  44. package/dist/components/translation-review-labels.js.map +1 -1
  45. package/dist/components/translation-review.d.ts +18 -8
  46. package/dist/components/translation-review.js +67 -8
  47. package/dist/components/translation-review.js.map +1 -1
  48. package/dist/components/write-lock-labels.js +2 -1
  49. package/dist/components/write-lock-labels.js.map +1 -1
  50. package/dist/components/write-lock.d.ts +33 -3
  51. package/dist/components/write-lock.js +9 -0
  52. package/dist/components/write-lock.js.map +1 -1
  53. package/dist/data-table.d.ts +2 -1
  54. package/dist/data-table.js +2 -0
  55. package/dist/data-table.js.map +1 -1
  56. package/dist/eslint/index.js +1 -1
  57. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  58. package/dist/feedback/feedback-row-detail.js +17 -26
  59. package/dist/feedback/feedback-row-detail.js.map +1 -1
  60. package/dist/feedback/feedback-swipe.d.ts +18 -0
  61. package/dist/feedback/feedback-swipe.js +10 -1
  62. package/dist/feedback/feedback-swipe.js.map +1 -1
  63. package/dist/i18n/german.js +41 -5
  64. package/dist/i18n/german.js.map +1 -1
  65. package/dist/i18n/kit-labels.d.ts +12 -1
  66. package/dist/i18n/kit-labels.js +8 -2
  67. package/dist/i18n/kit-labels.js.map +1 -1
  68. package/dist/i18n/locales/en.js +40 -6
  69. package/dist/i18n/locales/en.js.map +2 -2
  70. package/dist/i18n/locales/es.js +41 -5
  71. package/dist/i18n/locales/es.js.map +1 -1
  72. package/dist/i18n/locales/fr.js +41 -5
  73. package/dist/i18n/locales/fr.js.map +1 -1
  74. package/dist/i18n/locales/hu.js +41 -5
  75. package/dist/i18n/locales/hu.js.map +1 -1
  76. package/dist/i18n/locales/it.js +41 -5
  77. package/dist/i18n/locales/it.js.map +1 -1
  78. package/dist/i18n/locales/zh.js +41 -5
  79. package/dist/i18n/locales/zh.js.map +1 -1
  80. package/dist/i18n/review.js +3 -1
  81. package/dist/i18n/review.js.map +1 -1
  82. package/dist/index.d.ts +17 -4
  83. package/dist/index.js +38 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/date-format.d.ts +81 -0
  86. package/dist/lib/date-format.js +56 -0
  87. package/dist/lib/date-format.js.map +1 -0
  88. package/dist/lib/format.d.ts +3 -0
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/notifications/device-zone-report.d.ts +34 -0
  92. package/dist/notifications/device-zone-report.js +31 -0
  93. package/dist/notifications/device-zone-report.js.map +1 -0
  94. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  95. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  96. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  97. package/dist/notifications/feedback-notification-types.d.ts +24 -5
  98. package/dist/notifications/feedback-notification-types.js +7 -2
  99. package/dist/notifications/feedback-notification-types.js.map +1 -1
  100. package/dist/notifications/notification-bell.d.ts +33 -0
  101. package/dist/notifications/notification-bell.js +74 -0
  102. package/dist/notifications/notification-bell.js.map +1 -0
  103. package/dist/notifications/notification-inbox.d.ts +86 -0
  104. package/dist/notifications/notification-inbox.js +206 -0
  105. package/dist/notifications/notification-inbox.js.map +1 -0
  106. package/dist/notifications/notification-types-setting.d.ts +6 -4
  107. package/dist/notifications/notification-types-setting.js +103 -45
  108. package/dist/notifications/notification-types-setting.js.map +1 -1
  109. package/dist/notifications/notification-types.d.ts +19 -7
  110. package/dist/notifications/notifications-labels.d.ts +44 -8
  111. package/dist/notifications/notifications-labels.js +18 -1
  112. package/dist/notifications/notifications-labels.js.map +1 -1
  113. package/dist/notifications/push-devices-setting.d.ts +20 -3
  114. package/dist/notifications/push-devices-setting.js +93 -33
  115. package/dist/notifications/push-devices-setting.js.map +1 -1
  116. package/dist/notifications/push-worker.d.ts +108 -0
  117. package/dist/notifications/push-worker.js +62 -0
  118. package/dist/notifications/push-worker.js.map +1 -0
  119. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  120. package/dist/notifications/quiet-hours-setting.js +9 -41
  121. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  122. package/dist/notifications/time-zone-select.d.ts +8 -4
  123. package/dist/notifications/time-zone-select.js +2 -7
  124. package/dist/notifications/time-zone-select.js.map +1 -1
  125. package/dist/notifications/use-push-device.d.ts +4 -17
  126. package/dist/notifications/use-push-device.js +7 -15
  127. package/dist/notifications/use-push-device.js.map +1 -1
  128. package/dist/offline/local-first.d.ts +42 -0
  129. package/dist/offline/local-first.js +53 -0
  130. package/dist/offline/local-first.js.map +1 -0
  131. package/dist/offline/query-persistence.d.ts +26 -1
  132. package/dist/offline/query-persistence.js +27 -23
  133. package/dist/offline/query-persistence.js.map +1 -1
  134. package/dist/offline/service-worker-updates.d.ts +46 -0
  135. package/dist/offline/service-worker-updates.js +39 -0
  136. package/dist/offline/service-worker-updates.js.map +1 -0
  137. package/dist/offline.d.ts +5 -2
  138. package/dist/offline.js +12 -2
  139. package/dist/offline.js.map +1 -1
  140. package/dist/push-worker.d.ts +2 -0
  141. package/dist/push-worker.js +14 -0
  142. package/dist/push-worker.js.map +1 -0
  143. package/dist/shell/account-menu-text-size.d.ts +33 -0
  144. package/dist/shell/account-menu-text-size.js +51 -0
  145. package/dist/shell/account-menu-text-size.js.map +1 -0
  146. package/dist/shell/top-bar-actions.d.ts +6 -2
  147. package/dist/shell/top-bar-actions.js +1 -0
  148. package/dist/shell/top-bar-actions.js.map +1 -1
  149. package/dist/shell.d.ts +2 -0
  150. package/dist/shell.js +2 -0
  151. package/dist/shell.js.map +1 -1
  152. package/dist/sync/db.d.ts +93 -0
  153. package/dist/sync/db.js +33 -0
  154. package/dist/sync/db.js.map +1 -0
  155. package/dist/sync/engine.d.ts +126 -0
  156. package/dist/sync/engine.js +474 -0
  157. package/dist/sync/engine.js.map +1 -0
  158. package/dist/sync/hooks.d.ts +35 -0
  159. package/dist/sync/hooks.js +67 -0
  160. package/dist/sync/hooks.js.map +1 -0
  161. package/dist/sync/testing.d.ts +28 -0
  162. package/dist/sync/testing.js +41 -0
  163. package/dist/sync/testing.js.map +1 -0
  164. package/dist/sync/wire.d.ts +50 -0
  165. package/dist/sync/wire.js +2 -0
  166. package/dist/sync/wire.js.map +1 -0
  167. package/dist/sync.d.ts +7 -0
  168. package/dist/sync.js +25 -0
  169. package/dist/sync.js.map +1 -0
  170. package/dist/tour/tour-labels.js +1 -0
  171. package/dist/tour/tour-labels.js.map +1 -1
  172. package/dist/tour/tour.d.ts +30 -0
  173. package/dist/tour/tour.js +50 -6
  174. package/dist/tour/tour.js.map +1 -1
  175. package/package.json +19 -1
  176. package/src/admin/admin-area-labels.ts +13 -0
  177. package/src/admin/admin-overview.tsx +37 -2
  178. package/src/admin/mail-test-card.tsx +7 -3
  179. package/src/admin/user-roster.tsx +12 -3
  180. package/src/components/appearance-labels.ts +16 -0
  181. package/src/components/data-table-column-settings.tsx +2 -2
  182. package/src/components/data-table-head.tsx +2 -2
  183. package/src/components/data-table-row-actions.tsx +145 -5
  184. package/src/components/data-table-shared.tsx +21 -2
  185. package/src/components/data-table.tsx +68 -6
  186. package/src/components/date-format-setting.tsx +64 -0
  187. package/src/components/row-actions.tsx +33 -9
  188. package/src/components/swipeable-row.tsx +8 -0
  189. package/src/components/tabs.tsx +4 -3
  190. package/src/components/translation-review-editor.tsx +2 -0
  191. package/src/components/translation-review-labels.ts +7 -0
  192. package/src/components/translation-review.tsx +105 -15
  193. package/src/components/write-lock-labels.ts +1 -0
  194. package/src/components/write-lock.tsx +36 -3
  195. package/src/data-table.ts +4 -0
  196. package/src/feedback/feedback-row-detail.tsx +18 -23
  197. package/src/feedback/feedback-swipe.tsx +28 -1
  198. package/src/i18n/german.ts +44 -4
  199. package/src/i18n/kit-labels.tsx +18 -1
  200. package/src/i18n/locales/en.ts +1 -0
  201. package/src/i18n/locales/es.ts +44 -4
  202. package/src/i18n/locales/fr.ts +44 -4
  203. package/src/i18n/locales/hu.ts +44 -4
  204. package/src/i18n/locales/it.ts +44 -4
  205. package/src/i18n/locales/zh.ts +44 -2
  206. package/src/i18n/review.ts +3 -1
  207. package/src/index.ts +50 -1
  208. package/src/lib/date-format.ts +151 -0
  209. package/src/lib/format.ts +10 -4
  210. package/src/notifications/device-zone-report.ts +67 -0
  211. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  212. package/src/notifications/feedback-notification-types.ts +30 -5
  213. package/src/notifications/notification-bell.tsx +116 -0
  214. package/src/notifications/notification-inbox.tsx +314 -0
  215. package/src/notifications/notification-types-setting.tsx +151 -55
  216. package/src/notifications/notification-types.ts +20 -8
  217. package/src/notifications/notifications-labels.ts +72 -12
  218. package/src/notifications/push-devices-setting.tsx +123 -34
  219. package/src/notifications/push-worker.ts +160 -0
  220. package/src/notifications/quiet-hours-setting.tsx +41 -58
  221. package/src/notifications/time-zone-select.tsx +9 -10
  222. package/src/notifications/use-push-device.ts +20 -33
  223. package/src/offline/local-first.ts +100 -0
  224. package/src/offline/query-persistence.ts +57 -24
  225. package/src/offline/service-worker-updates.ts +81 -0
  226. package/src/offline.ts +7 -1
  227. package/src/push-worker.ts +25 -0
  228. package/src/shell/account-menu-text-size.tsx +77 -0
  229. package/src/shell/top-bar-actions.tsx +6 -1
  230. package/src/shell.ts +3 -0
  231. package/src/sync/db.ts +125 -0
  232. package/src/sync/engine.ts +701 -0
  233. package/src/sync/hooks.ts +98 -0
  234. package/src/sync/testing.ts +73 -0
  235. package/src/sync/wire.ts +61 -0
  236. package/src/sync.ts +29 -0
  237. package/src/tour/tour-labels.ts +1 -0
  238. package/src/tour/tour.tsx +100 -7
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The service worker's half of Web Push that the kit can own (0.46,
3
+ * docs/notifications-harmonization.md NT:1028): re-registering a subscription the
4
+ * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a
5
+ * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the
6
+ * server kept sending to the old endpoint, got 410, deleted the device, and the person
7
+ * had to turn push on again without knowing why.
8
+ *
9
+ * {@link handlePushSubscriptionChange} listens for the event in the app's worker and
10
+ * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit
11
+ * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only
12
+ * this browser and the server hold, prove the device without a session (a worker has no
13
+ * `Authorization` header to send), and the server moves that row to the new endpoint in
14
+ * place — same account, same device. Open pages are told with a
15
+ * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.
16
+ *
17
+ * Dependency-free (no React), so an app's worker bundle takes only this file — through
18
+ * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main
19
+ * barrel, which reaches React and the components:
20
+ *
21
+ * // sw.ts
22
+ * import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
23
+ * handlePushSubscriptionChange(self, {
24
+ * pushKey: () => fetch("/api/notifications/push-key").then((r) => r.json()).then((b) => b.key),
25
+ * replace: (body) => fetch("/api/notifications/devices/replace", {
26
+ * method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
27
+ * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),
28
+ * });
29
+ */
30
+ /** What `register` sends: `POST /notifications/devices` (§3.5). */
31
+ export interface PushSubscriptionInput {
32
+ endpoint: string;
33
+ keys: {
34
+ p256dh: string;
35
+ auth: string;
36
+ };
37
+ user_agent: string | null;
38
+ }
39
+ /** What `replace` sends: `POST /notifications/devices/replace` (server-kit
40
+ * `PushReplaceIn`). The endpoint never goes in a URL. */
41
+ export interface PushReplaceInput {
42
+ /** The rotated-away subscription's endpoint. */
43
+ old_endpoint: string;
44
+ /** Its `keys.auth` as registered — the proof that this browser held it. */
45
+ old_auth: string;
46
+ /** The browser's new subscription. */
47
+ subscription: PushSubscriptionInput;
48
+ }
49
+ /** The message a worker posts to its pages after a rotation, and the window event the
50
+ * hooks re-read on — the same name. */
51
+ export declare const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
52
+ /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
53
+ export declare function keyBytes(key: string): Uint8Array<ArrayBuffer>;
54
+ /** A subscription's register body, or `null` when the browser handed back an incomplete
55
+ * one. */
56
+ export declare function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null;
57
+ /** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's
58
+ * DOM lib). Either subscription may be missing: Chrome often hands neither. */
59
+ export interface PushSubscriptionChangeEventLike {
60
+ readonly oldSubscription?: PushSubscription | null;
61
+ readonly newSubscription?: PushSubscription | null;
62
+ waitUntil?: (promise: Promise<unknown>) => void;
63
+ }
64
+ export interface PushSubscriptionChangeOptions {
65
+ /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no
66
+ * new subscription and the old one does not carry its key. `""`: push is off. */
67
+ pushKey: () => Promise<string>;
68
+ /** `POST /notifications/devices/replace`. A rejection is the outcome `"failed"`. */
69
+ replace: (input: PushReplaceInput) => Promise<unknown>;
70
+ }
71
+ /**
72
+ * What a rotation came to: `"replaced"` — the server moved the device; `"no_previous"` —
73
+ * the browser named no old subscription (or one without its keys), so there is nothing
74
+ * to prove the device with and the page shows push as off; `"push_off"` — the server has
75
+ * no key; `"incomplete"` — the new subscription has no keys; `"failed"` — `replace` (or
76
+ * the browser's subscribe) threw.
77
+ */
78
+ export type PushResubscribeOutcome = "replaced" | "no_previous" | "push_off" | "incomplete" | "failed";
79
+ /**
80
+ * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when
81
+ * it handed none, the one it holds now, or a fresh one under the old subscription's key
82
+ * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.
83
+ */
84
+ export declare function resubscribeAfterChange(event: PushSubscriptionChangeEventLike, pushManager: PushManager, options: PushSubscriptionChangeOptions): Promise<PushResubscribeOutcome>;
85
+ /** The parts of a `ServiceWorkerGlobalScope` this needs. */
86
+ export interface PushWorkerScope {
87
+ addEventListener: (type: "pushsubscriptionchange", listener: (event: PushSubscriptionChangeEventLike) => void) => void;
88
+ registration: {
89
+ pushManager: PushManager;
90
+ };
91
+ clients?: {
92
+ matchAll: (options?: {
93
+ type?: "window";
94
+ includeUncontrolled?: boolean;
95
+ }) => Promise<readonly {
96
+ postMessage: (message: unknown) => void;
97
+ }[]>;
98
+ };
99
+ }
100
+ /**
101
+ * Listen for `pushsubscriptionchange` in the app's service worker (`self`) and
102
+ * re-register through {@link resubscribeAfterChange}, holding the worker alive until it
103
+ * is done (`waitUntil`). Then every open page gets `{ type: PUSH_SUBSCRIPTION_CHANGED,
104
+ * outcome }`; `usePushDevice` re-reads on it. `onOutcome` is for the app's own logging.
105
+ */
106
+ export declare function handlePushSubscriptionChange(scope: PushWorkerScope, options: PushSubscriptionChangeOptions & {
107
+ onOutcome?: (outcome: PushResubscribeOutcome) => void;
108
+ }): void;
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
3
+ function keyBytes(key) {
4
+ const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - key.length % 4) % 4);
5
+ const raw = atob(base64);
6
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
7
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
8
+ return bytes;
9
+ }
10
+ function pushSubscriptionInput(subscription) {
11
+ const json = subscription.toJSON();
12
+ const p256dh = json.keys?.p256dh;
13
+ const auth = json.keys?.auth;
14
+ if (!json.endpoint || !p256dh || !auth) return null;
15
+ return {
16
+ endpoint: json.endpoint,
17
+ keys: { p256dh, auth },
18
+ user_agent: typeof navigator !== "undefined" && navigator.userAgent || null
19
+ };
20
+ }
21
+ async function resubscribeAfterChange(event, pushManager, options) {
22
+ const old = event.oldSubscription;
23
+ const oldAuth = old?.toJSON().keys?.auth;
24
+ if (!old?.endpoint || !oldAuth) return "no_previous";
25
+ try {
26
+ let next = event.newSubscription ?? await pushManager.getSubscription();
27
+ if (!next || next.endpoint === old.endpoint) {
28
+ const kept = old.options?.applicationServerKey;
29
+ const key = kept ? null : await options.pushKey();
30
+ if (key === "") return "push_off";
31
+ next = await pushManager.subscribe({
32
+ userVisibleOnly: true,
33
+ applicationServerKey: kept ?? keyBytes(key ?? "")
34
+ });
35
+ }
36
+ const subscription = pushSubscriptionInput(next);
37
+ if (!subscription) return "incomplete";
38
+ await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });
39
+ return "replaced";
40
+ } catch {
41
+ return "failed";
42
+ }
43
+ }
44
+ function handlePushSubscriptionChange(scope, options) {
45
+ scope.addEventListener("pushsubscriptionchange", (event) => {
46
+ const done = resubscribeAfterChange(event, scope.registration.pushManager, options).then(async (outcome) => {
47
+ options.onOutcome?.(outcome);
48
+ const pages = await scope.clients?.matchAll({ type: "window", includeUncontrolled: true }) ?? [];
49
+ for (const page of pages) page.postMessage({ type: PUSH_SUBSCRIPTION_CHANGED, outcome });
50
+ return outcome;
51
+ });
52
+ event.waitUntil?.(done);
53
+ });
54
+ }
55
+ export {
56
+ PUSH_SUBSCRIPTION_CHANGED,
57
+ handlePushSubscriptionChange,
58
+ keyBytes,
59
+ pushSubscriptionInput,
60
+ resubscribeAfterChange
61
+ };
62
+ //# sourceMappingURL=push-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/notifications/push-worker.ts"],"sourcesContent":["/**\n * The service worker's half of Web Push that the kit can own (0.46,\n * docs/notifications-harmonization.md NT:1028): re-registering a subscription the\n * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a\n * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the\n * server kept sending to the old endpoint, got 410, deleted the device, and the person\n * had to turn push on again without knowing why.\n *\n * {@link handlePushSubscriptionChange} listens for the event in the app's worker and\n * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only\n * this browser and the server hold, prove the device without a session (a worker has no\n * `Authorization` header to send), and the server moves that row to the new endpoint in\n * place — same account, same device. Open pages are told with a\n * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.\n *\n * Dependency-free (no React), so an app's worker bundle takes only this file — through\n * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main\n * barrel, which reaches React and the components:\n *\n * // sw.ts\n * import { handlePushSubscriptionChange } from \"@eifi1/ui-kit/push-worker\";\n * handlePushSubscriptionChange(self, {\n * pushKey: () => fetch(\"/api/notifications/push-key\").then((r) => r.json()).then((b) => b.key),\n * replace: (body) => fetch(\"/api/notifications/devices/replace\", {\n * method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(body),\n * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),\n * });\n */\n\n/** What `register` sends: `POST /notifications/devices` (§3.5). */\nexport interface PushSubscriptionInput {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n user_agent: string | null;\n}\n\n/** What `replace` sends: `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`). The endpoint never goes in a URL. */\nexport interface PushReplaceInput {\n /** The rotated-away subscription's endpoint. */\n old_endpoint: string;\n /** Its `keys.auth` as registered — the proof that this browser held it. */\n old_auth: string;\n /** The browser's new subscription. */\n subscription: PushSubscriptionInput;\n}\n\n/** The message a worker posts to its pages after a rotation, and the window event the\n * hooks re-read on — the same name. */\nexport const PUSH_SUBSCRIPTION_CHANGED = \"eifi1:push-subscription-changed\";\n\n/** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */\nexport function keyBytes(key: string): Uint8Array<ArrayBuffer> {\n const base64 = key.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (key.length % 4)) % 4);\n const raw = atob(base64);\n const bytes = new Uint8Array(new ArrayBuffer(raw.length));\n for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);\n return bytes;\n}\n\n/** A subscription's register body, or `null` when the browser handed back an incomplete\n * one. */\nexport function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null {\n const json = subscription.toJSON();\n const p256dh = json.keys?.p256dh;\n const auth = json.keys?.auth;\n if (!json.endpoint || !p256dh || !auth) return null;\n return {\n endpoint: json.endpoint,\n keys: { p256dh, auth },\n user_agent: (typeof navigator !== \"undefined\" && navigator.userAgent) || null,\n };\n}\n\n/** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's\n * DOM lib). Either subscription may be missing: Chrome often hands neither. */\nexport interface PushSubscriptionChangeEventLike {\n readonly oldSubscription?: PushSubscription | null;\n readonly newSubscription?: PushSubscription | null;\n waitUntil?: (promise: Promise<unknown>) => void;\n}\n\nexport interface PushSubscriptionChangeOptions {\n /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no\n * new subscription and the old one does not carry its key. `\"\"`: push is off. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices/replace`. A rejection is the outcome `\"failed\"`. */\n replace: (input: PushReplaceInput) => Promise<unknown>;\n}\n\n/**\n * What a rotation came to: `\"replaced\"` — the server moved the device; `\"no_previous\"` —\n * the browser named no old subscription (or one without its keys), so there is nothing\n * to prove the device with and the page shows push as off; `\"push_off\"` — the server has\n * no key; `\"incomplete\"` — the new subscription has no keys; `\"failed\"` — `replace` (or\n * the browser's subscribe) threw.\n */\nexport type PushResubscribeOutcome = \"replaced\" | \"no_previous\" | \"push_off\" | \"incomplete\" | \"failed\";\n\n/**\n * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when\n * it handed none, the one it holds now, or a fresh one under the old subscription's key\n * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.\n */\nexport async function resubscribeAfterChange(\n event: PushSubscriptionChangeEventLike,\n pushManager: PushManager,\n options: PushSubscriptionChangeOptions,\n): Promise<PushResubscribeOutcome> {\n const old = event.oldSubscription;\n const oldAuth = old?.toJSON().keys?.auth;\n if (!old?.endpoint || !oldAuth) return \"no_previous\";\n try {\n let next = event.newSubscription ?? (await pushManager.getSubscription());\n if (!next || next.endpoint === old.endpoint) {\n const kept = old.options?.applicationServerKey;\n const key = kept ? null : await options.pushKey();\n if (key === \"\") return \"push_off\";\n next = await pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: kept ?? keyBytes(key ?? \"\"),\n });\n }\n const subscription = pushSubscriptionInput(next);\n if (!subscription) return \"incomplete\";\n await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });\n return \"replaced\";\n } catch {\n return \"failed\";\n }\n}\n\n/** The parts of a `ServiceWorkerGlobalScope` this needs. */\nexport interface PushWorkerScope {\n addEventListener: (type: \"pushsubscriptionchange\", listener: (event: PushSubscriptionChangeEventLike) => void) => void;\n registration: { pushManager: PushManager };\n clients?: { matchAll: (options?: { type?: \"window\"; includeUncontrolled?: boolean }) => Promise<readonly { postMessage: (message: unknown) => void }[]> };\n}\n\n/**\n * Listen for `pushsubscriptionchange` in the app's service worker (`self`) and\n * re-register through {@link resubscribeAfterChange}, holding the worker alive until it\n * is done (`waitUntil`). Then every open page gets `{ type: PUSH_SUBSCRIPTION_CHANGED,\n * outcome }`; `usePushDevice` re-reads on it. `onOutcome` is for the app's own logging.\n */\nexport function handlePushSubscriptionChange(\n scope: PushWorkerScope,\n options: PushSubscriptionChangeOptions & { onOutcome?: (outcome: PushResubscribeOutcome) => void },\n): void {\n scope.addEventListener(\"pushsubscriptionchange\", (event) => {\n const done = resubscribeAfterChange(event, scope.registration.pushManager, options).then(async (outcome) => {\n options.onOutcome?.(outcome);\n const pages = (await scope.clients?.matchAll({ type: \"window\", includeUncontrolled: true })) ?? [];\n for (const page of pages) page.postMessage({ type: PUSH_SUBSCRIPTION_CHANGED, outcome });\n return outcome;\n });\n event.waitUntil?.(done);\n });\n}\n"],"mappings":";AAkDO,MAAM,4BAA4B;AAGlC,SAAS,SAAS,KAAsC;AAC7D,QAAM,SAAS,IAAI,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,IAAI,IAAI,QAAQ,IAAK,IAAI,SAAS,KAAM,CAAC;AAChG,QAAM,MAAM,KAAK,MAAM;AACvB,QAAM,QAAQ,IAAI,WAAW,IAAI,YAAY,IAAI,MAAM,CAAC;AACxD,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,OAAM,CAAC,IAAI,IAAI,WAAW,CAAC;AAChE,SAAO;AACT;AAIO,SAAS,sBAAsB,cAA8D;AAClG,QAAM,OAAO,aAAa,OAAO;AACjC,QAAM,SAAS,KAAK,MAAM;AAC1B,QAAM,OAAO,KAAK,MAAM;AACxB,MAAI,CAAC,KAAK,YAAY,CAAC,UAAU,CAAC,KAAM,QAAO;AAC/C,SAAO;AAAA,IACL,UAAU,KAAK;AAAA,IACf,MAAM,EAAE,QAAQ,KAAK;AAAA,IACrB,YAAa,OAAO,cAAc,eAAe,UAAU,aAAc;AAAA,EAC3E;AACF;AAgCA,eAAsB,uBACpB,OACA,aACA,SACiC;AACjC,QAAM,MAAM,MAAM;AAClB,QAAM,UAAU,KAAK,OAAO,EAAE,MAAM;AACpC,MAAI,CAAC,KAAK,YAAY,CAAC,QAAS,QAAO;AACvC,MAAI;AACF,QAAI,OAAO,MAAM,mBAAoB,MAAM,YAAY,gBAAgB;AACvE,QAAI,CAAC,QAAQ,KAAK,aAAa,IAAI,UAAU;AAC3C,YAAM,OAAO,IAAI,SAAS;AAC1B,YAAM,MAAM,OAAO,OAAO,MAAM,QAAQ,QAAQ;AAChD,UAAI,QAAQ,GAAI,QAAO;AACvB,aAAO,MAAM,YAAY,UAAU;AAAA,QACjC,iBAAiB;AAAA,QACjB,sBAAsB,QAAQ,SAAS,OAAO,EAAE;AAAA,MAClD,CAAC;AAAA,IACH;AACA,UAAM,eAAe,sBAAsB,IAAI;AAC/C,QAAI,CAAC,aAAc,QAAO;AAC1B,UAAM,QAAQ,QAAQ,EAAE,cAAc,IAAI,UAAU,UAAU,SAAS,aAAa,CAAC;AACrF,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAeO,SAAS,6BACd,OACA,SACM;AACN,QAAM,iBAAiB,0BAA0B,CAAC,UAAU;AAC1D,UAAM,OAAO,uBAAuB,OAAO,MAAM,aAAa,aAAa,OAAO,EAAE,KAAK,OAAO,YAAY;AAC1G,cAAQ,YAAY,OAAO;AAC3B,YAAM,QAAS,MAAM,MAAM,SAAS,SAAS,EAAE,MAAM,UAAU,qBAAqB,KAAK,CAAC,KAAM,CAAC;AACjG,iBAAW,QAAQ,MAAO,MAAK,YAAY,EAAE,MAAM,2BAA2B,QAAQ,CAAC;AACvF,aAAO;AAAA,IACT,CAAC;AACD,UAAM,YAAY,IAAI;AAAA,EACxB,CAAC;AACH;","names":[]}
@@ -7,17 +7,29 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
7
7
  start: string | null;
8
8
  /** The `GET`'s `quiet_hours_end`. */
9
9
  end: string | null;
10
- /** The `GET`'s `timezone`, an IANA name, or `null` (the app's zone applies). */
11
- timezone: string | null;
10
+ /** The zone the 0.36 card showed, an IANA name, or `null`.
11
+ * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
12
+ * Shown read-only while `zone` is left out; the card no longer offers a picker. */
13
+ timezone?: string | null;
12
14
  /**
13
- * Save one change — `PATCH /notifications/settings`: a time (`null` when cleared), with
14
- * the device's zone the first time one is entered while `timezone` is `null`; or
15
- * `{timezone}`. The field shows the new value while the call runs and goes back if it
15
+ * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
16
+ * `device_timezone`, the zone of the device last used; `null` while no device has
17
+ * reported one, and the card then names `fallbackZone`, the zone the server applies.
18
+ * Shown read-only once a time is set ("In your time zone (Europe / Zurich), which follows
19
+ * the device you last used."). No picker: the zone is never chosen.
20
+ */
21
+ zone?: string | null;
22
+ /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs
23
+ * the notifications tick); the description says so. Default `false`: dropped. */
24
+ held?: boolean;
25
+ /**
26
+ * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
27
+ * Never a zone. The field shows the new value while the call runs and goes back if it
16
28
  * rejects.
17
29
  */
18
30
  onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
19
- /** The zone shown, and the one the server applies, while `timezone` is `null`.
20
- * Default `"Europe/Zurich"`, every app's (decision 6). */
31
+ /** The zone named while neither `zone` nor `timezone` names one: the one the server
32
+ * applies. Default `"Europe/Zurich"`, every app's. */
21
33
  fallbackZone?: string;
22
34
  /** The app keeps an inbox, and the description says a held push still reaches it. */
23
35
  inbox?: boolean;
@@ -29,19 +41,19 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
29
41
  /**
30
42
  * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local
31
43
  * times in the account's zone, which may cross midnight, when no push of a machine-sent
32
- * type is sent. A held push is dropped, not delayed; the inbox row and the mail go as
33
- * usual. keksdose's block, as a card of its own.
44
+ * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the
45
+ * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its
46
+ * own.
47
+ *
48
+ * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic
49
+ * only** — the zone of the device last used, else `Europe/Zurich`. The card names it
50
+ * read-only and never sends one; there is no picker and no "Use it".
34
51
  *
35
52
  * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
36
53
  * one is set the card says so: the window fails open until both are.
37
- * - **The zone** shows while a time is set. The first time entered while the account has
38
- * none sends this device's zone with it, so "22:00" means the person's 22:00
39
- * (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows
40
- * `fallbackZone` — the zone the server then applies — never the device's (keksdose's
41
- * card showed the device's zone while its server applied UTC).
42
- * - **"This device is in …"** with "Use it" when the device's zone is not the one in
43
- * effect. Never written on its own: a zone is written by a person's pick (§2.6).
54
+ * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
55
+ * `fallbackZone`.
44
56
  *
45
57
  * Under a write lock the fields are `commit` controls exempt from billing.
46
58
  */
47
- export declare function QuietHoursSetting({ start, end, timezone, onChange, fallbackZone, inbox, disabled, describeError, labels: labelsProp, className, ...rest }: QuietHoursSettingProps): import("react").JSX.Element;
59
+ export declare function QuietHoursSetting({ start, end, timezone, zone, held, onChange, fallbackZone, inbox, disabled, describeError, labels: labelsProp, className, ...rest }: QuietHoursSettingProps): import("react").JSX.Element;
@@ -2,7 +2,6 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId, useState } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
- import { Button } from "../components/button.js";
6
5
  import { Card } from "../components/card.js";
7
6
  import { TimeInput } from "../components/time-input.js";
8
7
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock.js";
@@ -10,12 +9,14 @@ import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes.js";
10
9
  import { SettingsCardTitle } from "../settings/settings-heading.js";
11
10
  import { settle, useMounted } from "../account/account-parts.js";
12
11
  import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts.js";
13
- import { deviceTimeZone, TimeZoneSelect, zoneName } from "./time-zone-select.js";
12
+ import { zoneName } from "./time-zone-select.js";
14
13
  const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
15
14
  function QuietHoursSetting({
16
15
  start,
17
16
  end,
18
- timezone,
17
+ timezone = null,
18
+ zone,
19
+ held = false,
19
20
  onChange,
20
21
  fallbackZone = DEFAULT_FALLBACK_ZONE,
21
22
  inbox = false,
@@ -26,16 +27,14 @@ function QuietHoursSetting({
26
27
  ...rest
27
28
  }) {
28
29
  const labels = useNotificationsLabels(labelsProp);
29
- const overlay = useWriteOverlay(`${start}|${end}|${timezone}`);
30
+ const overlay = useWriteOverlay(`${start}|${end}`);
30
31
  const [failure, setFailure] = useState(null);
31
32
  const mounted = useMounted();
32
33
  const id = useId();
33
34
  const shown = (field, server) => overlay.has(field) ? overlay.get(field) ?? null : server;
34
35
  const startShown = shown("quiet_hours_start", start);
35
36
  const endShown = shown("quiet_hours_end", end);
36
- const zoneShown = shown("timezone", timezone);
37
- const effectiveZone = zoneShown ?? fallbackZone;
38
- const device = deviceTimeZone();
37
+ const accountZone = (zone === void 0 ? timezone : zone) ?? fallbackZone;
39
38
  const save = (patch) => {
40
39
  const fields = Object.keys(patch);
41
40
  setFailure(null);
@@ -54,16 +53,14 @@ function QuietHoursSetting({
54
53
  const saveTime = (field, value) => {
55
54
  const next = value || null;
56
55
  if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
57
- const patch = { [field]: next };
58
- if (next && zoneShown === null) patch.timezone = device;
59
- save(patch);
56
+ save({ [field]: next });
60
57
  };
61
58
  const anySet = Boolean(startShown || endShown);
62
59
  const halfSet = Boolean(startShown) !== Boolean(endShown);
63
60
  return /* @__PURE__ */ jsxs(Card, { ...rest, className: cn("p-4 space-y-3", className), children: [
64
61
  /* @__PURE__ */ jsxs("div", { children: [
65
62
  /* @__PURE__ */ jsx(SettingsCardTitle, { children: labels.quietTitle }),
66
- /* @__PURE__ */ jsx("div", { className: CARD_DESCRIPTION_CLASS, children: labels.quietDescription(inbox) })
63
+ /* @__PURE__ */ jsx("div", { className: CARD_DESCRIPTION_CLASS, children: labels.quietDescription(inbox, held) })
67
64
  ] }),
68
65
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [
69
66
  /* @__PURE__ */ jsx(
@@ -92,36 +89,7 @@ function QuietHoursSetting({
92
89
  )
93
90
  ] }),
94
91
  halfSet && /* @__PURE__ */ jsx("p", { className: "text-xs text-warning", "data-quiet-incomplete": "", children: labels.quietIncomplete }),
95
- anySet && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
96
- /* @__PURE__ */ jsx(
97
- TimeZoneSelect,
98
- {
99
- id: `${id}-zone`,
100
- value: effectiveZone,
101
- disabled,
102
- commit: COMMIT_EXCEPT_BILLING,
103
- onChange: (zone) => save({ timezone: zone }),
104
- className: "max-w-72",
105
- labels: labelsProp
106
- }
107
- ),
108
- device !== effectiveZone && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [
109
- /* @__PURE__ */ jsx("span", { id: `${id}-device`, className: CARD_DESCRIPTION_CLASS, children: labels.deviceZone(zoneName(device, labels.regions)) }),
110
- /* @__PURE__ */ jsx(
111
- Button,
112
- {
113
- type: "button",
114
- size: "sm",
115
- variant: "link",
116
- disabled,
117
- commit: COMMIT_EXCEPT_BILLING,
118
- "aria-describedby": `${id}-device`,
119
- onClick: () => save({ timezone: device }),
120
- children: labels.useDeviceZone
121
- }
122
- )
123
- ] })
124
- ] }),
92
+ anySet && /* @__PURE__ */ jsx("p", { className: CARD_DESCRIPTION_CLASS, "data-quiet-zone": "", children: labels.quietZone(zoneName(accountZone, labels.regions)) }),
125
93
  /* @__PURE__ */ jsx(FailureLine, { children: failure })
126
94
  ] });
127
95
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { deviceTimeZone, TimeZoneSelect, zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while the account has none (§2.6): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The `GET`'s `timezone`, an IANA name, or `null` (the app's zone applies). */\n timezone: string | null;\n /**\n * Save one change — `PATCH /notifications/settings`: a time (`null` when cleared), with\n * the device's zone the first time one is entered while `timezone` is `null`; or\n * `{timezone}`. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone shown, and the one the server applies, while `timezone` is `null`.\n * Default `\"Europe/Zurich\"`, every app's (decision 6). */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\" | \"timezone\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped, not delayed; the inbox row and the mail go as\n * usual. keksdose's block, as a card of its own.\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set. The first time entered while the account has\n * none sends this device's zone with it, so \"22:00\" means the person's 22:00\n * (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows\n * `fallbackZone` — the zone the server then applies — never the device's (keksdose's\n * card showed the device's zone while its server applied UTC).\n * - **\"This device is in …\"** with \"Use it\" when the device's zone is not the one in\n * effect. Never written on its own: a zone is written by a person's pick (§2.6).\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}|${timezone}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const zoneShown = shown(\"timezone\", timezone);\n const effectiveZone = zoneShown ?? fallbackZone;\n const device = deviceTimeZone();\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n const patch: NotificationSettingsPatch = { [field]: next };\n if (next && zoneShown === null) patch.timezone = device;\n save(patch);\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <div className=\"space-y-2\">\n <TimeZoneSelect\n id={`${id}-zone`}\n value={effectiveZone}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onChange={(zone) => save({ timezone: zone })}\n className=\"max-w-72\"\n labels={labelsProp}\n />\n {device !== effectiveZone && (\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n <span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>\n {labels.deviceZone(zoneName(device, labels.regions))}\n </span>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"link\"\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n aria-describedby={`${id}-device`}\n onClick={() => save({ timezone: device })}\n >\n {labels.useDeviceZone}\n </Button>\n </div>\n )}\n </div>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The zone the 0.36 card showed, an IANA name, or `null`.\n * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.\n * Shown read-only while `zone` is left out; the card no longer offers a picker. */\n timezone?: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while neither `zone` nor `timezone` names one: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else\n * `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone = null,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
@@ -4,11 +4,12 @@ import type { ComboOption } from "../components/combobox-core.js";
4
4
  import type { CommitScope } from "../components/write-lock.js";
5
5
  import type { NotificationRegionLabels, NotificationsLabels } from "./notifications-labels.js";
6
6
  /**
7
- * This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
8
- * the zone quiet hours are entered in the first time (§2.6), and the one the card offers
9
- * when it differs from the account's.
7
+ * This device's IANA zone — the same function as the package root's, which lives beside
8
+ * `useDeviceZoneReport` now (0.46.1).
9
+ * @deprecated 0.46 — import `deviceTimeZone` from `@eifi1/ui-kit` (notifications/device-zone-report); this
10
+ * module is removed in 0.47.
10
11
  */
11
- export declare function deviceTimeZone(): string;
12
+ export declare const deviceTimeZone: () => string;
12
13
  /**
13
14
  * The zone picker's rows (keksdose `notifications-card.tsx:131-146`): every zone `Intl`
14
15
  * lists, as its city ("Argentina / Buenos Aires") under its region, the region named by
@@ -17,6 +18,7 @@ export declare function deviceTimeZone(): string;
17
18
  * rather than trailing the last group as if it were Pacific. `current` is kept even when
18
19
  * the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
19
20
  * own value would show an empty field.
21
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
20
22
  */
21
23
  export declare function timeZoneOptions(current: string, regionLabel: (region: string) => string | undefined): ComboOption<string>[];
22
24
  /** The region's name in `regions`, or `undefined` for one the labels do not know.
@@ -25,6 +27,7 @@ export declare function regionName(regions: NotificationRegionLabels, region: st
25
27
  /** A zone as the picker shows it, for a sentence: "Europe / Zurich", the region in the
26
28
  * kit's words. @internal Not part of the barrel. */
27
29
  export declare function zoneName(zone: string, regions: NotificationRegionLabels): string;
30
+ /** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */
28
31
  export interface TimeZoneSelectProps {
29
32
  /** The IANA zone. Not clearable: a zone can be changed, never emptied. */
30
33
  value: string;
@@ -46,5 +49,6 @@ export interface TimeZoneSelectProps {
46
49
  * a search box instead of a 400-row wheel. Rows are the city under its region, the region
47
50
  * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
48
51
  * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
52
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
49
53
  */
50
54
  export declare function TimeZoneSelect({ value, onChange, label, disabled, id, className, commit, labels: labelsProp, }: TimeZoneSelectProps): import("react").JSX.Element;
@@ -2,14 +2,9 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { useMemo } from "react";
4
4
  import { InlineEntityCombobox } from "../components/inline-entity-combobox.js";
5
+ import { deviceTimeZone as zoneOfDevice } from "./device-zone-report.js";
5
6
  import { useNotificationsLabels } from "./notification-parts.js";
6
- function deviceTimeZone() {
7
- try {
8
- return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
9
- } catch {
10
- return "UTC";
11
- }
12
- }
7
+ const deviceTimeZone = zoneOfDevice;
13
8
  function runtimeZones() {
14
9
  const intl = Intl;
15
10
  return typeof intl.supportedValuesOf === "function" ? intl.supportedValuesOf("timeZone") : [];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/time-zone-select.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport type { ComboOption } from \"../components/combobox-core\";\nimport { InlineEntityCombobox } from \"../components/inline-entity-combobox\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\n\n/**\n * This device's IANA zone (\"Europe/Zurich\"), or `\"UTC\"` where the runtime names none —\n * the zone quiet hours are entered in the first time (§2.6), and the one the card offers\n * when it differs from the account's.\n */\nexport function deviceTimeZone(): string {\n try {\n return Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\";\n } catch {\n return \"UTC\";\n }\n}\n\n/** Every zone the runtime lists; `[]` where `Intl.supportedValuesOf` is missing. */\nfunction runtimeZones(): string[] {\n const intl = Intl as typeof Intl & { supportedValuesOf?: (key: \"timeZone\") => string[] };\n return typeof intl.supportedValuesOf === \"function\" ? intl.supportedValuesOf(\"timeZone\") : [];\n}\n\n/** \"Argentina/Buenos_Aires\" → \"Argentina / Buenos Aires\": the zone's city, as a row. */\nfunction cityOf(rest: string): string {\n return rest.replace(/_/g, \" \").replace(/\\//g, \" / \");\n}\n\n/**\n * The zone picker's rows (keksdose `notifications-card.tsx:131-146`): every zone `Intl`\n * lists, as its city (\"Argentina / Buenos Aires\") under its region, the region named by\n * `regionLabel` — which answers `undefined` for a region it does not know, and the region\n * keeps its IANA name. A zone with no region (\"UTC\") comes first, ahead of every heading,\n * rather than trailing the last group as if it were Pacific. `current` is kept even when\n * the runtime does not list it (an old alias such as \"Europe/Kiev\"): a picker without its\n * own value would show an empty field.\n */\nexport function timeZoneOptions(\n current: string,\n regionLabel: (region: string) => string | undefined,\n): ComboOption<string>[] {\n const zones = runtimeZones();\n const all = !current || zones.includes(current) ? zones : [...zones, current].sort();\n const rows = all.map((zone): ComboOption<string> => {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return { value: zone, label: zone };\n const region = zone.slice(0, slash);\n return { value: zone, label: cityOf(zone.slice(slash + 1)), group: regionLabel(region) ?? region };\n });\n return [...rows.filter((row) => !row.group), ...rows.filter((row) => row.group)];\n}\n\n/** The region's name in `regions`, or `undefined` for one the labels do not know.\n * @internal Not part of the barrel. */\nexport function regionName(regions: NotificationRegionLabels, region: string): string | undefined {\n return Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : undefined;\n}\n\n/** A zone as the picker shows it, for a sentence: \"Europe / Zurich\", the region in the\n * kit's words. @internal Not part of the barrel. */\nexport function zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;\n}\n\nexport interface TimeZoneSelectProps {\n /** The IANA zone. Not clearable: a zone can be changed, never emptied. */\n value: string;\n /** The zone picked. Never called for the value it already has. */\n onChange: (zone: string) => void;\n /** Default `labels.timeZone`. */\n label?: ReactNode;\n disabled?: boolean;\n /** The input's id. */\n id?: string;\n className?: string;\n /** The picker SAVES on change (quiet hours do): locked under a write lock in scope. */\n commit?: CommitScope;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/**\n * An IANA time zone, searchable (docs/notifications-harmonization.md §4.2, keksdose's\n * quiet-hours picker): type \"Zur\" for Europe/Zurich, and on a phone the kit's sheet with\n * a search box instead of a 400-row wheel. Rows are the city under its region, the region\n * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the\n * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.\n */\nexport function TimeZoneSelect({\n value,\n onChange,\n label,\n disabled,\n id,\n className,\n commit,\n labels: labelsProp,\n}: TimeZoneSelectProps) {\n const labels = useNotificationsLabels(labelsProp);\n const { regions } = labels;\n const options = useMemo(() => timeZoneOptions(value, (region) => regionName(regions, region)), [value, regions]);\n return (\n <InlineEntityCombobox<string>\n id={id}\n label={label ?? labels.timeZone}\n value={value}\n options={options}\n disabled={disabled}\n commit={commit}\n onChange={(zone) => {\n if (zone && zone !== value) onChange(zone);\n }}\n className={className}\n />\n );\n}\n"],"mappings":";AAgHI;AAhHJ,SAAS,eAAe;AAKxB,SAAS,4BAA4B;AAErC,SAAS,8BAA8B;AAQhC,SAAS,iBAAyB;AACvC,MAAI;AACF,WAAO,KAAK,eAAe,EAAE,gBAAgB,EAAE,YAAY;AAAA,EAC7D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAyB;AAChC,QAAM,OAAO;AACb,SAAO,OAAO,KAAK,sBAAsB,aAAa,KAAK,kBAAkB,UAAU,IAAI,CAAC;AAC9F;AAGA,SAAS,OAAO,MAAsB;AACpC,SAAO,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,OAAO,KAAK;AACrD;AAWO,SAAS,gBACd,SACA,aACuB;AACvB,QAAM,QAAQ,aAAa;AAC3B,QAAM,MAAM,CAAC,WAAW,MAAM,SAAS,OAAO,IAAI,QAAQ,CAAC,GAAG,OAAO,OAAO,EAAE,KAAK;AACnF,QAAM,OAAO,IAAI,IAAI,CAAC,SAA8B;AAClD,UAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,QAAI,QAAQ,EAAG,QAAO,EAAE,OAAO,MAAM,OAAO,KAAK;AACjD,UAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,WAAO,EAAE,OAAO,MAAM,OAAO,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,GAAG,OAAO,YAAY,MAAM,KAAK,OAAO;AAAA,EACnG,CAAC;AACD,SAAO,CAAC,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,KAAK,GAAG,GAAG,KAAK,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;AACjF;AAIO,SAAS,WAAW,SAAmC,QAAoC;AAChG,SAAO,OAAO,UAAU,eAAe,KAAK,SAAS,MAAM,IACvD,QAAQ,MAAwC,IAChD;AACN;AAIO,SAAS,SAAS,MAAc,SAA2C;AAChF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,SAAO,GAAG,WAAW,SAAS,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,CAAC;AACpF;AAyBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAAwB;AACtB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,EAAE,QAAQ,IAAI;AACpB,QAAM,UAAU,QAAQ,MAAM,gBAAgB,OAAO,CAAC,WAAW,WAAW,SAAS,MAAM,CAAC,GAAG,CAAC,OAAO,OAAO,CAAC;AAC/G,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,SAAS,OAAO;AAAA,MACvB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,UAAU,CAAC,SAAS;AAClB,YAAI,QAAQ,SAAS,MAAO,UAAS,IAAI;AAAA,MAC3C;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/time-zone-select.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport type { ComboOption } from \"../components/combobox-core\";\nimport { InlineEntityCombobox } from \"../components/inline-entity-combobox\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { deviceTimeZone as zoneOfDevice } from \"./device-zone-report\";\nimport { useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\n\n/**\n * This device's IANA zone — the same function as the package root's, which lives beside\n * `useDeviceZoneReport` now (0.46.1).\n * @deprecated 0.46 — import `deviceTimeZone` from `@eifi1/ui-kit` (notifications/device-zone-report); this\n * module is removed in 0.47.\n */\nexport const deviceTimeZone: () => string = zoneOfDevice;\n\n/** Every zone the runtime lists; `[]` where `Intl.supportedValuesOf` is missing. */\nfunction runtimeZones(): string[] {\n const intl = Intl as typeof Intl & { supportedValuesOf?: (key: \"timeZone\") => string[] };\n return typeof intl.supportedValuesOf === \"function\" ? intl.supportedValuesOf(\"timeZone\") : [];\n}\n\n/** \"Argentina/Buenos_Aires\" → \"Argentina / Buenos Aires\": the zone's city, as a row. */\nfunction cityOf(rest: string): string {\n return rest.replace(/_/g, \" \").replace(/\\//g, \" / \");\n}\n\n/**\n * The zone picker's rows (keksdose `notifications-card.tsx:131-146`): every zone `Intl`\n * lists, as its city (\"Argentina / Buenos Aires\") under its region, the region named by\n * `regionLabel` — which answers `undefined` for a region it does not know, and the region\n * keeps its IANA name. A zone with no region (\"UTC\") comes first, ahead of every heading,\n * rather than trailing the last group as if it were Pacific. `current` is kept even when\n * the runtime does not list it (an old alias such as \"Europe/Kiev\"): a picker without its\n * own value would show an empty field.\n * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.\n */\nexport function timeZoneOptions(\n current: string,\n regionLabel: (region: string) => string | undefined,\n): ComboOption<string>[] {\n const zones = runtimeZones();\n const all = !current || zones.includes(current) ? zones : [...zones, current].sort();\n const rows = all.map((zone): ComboOption<string> => {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return { value: zone, label: zone };\n const region = zone.slice(0, slash);\n return { value: zone, label: cityOf(zone.slice(slash + 1)), group: regionLabel(region) ?? region };\n });\n return [...rows.filter((row) => !row.group), ...rows.filter((row) => row.group)];\n}\n\n/** The region's name in `regions`, or `undefined` for one the labels do not know.\n * @internal Not part of the barrel. */\nexport function regionName(regions: NotificationRegionLabels, region: string): string | undefined {\n return Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : undefined;\n}\n\n/** A zone as the picker shows it, for a sentence: \"Europe / Zurich\", the region in the\n * kit's words. @internal Not part of the barrel. */\nexport function zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;\n}\n\n/** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */\nexport interface TimeZoneSelectProps {\n /** The IANA zone. Not clearable: a zone can be changed, never emptied. */\n value: string;\n /** The zone picked. Never called for the value it already has. */\n onChange: (zone: string) => void;\n /** Default `labels.timeZone`. */\n label?: ReactNode;\n disabled?: boolean;\n /** The input's id. */\n id?: string;\n className?: string;\n /** The picker SAVES on change (quiet hours do): locked under a write lock in scope. */\n commit?: CommitScope;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/**\n * An IANA time zone, searchable (docs/notifications-harmonization.md §4.2, keksdose's\n * quiet-hours picker): type \"Zur\" for Europe/Zurich, and on a phone the kit's sheet with\n * a search box instead of a 400-row wheel. Rows are the city under its region, the region\n * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the\n * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.\n * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.\n */\nexport function TimeZoneSelect({\n value,\n onChange,\n label,\n disabled,\n id,\n className,\n commit,\n labels: labelsProp,\n}: TimeZoneSelectProps) {\n const labels = useNotificationsLabels(labelsProp);\n const { regions } = labels;\n const options = useMemo(() => timeZoneOptions(value, (region) => regionName(regions, region)), [value, regions]);\n return (\n <InlineEntityCombobox<string>\n id={id}\n label={label ?? labels.timeZone}\n value={value}\n options={options}\n disabled={disabled}\n commit={commit}\n onChange={(zone) => {\n if (zone && zone !== value) onChange(zone);\n }}\n className={className}\n />\n );\n}\n"],"mappings":";AA+GI;AA/GJ,SAAS,eAAe;AAKxB,SAAS,4BAA4B;AAErC,SAAS,kBAAkB,oBAAoB;AAC/C,SAAS,8BAA8B;AAShC,MAAM,iBAA+B;AAG5C,SAAS,eAAyB;AAChC,QAAM,OAAO;AACb,SAAO,OAAO,KAAK,sBAAsB,aAAa,KAAK,kBAAkB,UAAU,IAAI,CAAC;AAC9F;AAGA,SAAS,OAAO,MAAsB;AACpC,SAAO,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,OAAO,KAAK;AACrD;AAYO,SAAS,gBACd,SACA,aACuB;AACvB,QAAM,QAAQ,aAAa;AAC3B,QAAM,MAAM,CAAC,WAAW,MAAM,SAAS,OAAO,IAAI,QAAQ,CAAC,GAAG,OAAO,OAAO,EAAE,KAAK;AACnF,QAAM,OAAO,IAAI,IAAI,CAAC,SAA8B;AAClD,UAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,QAAI,QAAQ,EAAG,QAAO,EAAE,OAAO,MAAM,OAAO,KAAK;AACjD,UAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,WAAO,EAAE,OAAO,MAAM,OAAO,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,GAAG,OAAO,YAAY,MAAM,KAAK,OAAO;AAAA,EACnG,CAAC;AACD,SAAO,CAAC,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,KAAK,GAAG,GAAG,KAAK,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;AACjF;AAIO,SAAS,WAAW,SAAmC,QAAoC;AAChG,SAAO,OAAO,UAAU,eAAe,KAAK,SAAS,MAAM,IACvD,QAAQ,MAAwC,IAChD;AACN;AAIO,SAAS,SAAS,MAAc,SAA2C;AAChF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,SAAO,GAAG,WAAW,SAAS,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,CAAC;AACpF;AA2BO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAAwB;AACtB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,EAAE,QAAQ,IAAI;AACpB,QAAM,UAAU,QAAQ,MAAM,gBAAgB,OAAO,CAAC,WAAW,WAAW,SAAS,MAAM,CAAC,GAAG,CAAC,OAAO,OAAO,CAAC;AAC/G,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,SAAS,OAAO;AAAA,MACvB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,UAAU,CAAC,SAAS;AAClB,YAAI,QAAQ,SAAS,MAAO,UAAS,IAAI;AAAA,MAC3C;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -1,3 +1,7 @@
1
+ import { PUSH_SUBSCRIPTION_CHANGED } from "./push-worker.js";
2
+ import type { PushSubscriptionInput } from "./push-worker.js";
3
+ export { PUSH_SUBSCRIPTION_CHANGED };
4
+ export type { PushSubscriptionInput };
1
5
  /**
2
6
  * This browser's Web Push subscription for the signed-in account
3
7
  * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's
@@ -21,15 +25,6 @@ export declare class PushDeviceError extends Error {
21
25
  readonly code: PushDeviceErrorCode;
22
26
  constructor(code: PushDeviceErrorCode);
23
27
  }
24
- /** What `register` sends: `POST /notifications/devices` (§3.5). */
25
- export interface PushSubscriptionInput {
26
- endpoint: string;
27
- keys: {
28
- p256dh: string;
29
- auth: string;
30
- };
31
- user_agent: string | null;
32
- }
33
28
  export interface UsePushDeviceOptions {
34
29
  /** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
35
30
  pushKey: () => Promise<string>;
@@ -85,14 +80,6 @@ export interface PushDevice {
85
80
  sent: number;
86
81
  }>;
87
82
  }
88
- /**
89
- * The window event every mounted {@link usePushDevice} re-reads its state on. Fired by
90
- * every enable and disable, and by `PushDevicesSetting` when it removes this device — so
91
- * an app's own card on the same hook (keksdose's check-up) stays in step. The hook that
92
- * fires it does not re-read: it knows what it changed. Namespaced for the kit;
93
- * keksdose's was `"keksdose:push-subscription-changed"`.
94
- */
95
- export declare const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
96
83
  /** Tell every {@link usePushDevice} that this browser's subscription changed. */
97
84
  export declare function notifyPushSubscriptionChanged(): void;
98
85
  /** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED
@@ -2,6 +2,7 @@
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { useMediaQuery } from "../hooks/use-media-query.js";
4
4
  import { httpStatusOf } from "../auth/auth-errors.js";
5
+ import { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from "./push-worker.js";
5
6
  class PushDeviceError extends Error {
6
7
  code;
7
8
  constructor(code) {
@@ -10,7 +11,6 @@ class PushDeviceError extends Error {
10
11
  this.code = code;
11
12
  }
12
13
  }
13
- const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
14
14
  function notifyPushSubscriptionChanged() {
15
15
  if (typeof window !== "undefined") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));
16
16
  }
@@ -38,20 +38,6 @@ async function activeRegistration() {
38
38
  if (!existing) throw new PushDeviceError("no_service_worker");
39
39
  return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
40
40
  }
41
- function keyBytes(key) {
42
- const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - key.length % 4) % 4);
43
- const raw = atob(base64);
44
- const bytes = new Uint8Array(new ArrayBuffer(raw.length));
45
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
46
- return bytes;
47
- }
48
- function inputOf(subscription) {
49
- const json = subscription.toJSON();
50
- const p256dh = json.keys?.p256dh;
51
- const auth = json.keys?.auth;
52
- if (!json.endpoint || !p256dh || !auth) return null;
53
- return { endpoint: json.endpoint, keys: { p256dh, auth }, user_agent: navigator.userAgent || null };
54
- }
55
41
  const defaultEndpointTaken = (error) => httpStatusOf(error) === 409;
56
42
  function usePushDevice(options) {
57
43
  const latest = useRef(options);
@@ -133,11 +119,17 @@ function usePushDevice(options) {
133
119
  const heard = () => {
134
120
  if (!announcing.current) void sync();
135
121
  };
122
+ const fromWorker = (event) => {
123
+ if (event.data?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();
124
+ };
125
+ const worker = navigator.serviceWorker;
136
126
  void sync();
137
127
  window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
128
+ worker?.addEventListener?.("message", fromWorker);
138
129
  return () => {
139
130
  cancelled = true;
140
131
  window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
132
+ worker?.removeEventListener?.("message", fromWorker);
141
133
  };
142
134
  }, [supported]);
143
135
  const enable = useCallback(async () => {