@eifi1/ui-kit 0.51.7 → 0.52.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 (746) hide show
  1. package/README.md +141 -23
  2. package/dist/account/account-parts.d.ts +10 -8
  3. package/dist/account/account-parts.js +1 -18
  4. package/dist/account/account-parts.js.map +1 -1
  5. package/dist/account/data-export-setting-labels.js +1 -1
  6. package/dist/account/data-export-setting-labels.js.map +1 -1
  7. package/dist/account/delete-account-setting-labels.js +1 -1
  8. package/dist/account/delete-account-setting-labels.js.map +1 -1
  9. package/dist/account/email-change-setting-labels.js +1 -1
  10. package/dist/account/email-change-setting-labels.js.map +1 -1
  11. package/dist/account/passkeys-setting.d.ts +9 -2
  12. package/dist/account/passkeys-setting.js +41 -29
  13. package/dist/account/passkeys-setting.js.map +1 -1
  14. package/dist/account/sessions-setting.d.ts +3 -2
  15. package/dist/account/sessions-setting.js +10 -8
  16. package/dist/account/sessions-setting.js.map +1 -1
  17. package/dist/admin/admin-action-confirm.js +1 -3
  18. package/dist/admin/admin-action-confirm.js.map +1 -1
  19. package/dist/admin/admin-parts.d.ts +6 -3
  20. package/dist/admin/admin-parts.js +6 -21
  21. package/dist/admin/admin-parts.js.map +1 -1
  22. package/dist/admin/mail-test-card-labels.js +1 -1
  23. package/dist/admin/mail-test-card-labels.js.map +1 -1
  24. package/dist/admin/plan-change-labels.d.ts +2 -2
  25. package/dist/admin/plan-change-labels.js +1 -17
  26. package/dist/admin/plan-change-labels.js.map +1 -1
  27. package/dist/admin/plan-change.d.ts +3 -42
  28. package/dist/admin/plan-change.js +1 -1
  29. package/dist/admin/plan-change.js.map +1 -1
  30. package/dist/admin/reviewer-scope-editor.js +3 -3
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -1
  32. package/dist/auth/auth-errors-labels.d.ts +11 -0
  33. package/dist/auth/auth-errors-labels.js +13 -0
  34. package/dist/auth/auth-errors-labels.js.map +1 -0
  35. package/dist/auth/auth-errors.d.ts +7 -23
  36. package/dist/auth/auth-errors.js +2 -15
  37. package/dist/auth/auth-errors.js.map +1 -1
  38. package/dist/auth/complete-name-dialog.js +2 -1
  39. package/dist/auth/complete-name-dialog.js.map +1 -1
  40. package/dist/auth/forgot-password-form-labels.js +1 -1
  41. package/dist/auth/forgot-password-form-labels.js.map +1 -1
  42. package/dist/auth/forgot-password-form.js +7 -2
  43. package/dist/auth/forgot-password-form.js.map +1 -1
  44. package/dist/auth/register-form-labels.js +1 -1
  45. package/dist/auth/register-form-labels.js.map +1 -1
  46. package/dist/auth/reset-password-form-labels.js +1 -1
  47. package/dist/auth/reset-password-form-labels.js.map +1 -1
  48. package/dist/auth/reset-password-form.js +5 -1
  49. package/dist/auth/reset-password-form.js.map +1 -1
  50. package/dist/auth/sign-in-form-labels.js +1 -1
  51. package/dist/auth/sign-in-form-labels.js.map +1 -1
  52. package/dist/auth/status-parts.d.ts +0 -3
  53. package/dist/auth/status-parts.js +0 -4
  54. package/dist/auth/status-parts.js.map +1 -1
  55. package/dist/auth/verify-email-labels.js +1 -1
  56. package/dist/auth/verify-email-labels.js.map +1 -1
  57. package/dist/auth/verify-email.js +7 -2
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/billing/cancel-subscription.d.ts +20 -8
  60. package/dist/billing/cancel-subscription.js +7 -5
  61. package/dist/billing/cancel-subscription.js.map +1 -1
  62. package/dist/billing/dialog-close.d.ts +29 -0
  63. package/dist/billing/dialog-close.js +8 -0
  64. package/dist/billing/dialog-close.js.map +1 -0
  65. package/dist/billing/plan-card.d.ts +14 -4
  66. package/dist/billing/plan-card.js +15 -8
  67. package/dist/billing/plan-card.js.map +1 -1
  68. package/dist/billing/plan-change-labels.d.ts +42 -0
  69. package/dist/billing/plan-change-labels.js +22 -0
  70. package/dist/billing/plan-change-labels.js.map +1 -0
  71. package/dist/billing/plan-switch.d.ts +19 -7
  72. package/dist/billing/plan-switch.js +8 -4
  73. package/dist/billing/plan-switch.js.map +1 -1
  74. package/dist/chart.d.ts +12 -0
  75. package/dist/chart.js +16 -1
  76. package/dist/chart.js.map +1 -1
  77. package/dist/components/account-chips.d.ts +5 -5
  78. package/dist/components/account-chips.js +11 -13
  79. package/dist/components/account-chips.js.map +1 -1
  80. package/dist/components/alert-banner.d.ts +16 -5
  81. package/dist/components/alert-banner.js +46 -1
  82. package/dist/components/alert-banner.js.map +1 -1
  83. package/dist/components/amount-input.d.ts +13 -0
  84. package/dist/components/amount-input.js +1 -0
  85. package/dist/components/amount-input.js.map +1 -1
  86. package/dist/components/autocomplete.js +57 -65
  87. package/dist/components/autocomplete.js.map +1 -1
  88. package/dist/components/bulk-action-bar.d.ts +12 -2
  89. package/dist/components/bulk-action-bar.js +65 -21
  90. package/dist/components/bulk-action-bar.js.map +1 -1
  91. package/dist/components/button-look.d.ts +5 -5
  92. package/dist/components/button-look.js.map +1 -1
  93. package/dist/components/button.d.ts +2 -2
  94. package/dist/components/button.js.map +1 -1
  95. package/dist/components/chart-edge.d.ts +16 -0
  96. package/dist/components/chart-edge.js +14 -1
  97. package/dist/components/chart-edge.js.map +1 -1
  98. package/dist/components/chart-figure.d.ts +13 -0
  99. package/dist/components/chart-figure.js +15 -0
  100. package/dist/components/chart-figure.js.map +1 -0
  101. package/dist/components/chart-zoom-core.js +1 -4
  102. package/dist/components/chart-zoom-core.js.map +1 -1
  103. package/dist/components/chart.d.ts +8 -1
  104. package/dist/components/chart.js +19 -10
  105. package/dist/components/chart.js.map +1 -1
  106. package/dist/components/combobox-core.d.ts +111 -2
  107. package/dist/components/combobox-core.js +148 -42
  108. package/dist/components/combobox-core.js.map +1 -1
  109. package/dist/components/combobox.d.ts +1 -1
  110. package/dist/components/combobox.js +141 -130
  111. package/dist/components/combobox.js.map +1 -1
  112. package/dist/components/copy-button.d.ts +6 -5
  113. package/dist/components/copy-button.js +5 -3
  114. package/dist/components/copy-button.js.map +1 -1
  115. package/dist/components/country-select.js +2 -2
  116. package/dist/components/country-select.js.map +1 -1
  117. package/dist/components/data-table-column-settings.d.ts +1 -1
  118. package/dist/components/data-table-column-settings.js +33 -7
  119. package/dist/components/data-table-column-settings.js.map +1 -1
  120. package/dist/components/data-table-filters.d.ts +4 -2
  121. package/dist/components/data-table-filters.js +2 -2
  122. package/dist/components/data-table-filters.js.map +1 -1
  123. package/dist/components/data-table-fit.d.ts +6 -4
  124. package/dist/components/data-table-fit.js +4 -1
  125. package/dist/components/data-table-fit.js.map +1 -1
  126. package/dist/components/data-table-groups.d.ts +1 -1
  127. package/dist/components/data-table-groups.js.map +1 -1
  128. package/dist/components/data-table-head.d.ts +1 -1
  129. package/dist/components/data-table-head.js +15 -12
  130. package/dist/components/data-table-head.js.map +1 -1
  131. package/dist/components/data-table-mobile.d.ts +12 -7
  132. package/dist/components/data-table-mobile.js +43 -30
  133. package/dist/components/data-table-mobile.js.map +1 -1
  134. package/dist/components/data-table-pagination.js +11 -6
  135. package/dist/components/data-table-pagination.js.map +1 -1
  136. package/dist/components/data-table-resize.d.ts +1 -1
  137. package/dist/components/data-table-resize.js.map +1 -1
  138. package/dist/components/data-table-row-actions.d.ts +2 -2
  139. package/dist/components/data-table-row-actions.js.map +1 -1
  140. package/dist/components/data-table-row.d.ts +6 -2
  141. package/dist/components/data-table-row.js +17 -10
  142. package/dist/components/data-table-row.js.map +1 -1
  143. package/dist/components/data-table-selection.d.ts +1 -1
  144. package/dist/components/data-table-selection.js.map +1 -1
  145. package/dist/components/data-table-shared.d.ts +10 -2
  146. package/dist/components/data-table-shared.js +18 -0
  147. package/dist/components/data-table-shared.js.map +1 -1
  148. package/dist/components/data-table-sort.d.ts +21 -2
  149. package/dist/components/data-table-sort.js +24 -0
  150. package/dist/components/data-table-sort.js.map +1 -1
  151. package/dist/components/data-table-virtual.d.ts +1 -1
  152. package/dist/components/data-table-virtual.js +18 -6
  153. package/dist/components/data-table-virtual.js.map +1 -1
  154. package/dist/components/data-table.d.ts +13 -4
  155. package/dist/components/data-table.js +139 -36
  156. package/dist/components/data-table.js.map +1 -1
  157. package/dist/components/date-field.d.ts +24 -2
  158. package/dist/components/date-field.js +33 -8
  159. package/dist/components/date-field.js.map +1 -1
  160. package/dist/components/date-picker.d.ts +15 -2
  161. package/dist/components/date-picker.js +55 -26
  162. package/dist/components/date-picker.js.map +1 -1
  163. package/dist/components/date-range-picker.d.ts +47 -20
  164. package/dist/components/date-range-picker.js +23 -7
  165. package/dist/components/date-range-picker.js.map +1 -1
  166. package/dist/components/error-boundary.js +37 -30
  167. package/dist/components/error-boundary.js.map +1 -1
  168. package/dist/components/{ui-labels.d.ts → field-base-labels.d.ts} +0 -2
  169. package/dist/components/{ui-labels.js → field-base-labels.js} +2 -9
  170. package/dist/components/field-base-labels.js.map +1 -0
  171. package/dist/components/field-base.d.ts +4 -3
  172. package/dist/components/field-base.js +3 -3
  173. package/dist/components/field-base.js.map +1 -1
  174. package/dist/components/field-sync-labels.d.ts +1 -0
  175. package/dist/components/field-sync-labels.js.map +1 -1
  176. package/dist/components/field-sync.d.ts +23 -1
  177. package/dist/components/field-sync.js.map +1 -1
  178. package/dist/components/form-actions.js +11 -5
  179. package/dist/components/form-actions.js.map +1 -1
  180. package/dist/components/hover-menu.d.ts +3 -0
  181. package/dist/components/hover-menu.js +5 -3
  182. package/dist/components/hover-menu.js.map +1 -1
  183. package/dist/components/icon-button.js +14 -9
  184. package/dist/components/icon-button.js.map +1 -1
  185. package/dist/components/inline-confirm.d.ts +43 -0
  186. package/dist/components/inline-confirm.js +67 -0
  187. package/dist/components/inline-confirm.js.map +1 -0
  188. package/dist/components/inline-entity-combobox.d.ts +4 -3
  189. package/dist/components/inline-entity-combobox.js +148 -140
  190. package/dist/components/inline-entity-combobox.js.map +1 -1
  191. package/dist/components/input.js +1 -1
  192. package/dist/components/input.js.map +1 -1
  193. package/dist/components/jump-calendar.js +28 -112
  194. package/dist/components/jump-calendar.js.map +1 -1
  195. package/dist/components/legal-page.d.ts +3 -1
  196. package/dist/components/legal-page.js +3 -2
  197. package/dist/components/legal-page.js.map +1 -1
  198. package/dist/components/legal.d.ts +4 -2
  199. package/dist/components/legal.js.map +1 -1
  200. package/dist/components/lightbox.d.ts +2 -2
  201. package/dist/components/lightbox.js +17 -6
  202. package/dist/components/lightbox.js.map +1 -1
  203. package/dist/components/list.d.ts +6 -2
  204. package/dist/components/list.js +3 -2
  205. package/dist/components/list.js.map +1 -1
  206. package/dist/components/measured-grid.d.ts +1 -1
  207. package/dist/components/measured-grid.js +2 -1
  208. package/dist/components/measured-grid.js.map +1 -1
  209. package/dist/components/mini-calendar.js +3 -5
  210. package/dist/components/mini-calendar.js.map +1 -1
  211. package/dist/components/modal.js +3 -1
  212. package/dist/components/modal.js.map +1 -1
  213. package/dist/components/month-grid.d.ts +55 -0
  214. package/dist/components/month-grid.js +148 -0
  215. package/dist/components/month-grid.js.map +1 -0
  216. package/dist/components/month-picker.d.ts +36 -4
  217. package/dist/components/month-picker.js +123 -146
  218. package/dist/components/month-picker.js.map +1 -1
  219. package/dist/components/multi-select.d.ts +2 -2
  220. package/dist/components/multi-select.js +11 -6
  221. package/dist/components/multi-select.js.map +1 -1
  222. package/dist/components/picker-sheet.js +2 -1
  223. package/dist/components/picker-sheet.js.map +1 -1
  224. package/dist/components/pie-chart-labels.d.ts +7 -0
  225. package/dist/components/pie-chart-labels.js +1 -0
  226. package/dist/components/pie-chart-labels.js.map +1 -1
  227. package/dist/components/pie-chart.d.ts +5 -3
  228. package/dist/components/pie-chart.js +7 -22
  229. package/dist/components/pie-chart.js.map +1 -1
  230. package/dist/components/popover.d.ts +3 -0
  231. package/dist/components/popover.js +7 -1
  232. package/dist/components/popover.js.map +1 -1
  233. package/dist/components/row-actions.js +2 -1
  234. package/dist/components/row-actions.js.map +1 -1
  235. package/dist/components/section-nav.d.ts +7 -0
  236. package/dist/components/section-nav.js.map +1 -1
  237. package/dist/components/series-chart-axes.d.ts +3 -3
  238. package/dist/components/series-chart-axes.js.map +1 -1
  239. package/dist/components/series-chart-labels.d.ts +7 -4
  240. package/dist/components/series-chart-labels.js.map +1 -1
  241. package/dist/components/series-chart.d.ts +31 -7
  242. package/dist/components/series-chart.js +72 -29
  243. package/dist/components/series-chart.js.map +1 -1
  244. package/dist/components/share-card.js +3 -3
  245. package/dist/components/share-card.js.map +1 -1
  246. package/dist/components/sign-chip-labels.d.ts +1 -0
  247. package/dist/components/sign-chip-labels.js.map +1 -1
  248. package/dist/components/sign-chip.d.ts +10 -1
  249. package/dist/components/sign-chip.js.map +1 -1
  250. package/dist/components/tabs-labels.d.ts +2 -0
  251. package/dist/components/tabs-labels.js +11 -0
  252. package/dist/components/tabs-labels.js.map +1 -0
  253. package/dist/components/tabs.d.ts +1 -1
  254. package/dist/components/tabs.js +1 -1
  255. package/dist/components/tabs.js.map +1 -1
  256. package/dist/components/toggle-legend.d.ts +2 -2
  257. package/dist/components/toggle-legend.js +6 -6
  258. package/dist/components/toggle-legend.js.map +1 -1
  259. package/dist/components/translation-review-editor.d.ts +4 -2
  260. package/dist/components/translation-review-editor.js +2 -3
  261. package/dist/components/translation-review-editor.js.map +1 -1
  262. package/dist/components/translation-review.d.ts +2 -1
  263. package/dist/components/translation-review.js +2 -2
  264. package/dist/components/translation-review.js.map +1 -1
  265. package/dist/components/treemap-labels.d.ts +26 -0
  266. package/dist/components/treemap-labels.js +10 -0
  267. package/dist/components/treemap-labels.js.map +1 -0
  268. package/dist/components/treemap-layout.d.ts +62 -0
  269. package/dist/components/treemap-layout.js +126 -0
  270. package/dist/components/treemap-layout.js.map +1 -0
  271. package/dist/components/treemap.d.ts +140 -17
  272. package/dist/components/treemap.js +324 -97
  273. package/dist/components/treemap.js.map +1 -1
  274. package/dist/components/use-table-state.d.ts +5 -5
  275. package/dist/components/use-table-state.js +4 -1
  276. package/dist/components/use-table-state.js.map +1 -1
  277. package/dist/data-table.d.ts +6 -2
  278. package/dist/data-table.js +7 -2
  279. package/dist/data-table.js.map +1 -1
  280. package/dist/eslint/index.js +1 -1
  281. package/dist/eslint/role-utilities.d.ts +36 -7
  282. package/dist/eslint/role-utilities.js +42 -9
  283. package/dist/eslint/role-utilities.js.map +1 -1
  284. package/dist/feedback/feedback-dialog.d.ts +25 -16
  285. package/dist/feedback/feedback-dialog.js +6 -2
  286. package/dist/feedback/feedback-dialog.js.map +1 -1
  287. package/dist/feedback/feedback-mutation.d.ts +30 -0
  288. package/dist/feedback/feedback-mutation.js +16 -0
  289. package/dist/feedback/feedback-mutation.js.map +1 -0
  290. package/dist/feedback/feedback-page-link.d.ts +53 -0
  291. package/dist/feedback/feedback-page-link.js +37 -0
  292. package/dist/feedback/feedback-page-link.js.map +1 -0
  293. package/dist/feedback/feedback-public-list.d.ts +12 -3
  294. package/dist/feedback/feedback-public-list.js +2 -1
  295. package/dist/feedback/feedback-public-list.js.map +1 -1
  296. package/dist/feedback/feedback-replies.d.ts +3 -1
  297. package/dist/feedback/feedback-replies.js +5 -4
  298. package/dist/feedback/feedback-replies.js.map +1 -1
  299. package/dist/feedback/feedback-row-detail-labels.js +5 -0
  300. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  301. package/dist/feedback/feedback-row-detail.d.ts +64 -28
  302. package/dist/feedback/feedback-row-detail.js +30 -28
  303. package/dist/feedback/feedback-row-detail.js.map +1 -1
  304. package/dist/feedback/feedback-status-undo.d.ts +30 -11
  305. package/dist/feedback/feedback-status-undo.js +10 -15
  306. package/dist/feedback/feedback-status-undo.js.map +1 -1
  307. package/dist/feedback/feedback-submit-dialog.d.ts +1 -1
  308. package/dist/feedback/feedback-submit-dialog.js +1 -1
  309. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  310. package/dist/feedback/feedback-submit.js +9 -3
  311. package/dist/feedback/feedback-submit.js.map +1 -1
  312. package/dist/feedback/feedback-swipe.d.ts +1 -1
  313. package/dist/feedback/feedback-swipe.js.map +1 -1
  314. package/dist/feedback/feedback-table.d.ts +21 -9
  315. package/dist/feedback/feedback-table.js +45 -25
  316. package/dist/feedback/feedback-table.js.map +1 -1
  317. package/dist/feedback/feedback-thread-labels.d.ts +2 -2
  318. package/dist/feedback/feedback-thread-labels.js.map +1 -1
  319. package/dist/feedback/feedback-thread.d.ts +24 -23
  320. package/dist/feedback/feedback-thread.js +2 -1
  321. package/dist/feedback/feedback-thread.js.map +1 -1
  322. package/dist/feedback.d.ts +34 -0
  323. package/dist/feedback.js +42 -0
  324. package/dist/feedback.js.map +1 -1
  325. package/dist/hooks/use-focus-trap.d.ts +35 -0
  326. package/dist/hooks/use-focus-trap.js +6 -4
  327. package/dist/hooks/use-focus-trap.js.map +1 -1
  328. package/dist/hooks/use-hotkey.d.ts +5 -2
  329. package/dist/hooks/use-hotkey.js +5 -4
  330. package/dist/hooks/use-hotkey.js.map +1 -1
  331. package/dist/hooks/use-long-press.d.ts +7 -4
  332. package/dist/hooks/use-long-press.js +3 -0
  333. package/dist/hooks/use-long-press.js.map +1 -1
  334. package/dist/hooks/use-media-query.js +12 -4
  335. package/dist/hooks/use-media-query.js.map +1 -1
  336. package/dist/hooks/use-mounted.d.ts +22 -0
  337. package/dist/hooks/use-mounted.js +24 -0
  338. package/dist/hooks/use-mounted.js.map +1 -0
  339. package/dist/hooks/use-overlay-history.js +19 -3
  340. package/dist/hooks/use-overlay-history.js.map +1 -1
  341. package/dist/i18n/defaults.js +4 -5
  342. package/dist/i18n/defaults.js.map +1 -1
  343. package/dist/i18n/german.js +7 -1
  344. package/dist/i18n/german.js.map +1 -1
  345. package/dist/i18n/kit-labels.d.ts +9 -2
  346. package/dist/i18n/kit-labels.js.map +1 -1
  347. package/dist/i18n/locales/en.js +20 -41
  348. package/dist/i18n/locales/en.js.map +3 -3
  349. package/dist/i18n/locales/es.js +7 -1
  350. package/dist/i18n/locales/es.js.map +1 -1
  351. package/dist/i18n/locales/fr.js +20 -10
  352. package/dist/i18n/locales/fr.js.map +1 -1
  353. package/dist/i18n/locales/hu.js +9 -1
  354. package/dist/i18n/locales/hu.js.map +1 -1
  355. package/dist/i18n/locales/it.js +7 -1
  356. package/dist/i18n/locales/it.js.map +1 -1
  357. package/dist/i18n/locales/zh.js +8 -1
  358. package/dist/i18n/locales/zh.js.map +1 -1
  359. package/dist/i18n/review.js +5 -0
  360. package/dist/i18n/review.js.map +1 -1
  361. package/dist/index.d.ts +64 -17
  362. package/dist/index.js +77 -12
  363. package/dist/index.js.map +1 -1
  364. package/dist/landing/routing.d.ts +3 -1
  365. package/dist/landing/routing.js +4 -7
  366. package/dist/landing/routing.js.map +1 -1
  367. package/dist/lib/broadcast.d.ts +17 -0
  368. package/dist/lib/broadcast.js +30 -0
  369. package/dist/lib/broadcast.js.map +1 -0
  370. package/dist/lib/date-format.d.ts +37 -2
  371. package/dist/lib/date-format.js +34 -8
  372. package/dist/lib/date-format.js.map +1 -1
  373. package/dist/lib/day-format.d.ts +46 -0
  374. package/dist/lib/day-format.js +46 -0
  375. package/dist/lib/day-format.js.map +1 -0
  376. package/dist/lib/describe-failure.d.ts +14 -0
  377. package/dist/lib/describe-failure.js +9 -0
  378. package/dist/lib/describe-failure.js.map +1 -0
  379. package/dist/lib/direction.d.ts +16 -0
  380. package/dist/lib/direction.js +9 -1
  381. package/dist/lib/direction.js.map +1 -1
  382. package/dist/lib/focus-left.d.ts +63 -0
  383. package/dist/lib/focus-left.js +67 -0
  384. package/dist/lib/focus-left.js.map +1 -0
  385. package/dist/lib/format.js +5 -14
  386. package/dist/lib/format.js.map +1 -1
  387. package/dist/lib/http-status.d.ts +24 -0
  388. package/dist/lib/http-status.js +14 -0
  389. package/dist/lib/http-status.js.map +1 -0
  390. package/dist/lib/intl-cache.d.ts +14 -0
  391. package/dist/lib/intl-cache.js +15 -0
  392. package/dist/lib/intl-cache.js.map +1 -0
  393. package/dist/lib/isin.d.ts +10 -0
  394. package/dist/lib/isin.js.map +1 -1
  395. package/dist/lib/iso-day.d.ts +11 -0
  396. package/dist/lib/iso-day.js +8 -0
  397. package/dist/lib/iso-day.js.map +1 -0
  398. package/dist/lib/lazy-with-fallback.d.ts +31 -0
  399. package/dist/lib/lazy-with-fallback.js +40 -0
  400. package/dist/lib/lazy-with-fallback.js.map +1 -0
  401. package/dist/lib/path-pattern.d.ts +34 -0
  402. package/dist/lib/path-pattern.js +24 -0
  403. package/dist/lib/path-pattern.js.map +1 -0
  404. package/dist/lib/reduced-motion.d.ts +2 -1
  405. package/dist/lib/reduced-motion.js.map +1 -1
  406. package/dist/lib/round.d.ts +2 -1
  407. package/dist/lib/round.js +2 -1
  408. package/dist/lib/round.js.map +1 -1
  409. package/dist/lib/safe-storage.d.ts +24 -5
  410. package/dist/lib/safe-storage.js +27 -2
  411. package/dist/lib/safe-storage.js.map +1 -1
  412. package/dist/lib/session-refresh.js +1 -1
  413. package/dist/lib/session-refresh.js.map +1 -1
  414. package/dist/lib/share-format.d.ts +13 -0
  415. package/dist/lib/share-format.js +10 -0
  416. package/dist/lib/share-format.js.map +1 -0
  417. package/dist/lib/text-case.d.ts +17 -0
  418. package/dist/lib/text-case.js +8 -0
  419. package/dist/lib/text-case.js.map +1 -0
  420. package/dist/lib/text-match.d.ts +29 -0
  421. package/dist/lib/text-match.js +20 -2
  422. package/dist/lib/text-match.js.map +1 -1
  423. package/dist/lib/thenable.d.ts +13 -0
  424. package/dist/lib/thenable.js +8 -0
  425. package/dist/lib/thenable.js.map +1 -0
  426. package/dist/lib/translation-review.d.ts +7 -2
  427. package/dist/lib/translation-review.js +4 -4
  428. package/dist/lib/translation-review.js.map +1 -1
  429. package/dist/notifications/device-zone-report.js +1 -1
  430. package/dist/notifications/device-zone-report.js.map +1 -1
  431. package/dist/notifications/notification-bell.d.ts +2 -1
  432. package/dist/notifications/notification-bell.js +3 -3
  433. package/dist/notifications/notification-bell.js.map +1 -1
  434. package/dist/notifications/notification-inbox.d.ts +10 -6
  435. package/dist/notifications/notification-inbox.js +26 -48
  436. package/dist/notifications/notification-inbox.js.map +1 -1
  437. package/dist/notifications/notification-parts.d.ts +3 -2
  438. package/dist/notifications/notification-parts.js +1 -3
  439. package/dist/notifications/notification-parts.js.map +1 -1
  440. package/dist/notifications/notification-types-setting.js +1 -1
  441. package/dist/notifications/notification-types-setting.js.map +1 -1
  442. package/dist/notifications/notifications-labels.d.ts +6 -0
  443. package/dist/notifications/notifications-labels.js +1 -0
  444. package/dist/notifications/notifications-labels.js.map +1 -1
  445. package/dist/notifications/push-device-setting.js +1 -1
  446. package/dist/notifications/push-device-setting.js.map +1 -1
  447. package/dist/notifications/push-devices-setting.d.ts +2 -1
  448. package/dist/notifications/push-devices-setting.js +27 -49
  449. package/dist/notifications/push-devices-setting.js.map +1 -1
  450. package/dist/notifications/quiet-hours-setting.d.ts +10 -3
  451. package/dist/notifications/quiet-hours-setting.js +65 -26
  452. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  453. package/dist/notifications/unsubscribe-panel.js +4 -7
  454. package/dist/notifications/unsubscribe-panel.js.map +1 -1
  455. package/dist/notifications/use-push-device.d.ts +39 -1
  456. package/dist/notifications/use-push-device.js +28 -7
  457. package/dist/notifications/use-push-device.js.map +1 -1
  458. package/dist/offline/query-persistence.d.ts +3 -1
  459. package/dist/offline/query-persistence.js +19 -2
  460. package/dist/offline/query-persistence.js.map +1 -1
  461. package/dist/rhf/fields.d.ts +11 -4
  462. package/dist/rhf/fields.js.map +1 -1
  463. package/dist/search/command-palette.js +1 -1
  464. package/dist/search/command-palette.js.map +1 -1
  465. package/dist/search/global-search.js +19 -9
  466. package/dist/search/global-search.js.map +1 -1
  467. package/dist/search/use-command-key.d.ts +12 -1
  468. package/dist/search/use-command-key.js +3 -12
  469. package/dist/search/use-command-key.js.map +1 -1
  470. package/dist/search.d.ts +7 -0
  471. package/dist/search.js +10 -0
  472. package/dist/search.js.map +1 -1
  473. package/dist/settings/use-settings-focus.js +1 -0
  474. package/dist/settings/use-settings-focus.js.map +1 -1
  475. package/dist/shell/account-menu-language.d.ts +21 -5
  476. package/dist/shell/account-menu-language.js +3 -1
  477. package/dist/shell/account-menu-language.js.map +1 -1
  478. package/dist/shell/account-menu-text-size.d.ts +26 -9
  479. package/dist/shell/account-menu-text-size.js +3 -2
  480. package/dist/shell/account-menu-text-size.js.map +1 -1
  481. package/dist/shell/dev-menu.js +5 -18
  482. package/dist/shell/dev-menu.js.map +1 -1
  483. package/dist/shell/role-switcher.d.ts +7 -0
  484. package/dist/shell/role-switcher.js.map +1 -1
  485. package/dist/shell/top-bar-actions.d.ts +1 -1
  486. package/dist/shell/top-bar-actions.js.map +1 -1
  487. package/dist/shell/top-bar-parts-labels.d.ts +0 -2
  488. package/dist/shell/top-bar-parts-labels.js +0 -1
  489. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  490. package/dist/shell.d.ts +27 -8
  491. package/dist/shell.js +28 -7
  492. package/dist/shell.js.map +1 -1
  493. package/dist/sync/engine.d.ts +30 -4
  494. package/dist/sync/engine.js +172 -52
  495. package/dist/sync/engine.js.map +1 -1
  496. package/dist/sync.d.ts +6 -2
  497. package/dist/sync.js +6 -1
  498. package/dist/sync.js.map +1 -1
  499. package/dist/testing/css-variables.js +3 -8
  500. package/dist/testing/css-variables.js.map +1 -1
  501. package/dist/theme/contrast.d.ts +2 -1
  502. package/dist/theme/contrast.js +3 -8
  503. package/dist/theme/contrast.js.map +1 -1
  504. package/dist/theme/palette-store.js +6 -6
  505. package/dist/theme/palette-store.js.map +1 -1
  506. package/dist/theme/text-size.js +3 -8
  507. package/dist/theme/text-size.js.map +1 -1
  508. package/dist/theme/theme-store.js +3 -6
  509. package/dist/theme/theme-store.js.map +1 -1
  510. package/dist/tour/tour-catalogue.d.ts +7 -4
  511. package/dist/tour/tour-catalogue.js +12 -10
  512. package/dist/tour/tour-catalogue.js.map +1 -1
  513. package/dist/tour/tour-dock.d.ts +0 -2
  514. package/dist/tour/tour-dock.js +0 -2
  515. package/dist/tour/tour-dock.js.map +1 -1
  516. package/dist/tour/tour-gates.d.ts +4 -0
  517. package/dist/tour/tour-gates.js +14 -0
  518. package/dist/tour/tour-gates.js.map +1 -1
  519. package/dist/tour/tour-overlay.d.ts +27 -0
  520. package/dist/tour/tour-overlay.js +741 -0
  521. package/dist/tour/tour-overlay.js.map +1 -0
  522. package/dist/tour/tour.d.ts +13 -3
  523. package/dist/tour/tour.js +59 -781
  524. package/dist/tour/tour.js.map +1 -1
  525. package/dist/tour/use-tour-progress.js +1 -1
  526. package/dist/tour/use-tour-progress.js.map +1 -1
  527. package/dist/wizard/stepper-nav.d.ts +5 -1
  528. package/dist/wizard/stepper-nav.js.map +1 -1
  529. package/dist/wizard/types.d.ts +6 -2
  530. package/dist/wizard/types.js.map +1 -1
  531. package/dist/wizard/use-wizard.d.ts +3 -1
  532. package/dist/wizard/use-wizard.js.map +1 -1
  533. package/dist/wizard/validation.d.ts +3 -1
  534. package/dist/wizard/validation.js.map +1 -1
  535. package/package.json +10 -6
  536. package/src/account/account-parts.ts +5 -25
  537. package/src/account/data-export-setting-labels.ts +1 -1
  538. package/src/account/delete-account-setting-labels.ts +1 -1
  539. package/src/account/email-change-setting-labels.ts +1 -1
  540. package/src/account/passkeys-setting.tsx +60 -38
  541. package/src/account/sessions-setting.tsx +18 -12
  542. package/src/admin/admin-action-confirm.tsx +1 -5
  543. package/src/admin/admin-parts.ts +11 -25
  544. package/src/admin/mail-test-card-labels.ts +1 -1
  545. package/src/admin/plan-change-labels.ts +5 -23
  546. package/src/admin/plan-change.tsx +7 -44
  547. package/src/admin/reviewer-scope-editor.tsx +3 -3
  548. package/src/auth/auth-errors-labels.ts +23 -0
  549. package/src/auth/auth-errors.ts +11 -38
  550. package/src/auth/complete-name-dialog.tsx +4 -1
  551. package/src/auth/forgot-password-form-labels.ts +1 -1
  552. package/src/auth/forgot-password-form.tsx +11 -6
  553. package/src/auth/register-form-labels.ts +1 -1
  554. package/src/auth/reset-password-form-labels.ts +1 -1
  555. package/src/auth/reset-password-form.tsx +8 -4
  556. package/src/auth/sign-in-form-labels.ts +1 -1
  557. package/src/auth/status-parts.tsx +0 -6
  558. package/src/auth/verify-email-labels.ts +1 -1
  559. package/src/auth/verify-email.tsx +11 -6
  560. package/src/billing/cancel-subscription.tsx +26 -12
  561. package/src/billing/dialog-close.ts +34 -0
  562. package/src/billing/plan-card.tsx +36 -11
  563. package/src/billing/plan-change-labels.ts +66 -0
  564. package/src/billing/plan-switch.tsx +26 -12
  565. package/src/chart.ts +21 -3
  566. package/src/components/account-chips.tsx +20 -23
  567. package/src/components/alert-banner.tsx +82 -5
  568. package/src/components/amount-input.tsx +18 -7
  569. package/src/components/autocomplete.tsx +64 -70
  570. package/src/components/bulk-action-bar.tsx +70 -2
  571. package/src/components/button-look.ts +5 -5
  572. package/src/components/button.tsx +2 -2
  573. package/src/components/chart-edge.ts +34 -1
  574. package/src/components/chart-figure.tsx +35 -0
  575. package/src/components/chart-zoom-core.tsx +6 -5
  576. package/src/components/chart.tsx +26 -5
  577. package/src/components/combobox-core.tsx +293 -62
  578. package/src/components/combobox.tsx +183 -153
  579. package/src/components/copy-button.tsx +34 -23
  580. package/src/components/country-select.tsx +16 -11
  581. package/src/components/data-table-column-settings.tsx +52 -10
  582. package/src/components/data-table-filters.ts +6 -4
  583. package/src/components/data-table-fit.ts +19 -5
  584. package/src/components/data-table-groups.tsx +1 -1
  585. package/src/components/data-table-head.tsx +32 -22
  586. package/src/components/data-table-mobile.tsx +61 -26
  587. package/src/components/data-table-pagination.tsx +21 -6
  588. package/src/components/data-table-resize.ts +1 -1
  589. package/src/components/data-table-row-actions.tsx +3 -3
  590. package/src/components/data-table-row.tsx +25 -14
  591. package/src/components/data-table-selection.ts +1 -1
  592. package/src/components/data-table-shared.tsx +33 -1
  593. package/src/components/data-table-sort.ts +57 -2
  594. package/src/components/data-table-virtual.ts +32 -8
  595. package/src/components/data-table.tsx +239 -68
  596. package/src/components/date-field.tsx +65 -8
  597. package/src/components/date-picker.tsx +125 -48
  598. package/src/components/date-range-picker.tsx +82 -31
  599. package/src/components/error-boundary.tsx +53 -39
  600. package/src/components/field-base-labels.ts +20 -0
  601. package/src/components/field-base.tsx +13 -7
  602. package/src/components/field-sync-labels.ts +1 -0
  603. package/src/components/field-sync.tsx +23 -1
  604. package/src/components/form-actions.tsx +20 -5
  605. package/src/components/hover-menu.tsx +18 -8
  606. package/src/components/icon-button.tsx +14 -9
  607. package/src/components/inline-confirm.tsx +116 -0
  608. package/src/components/inline-entity-combobox.tsx +197 -153
  609. package/src/components/input.tsx +1 -1
  610. package/src/components/jump-calendar.tsx +30 -146
  611. package/src/components/legal-page.tsx +10 -7
  612. package/src/components/legal.tsx +4 -2
  613. package/src/components/lightbox.tsx +21 -8
  614. package/src/components/list.tsx +10 -4
  615. package/src/components/measured-grid.tsx +8 -4
  616. package/src/components/mini-calendar.tsx +6 -5
  617. package/src/components/modal.tsx +6 -1
  618. package/src/components/month-grid.tsx +229 -0
  619. package/src/components/month-picker.tsx +192 -200
  620. package/src/components/multi-select.tsx +21 -9
  621. package/src/components/picker-sheet.tsx +12 -9
  622. package/src/components/pie-chart-labels.ts +8 -0
  623. package/src/components/pie-chart.tsx +22 -28
  624. package/src/components/popover.tsx +18 -1
  625. package/src/components/row-actions.tsx +2 -1
  626. package/src/components/section-nav.tsx +7 -0
  627. package/src/components/series-chart-axes.ts +3 -3
  628. package/src/components/series-chart-labels.ts +7 -4
  629. package/src/components/series-chart.tsx +178 -69
  630. package/src/components/share-card.tsx +6 -6
  631. package/src/components/sign-chip-labels.ts +1 -0
  632. package/src/components/sign-chip.tsx +10 -1
  633. package/src/components/tabs-labels.ts +13 -0
  634. package/src/components/tabs.tsx +2 -2
  635. package/src/components/toggle-legend.tsx +16 -11
  636. package/src/components/translation-review-editor.tsx +6 -5
  637. package/src/components/translation-review.tsx +4 -3
  638. package/src/components/treemap-labels.ts +39 -0
  639. package/src/components/treemap-layout.ts +210 -0
  640. package/src/components/treemap.tsx +722 -133
  641. package/src/components/use-table-state.ts +12 -9
  642. package/src/data-table.ts +9 -2
  643. package/src/eslint/role-utilities.ts +97 -19
  644. package/src/feedback/feedback-dialog.tsx +30 -19
  645. package/src/feedback/feedback-mutation.ts +41 -0
  646. package/src/feedback/feedback-page-link.ts +90 -0
  647. package/src/feedback/feedback-public-list.tsx +17 -5
  648. package/src/feedback/feedback-replies.tsx +13 -6
  649. package/src/feedback/feedback-row-detail-labels.ts +5 -0
  650. package/src/feedback/feedback-row-detail.tsx +103 -67
  651. package/src/feedback/feedback-status-undo.ts +41 -27
  652. package/src/feedback/feedback-submit-dialog.tsx +2 -2
  653. package/src/feedback/feedback-submit.tsx +21 -7
  654. package/src/feedback/feedback-swipe.tsx +1 -1
  655. package/src/feedback/feedback-table.tsx +86 -38
  656. package/src/feedback/feedback-thread-labels.ts +2 -2
  657. package/src/feedback/feedback-thread.tsx +27 -25
  658. package/src/feedback.ts +42 -0
  659. package/src/hooks/use-focus-trap.ts +8 -24
  660. package/src/hooks/use-hotkey.ts +24 -7
  661. package/src/hooks/use-long-press.ts +16 -2
  662. package/src/hooks/use-media-query.ts +20 -4
  663. package/src/hooks/use-mounted.ts +39 -0
  664. package/src/hooks/use-overlay-history.ts +32 -3
  665. package/src/i18n/defaults.ts +4 -5
  666. package/src/i18n/german.ts +8 -2
  667. package/src/i18n/kit-labels.tsx +9 -2
  668. package/src/i18n/locales/es.ts +8 -2
  669. package/src/i18n/locales/fr.ts +21 -13
  670. package/src/i18n/locales/hu.ts +10 -2
  671. package/src/i18n/locales/it.ts +8 -2
  672. package/src/i18n/locales/zh.ts +9 -2
  673. package/src/i18n/review.ts +5 -0
  674. package/src/index.ts +103 -13
  675. package/src/landing/routing.tsx +14 -10
  676. package/src/lib/broadcast.ts +52 -0
  677. package/src/lib/date-format.ts +89 -13
  678. package/src/lib/day-format.ts +96 -0
  679. package/src/lib/describe-failure.ts +18 -0
  680. package/src/lib/direction.ts +39 -0
  681. package/src/lib/focus-left.ts +156 -0
  682. package/src/lib/format.ts +5 -17
  683. package/src/lib/http-status.ts +38 -0
  684. package/src/lib/intl-cache.ts +25 -0
  685. package/src/lib/isin.ts +10 -0
  686. package/src/lib/iso-day.ts +13 -0
  687. package/src/lib/lazy-with-fallback.ts +82 -0
  688. package/src/lib/path-pattern.ts +55 -0
  689. package/src/lib/reduced-motion.ts +2 -1
  690. package/src/lib/round.ts +7 -2
  691. package/src/lib/safe-storage.ts +48 -6
  692. package/src/lib/session-refresh.ts +1 -1
  693. package/src/lib/share-format.ts +18 -0
  694. package/src/lib/text-case.ts +19 -0
  695. package/src/lib/text-match.ts +57 -2
  696. package/src/lib/thenable.ts +19 -0
  697. package/src/lib/translation-review.ts +13 -5
  698. package/src/notifications/device-zone-report.ts +1 -1
  699. package/src/notifications/notification-bell.tsx +7 -4
  700. package/src/notifications/notification-inbox.tsx +40 -58
  701. package/src/notifications/notification-parts.tsx +3 -4
  702. package/src/notifications/notification-types-setting.tsx +1 -1
  703. package/src/notifications/notifications-labels.ts +7 -0
  704. package/src/notifications/push-device-setting.tsx +1 -1
  705. package/src/notifications/push-devices-setting.tsx +30 -55
  706. package/src/notifications/quiet-hours-setting.tsx +93 -31
  707. package/src/notifications/unsubscribe-panel.tsx +4 -7
  708. package/src/notifications/use-push-device.ts +76 -10
  709. package/src/offline/query-persistence.ts +35 -3
  710. package/src/rhf/fields.tsx +11 -4
  711. package/src/search/command-palette.tsx +2 -4
  712. package/src/search/global-search.tsx +36 -15
  713. package/src/search/use-command-key.ts +13 -12
  714. package/src/search.ts +9 -0
  715. package/src/settings/use-settings-focus.ts +3 -0
  716. package/src/shell/account-menu-language.tsx +27 -5
  717. package/src/shell/account-menu-text-size.tsx +32 -10
  718. package/src/shell/dev-menu.tsx +7 -21
  719. package/src/shell/role-switcher.tsx +7 -0
  720. package/src/shell/top-bar-actions.tsx +1 -1
  721. package/src/shell/top-bar-parts-labels.ts +0 -3
  722. package/src/shell.ts +40 -9
  723. package/src/sync/engine.ts +339 -61
  724. package/src/sync.ts +8 -1
  725. package/src/testing/css-variables.ts +8 -9
  726. package/src/theme/contrast.ts +8 -9
  727. package/src/theme/palette-store.ts +9 -9
  728. package/src/theme/text-size.ts +6 -8
  729. package/src/theme/theme-store.ts +7 -9
  730. package/src/tour/tour-catalogue.tsx +26 -15
  731. package/src/tour/tour-dock.ts +0 -2
  732. package/src/tour/tour-gates.ts +23 -0
  733. package/src/tour/tour-overlay.tsx +1170 -0
  734. package/src/tour/tour.tsx +108 -1202
  735. package/src/tour/use-tour-progress.ts +1 -1
  736. package/src/wizard/stepper-nav.tsx +3 -1
  737. package/src/wizard/types.ts +11 -2
  738. package/src/wizard/use-wizard.ts +2 -1
  739. package/src/wizard/validation.ts +8 -2
  740. package/tokens.css +2 -1
  741. package/dist/components/ui-labels.js.map +0 -1
  742. package/dist/shell/dev-switchers.d.ts +0 -6
  743. package/dist/shell/dev-switchers.js +0 -12
  744. package/dist/shell/dev-switchers.js.map +0 -1
  745. package/src/components/ui-labels.ts +0 -24
  746. package/src/shell/dev-switchers.tsx +0 -18
@@ -20,7 +20,9 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
20
20
  held?: boolean;
21
21
  /**
22
22
  * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
23
- * Never a zone. The field shows the new value while the call runs and goes back if it
23
+ * Never a zone. Called when a time is ENTERED — the field left, or Enter, or the card
24
+ * unmounted with a time still typed — never per keystroke, and one call per field at a
25
+ * time (0.52). The field shows the new value while the call runs and goes back if it
24
26
  * rejects.
25
27
  */
26
28
  onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
@@ -45,8 +47,13 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
45
47
  * only** — the zone of the device last used, else `Europe/Zurich`. The card names it
46
48
  * read-only and never sends one; there is no picker and no "Use it".
47
49
  *
48
- * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
49
- * one is set the card says so: the window fails open until both are.
50
+ * - **Two times**, each saved when it is entered — on leaving the field or on Enter, or
51
+ * when the card goes while it is still typed — and a cleared time sends `null`. While
52
+ * only one is set the card says so: the window fails open until both are.
53
+ * - **One request per field at a time** (0.52): a time entered while that field's save
54
+ * runs waits for it, and only the latest waiting one is sent. Each input event used to
55
+ * PATCH — typing "30" over 22:00's minutes sent 22:03 and 22:30, and whichever answer
56
+ * landed last stayed on the server (refactor audit 2026-10-30, FLW-4).
50
57
  * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.
51
58
  *
52
59
  * Under a write lock the fields are `commit` controls exempt from billing.
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useId, useState } from "react";
3
+ import { useEffect, useId, useRef, useState } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
5
  import { Card } from "../components/card.js";
6
6
  import { TimeInput } from "../components/time-input.js";
7
7
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock.js";
8
8
  import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes.js";
9
9
  import { SettingsCardTitle } from "../settings/settings-heading.js";
10
- import { settle, useMounted } from "../account/account-parts.js";
10
+ import { settle, useMounted } from "../hooks/use-mounted.js";
11
11
  import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts.js";
12
12
  const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
13
13
  function zoneName(zone, regions) {
@@ -36,29 +36,64 @@ function QuietHoursSetting({
36
36
  const [failure, setFailure] = useState(null);
37
37
  const mounted = useMounted();
38
38
  const id = useId();
39
- const shown = (field, server) => overlay.has(field) ? overlay.get(field) ?? null : server;
40
- const startShown = shown("quiet_hours_start", start);
41
- const endShown = shown("quiet_hours_end", end);
39
+ const [drafts, setDrafts] = useState({});
40
+ const running = useRef({});
41
+ const waiting = useRef({});
42
+ const server = (field) => field === "quiet_hours_start" ? start : end;
43
+ const saved = (field) => overlay.has(field) ? overlay.get(field) ?? null : server(field);
44
+ const shown = (field) => drafts[field] ?? saved(field) ?? "";
45
+ const startShown = shown("quiet_hours_start");
46
+ const endShown = shown("quiet_hours_end");
42
47
  const accountZone = zone ?? fallbackZone;
43
- const save = (patch) => {
44
- const fields = Object.keys(patch);
45
- setFailure(null);
46
- for (const field of fields) overlay.set(field, patch[field] ?? null);
47
- settle(() => onChange(patch)).then(
48
- () => {
49
- if (mounted.current) for (const field of fields) overlay.settle(field, true);
50
- },
51
- (error) => {
52
- if (!mounted.current) return;
53
- for (const field of fields) overlay.settle(field, false);
54
- setFailure(describeFailure(describeError?.(error), labels.failed));
55
- }
48
+ const send = (field, value) => {
49
+ running.current[field] = true;
50
+ if (mounted.current) setFailure(null);
51
+ settle(() => onChange({ [field]: value })).then(
52
+ () => done(field, true),
53
+ (error) => done(field, false, error)
56
54
  );
57
55
  };
58
- const saveTime = (field, value) => {
59
- const next = value || null;
60
- if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
61
- save({ [field]: next });
56
+ const done = (field, ok, error) => {
57
+ running.current[field] = false;
58
+ if (Object.prototype.hasOwnProperty.call(waiting.current, field)) {
59
+ const next = waiting.current[field] ?? null;
60
+ delete waiting.current[field];
61
+ send(field, next);
62
+ return;
63
+ }
64
+ if (!mounted.current) return;
65
+ overlay.settle(field, ok);
66
+ if (!ok) setFailure(describeFailure(describeError?.(error), labels.failed));
67
+ };
68
+ const save = (field, draft) => {
69
+ const next = draft || null;
70
+ if (next === saved(field)) return void 0;
71
+ if (running.current[field]) waiting.current[field] = next;
72
+ else send(field, next);
73
+ return next;
74
+ };
75
+ const enter = (field) => {
76
+ const draft = drafts[field];
77
+ if (draft === void 0) return;
78
+ setDrafts((current) => {
79
+ const rest2 = { ...current };
80
+ delete rest2[field];
81
+ return rest2;
82
+ });
83
+ const next = save(field, draft);
84
+ if (next !== void 0) overlay.set(field, next);
85
+ };
86
+ const leave = useRef(() => {
87
+ });
88
+ useEffect(() => {
89
+ leave.current = () => {
90
+ for (const [field, draft] of Object.entries(drafts)) save(field, draft);
91
+ };
92
+ });
93
+ useEffect(() => () => leave.current(), []);
94
+ const type = (field, value) => setDrafts((current) => ({ ...current, [field]: value }));
95
+ const enterOnEnter = (field, event) => {
96
+ if (event.key === "Enter") enter(field);
62
97
  };
63
98
  const anySet = Boolean(startShown || endShown);
64
99
  const halfSet = Boolean(startShown) !== Boolean(endShown);
@@ -73,10 +108,12 @@ function QuietHoursSetting({
73
108
  {
74
109
  id: `${id}-start`,
75
110
  label: labels.quietFrom,
76
- value: startShown ?? "",
111
+ value: startShown,
112
+ onValueChange: (value) => type("quiet_hours_start", value),
113
+ onBlur: () => enter("quiet_hours_start"),
114
+ onKeyDown: (event) => enterOnEnter("quiet_hours_start", event),
77
115
  disabled,
78
116
  commit: COMMIT_EXCEPT_BILLING,
79
- onValueChange: (value) => saveTime("quiet_hours_start", value),
80
117
  className: "w-36"
81
118
  }
82
119
  ),
@@ -85,10 +122,12 @@ function QuietHoursSetting({
85
122
  {
86
123
  id: `${id}-end`,
87
124
  label: labels.quietUntil,
88
- value: endShown ?? "",
125
+ value: endShown,
126
+ onValueChange: (value) => type("quiet_hours_end", value),
127
+ onBlur: () => enter("quiet_hours_end"),
128
+ onKeyDown: (event) => enterOnEnter("quiet_hours_end", event),
89
129
  disabled,
90
130
  commit: COMMIT_EXCEPT_BILLING,
91
- onValueChange: (value) => saveTime("quiet_hours_end", value),
92
131
  className: "w-36"
93
132
  }
94
133
  )
@@ -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 { 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 { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\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\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction 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 const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone\n .slice(slash + 1)\n .replace(/_/g, \" \")\n .replace(/\\//g, \" / \")}`;\n}\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 /**\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 `zone` names none: 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 `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\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 ?? 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":[]}
1
+ {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, KeyboardEvent, 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 \"../hooks/use-mounted\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\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\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction 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 const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone\n .slice(slash + 1)\n .replace(/_/g, \" \")\n .replace(/\\//g, \" / \")}`;\n}\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 /**\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. Called when a time is ENTERED — the field left, or Enter, or the card\n * unmounted with a time still typed — never per keystroke, and one call per field at a\n * time (0.52). 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 `zone` names none: 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 when it is entered — on leaving the field or on Enter, or\n * when the card goes while it is still typed — and a cleared time sends `null`. While\n * only one is set the card says so: the window fails open until both are.\n * - **One request per field at a time** (0.52): a time entered while that field's save\n * runs waits for it, and only the latest waiting one is sent. Each input event used to\n * PATCH — typing \"30\" over 22:00's minutes sent 22:03 and 22:30, and whichever answer\n * landed last stayed on the server (refactor audit 2026-10-30, FLW-4).\n * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\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 // What is typed and not yet entered, per field: the field shows it, nothing is sent.\n const [drafts, setDrafts] = useState<Partial<Record<QuietField, string>>>({});\n // Per field: whether a save runs, and the latest time entered while it did (own key =\n // one is waiting). Refs: a save's answer reads them after renders it did not see.\n const running = useRef<Partial<Record<QuietField, boolean>>>({});\n const waiting = useRef<Partial<Record<QuietField, string | null>>>({});\n\n const server = (field: QuietField) => (field === \"quiet_hours_start\" ? start : end);\n const saved = (field: QuietField) => (overlay.has(field) ? (overlay.get(field) ?? null) : server(field));\n const shown = (field: QuietField) => drafts[field] ?? saved(field) ?? \"\";\n const startShown = shown(\"quiet_hours_start\");\n const endShown = shown(\"quiet_hours_end\");\n const accountZone = zone ?? fallbackZone;\n\n const send = (field: QuietField, value: string | null) => {\n running.current[field] = true;\n if (mounted.current) setFailure(null);\n settle(() => onChange({ [field]: value })).then(\n () => done(field, true),\n (error: unknown) => done(field, false, error),\n );\n };\n\n /** A save answered: the waiting time goes next — even after an unmount, it is what the\n * person entered last — and supersedes this answer, a failure included. */\n const done = (field: QuietField, ok: boolean, error?: unknown) => {\n running.current[field] = false;\n if (Object.prototype.hasOwnProperty.call(waiting.current, field)) {\n const next = waiting.current[field] ?? null;\n delete waiting.current[field];\n send(field, next);\n return;\n }\n if (!mounted.current) return;\n overlay.settle(field, ok);\n if (!ok) setFailure(describeFailure(describeError?.(error), labels.failed));\n };\n\n /** `draft` entered: the time it names, or `undefined` when that is what the field\n * already holds and nothing is to be sent. Queued behind the field's running save. */\n const save = (field: QuietField, draft: string): string | null | undefined => {\n const next = draft || null;\n if (next === saved(field)) return undefined;\n if (running.current[field]) waiting.current[field] = next;\n else send(field, next);\n return next;\n };\n\n /** The typed time, entered: saved unless it is what the field already holds. */\n const enter = (field: QuietField) => {\n const draft = drafts[field];\n if (draft === undefined) return;\n setDrafts((current) => {\n const rest = { ...current };\n delete rest[field];\n return rest;\n });\n const next = save(field, draft);\n if (next !== undefined) overlay.set(field, next);\n };\n\n // Leaving the card is leaving the field. A focused input removed with its card fires no\n // blur — Escape on the settings sheet, a shortcut that navigates — and the time it showed\n // as typed was dropped (0.52 code review, quiet-hours-setting.tsx; save-on-blur is FLW-4's\n // decision). So whatever is still typed is entered as the card goes. A ref to this\n // render's drafts and saved values, assigned in an effect, never during render.\n const leave = useRef<() => void>(() => {});\n useEffect(() => {\n leave.current = () => {\n for (const [field, draft] of Object.entries(drafts) as [QuietField, string][]) save(field, draft);\n };\n });\n useEffect(() => () => leave.current(), []);\n\n const type = (field: QuietField, value: string) => setDrafts((current) => ({ ...current, [field]: value }));\n const enterOnEnter = (field: QuietField, event: KeyboardEvent<HTMLInputElement>) => {\n if (event.key === \"Enter\") enter(field);\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 onValueChange={(value) => type(\"quiet_hours_start\", value)}\n onBlur={() => enter(\"quiet_hours_start\")}\n onKeyDown={(event) => enterOnEnter(\"quiet_hours_start\", event)}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown}\n onValueChange={(value) => type(\"quiet_hours_end\", value)}\n onBlur={() => enter(\"quiet_hours_end\")}\n onKeyDown={(event) => enterOnEnter(\"quiet_hours_end\", event)}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\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":["rest"]}
@@ -3,11 +3,12 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useRef, useState } from "react";
4
4
  import { CheckCircle2, Link2Off } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
- import { errorBodyWithCode, httpStatusOf } from "../auth/auth-errors.js";
6
+ import { errorBodyWithCode } from "../auth/auth-errors.js";
7
+ import { httpStatusOf } from "../lib/http-status.js";
7
8
  import { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from "../auth/status-parts.js";
8
9
  import { Button } from "../components/button.js";
9
10
  import { EmptyState } from "../components/empty-state.js";
10
- import { useMounted } from "../account/account-parts.js";
11
+ import { useMounted } from "../hooks/use-mounted.js";
11
12
  import { FailureLine, useNotificationsLabels } from "./notification-parts.js";
12
13
  const TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;
13
14
  const BASE64URL = /^[A-Za-z0-9_-]+$/;
@@ -87,11 +88,7 @@ function UnsubscribePanel({
87
88
  tone: "success",
88
89
  icon: /* @__PURE__ */ jsx(CheckCircle2, {}),
89
90
  title: shown === "stopped-these" ? labels.stoppedThese : labels.stoppedAll(appName),
90
- hint: /* @__PURE__ */ jsxs(Fragment, { children: [
91
- labels.accountMail,
92
- " ",
93
- labels.turnBackOn
94
- ] }),
91
+ hint: labels.stoppedHint(labels.accountMail, labels.turnBackOn),
95
92
  action: settingsLink,
96
93
  className: OUTCOME_CLASS
97
94
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/unsubscribe-panel.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, Link2Off } from \"lucide-react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { errorBodyWithCode, httpStatusOf } from \"../auth/auth-errors\";\nimport { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from \"../auth/status-parts\";\nimport type { AuthHeadingLevel } from \"../auth/status-parts\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { useMounted } from \"../account/account-parts\";\nimport { FailureLine, useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\n\n/** A type id, as the server's registry allows it (§2.1). */\nconst TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;\n/** base64url, unpadded, as the token's `<data>` is written (§3.6). */\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\n\n/**\n * The notification type an unsubscribe token names, read from its `<data>` half —\n * `v1.<base64url(\"<user_id>:<type id>\")>.<mac>` (docs/notifications-harmonization.md\n * §3.6) — or `null` for a token that does not parse. Only the claim is read: the MAC is\n * the server's to check, so a forged token still names a type here and the server\n * refuses it. For the page's words (\"You get e-mails about: Overdue invoices\").\n */\nexport function unsubscribeTokenType(token: string): string | null {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const [version, data, mac] = parts;\n if (version !== \"v1\" || !BASE64URL.test(data) || !BASE64URL.test(mac) || data.length % 4 === 1) return null;\n let claim: string;\n try {\n claim = atob(data.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (data.length % 4)) % 4));\n } catch {\n return null;\n }\n const colon = claim.indexOf(\":\");\n if (colon < 1) return null;\n const type = claim.slice(colon + 1);\n return TYPE_ID.test(type) ? type : null;\n}\n\n/** server-kit's `UnsubscribeTokenError`: 400 `{code: \"unsubscribe_invalid\"}`. A body\n * thrown as it came, with no status, counts on its code. */\nfunction defaultIsInvalid(error: unknown): boolean {\n const coded = errorBodyWithCode(error, (code) => code === \"unsubscribe_invalid\") !== undefined;\n const status = httpStatusOf(error);\n return coded && (status === undefined || status === 400);\n}\n\nexport interface UnsubscribePanelProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The `token` from the page's query (`/unsubscribe?token=…`). */\n token: string;\n /** The app's name, for \"Stop all notification e-mails from {app}\". */\n appName: string;\n /** The type's name in the app's words, from {@link unsubscribeTokenType}. Without it\n * the page says only that notification mail comes. */\n typeLabel?: string;\n /** `POST /notifications/unsubscribe/{token}`: that type's e-mail off. */\n onStop: (token: string) => Promise<unknown>;\n /** `POST /notifications/unsubscribe/{token}/all`: every e-mail switch off. */\n onStopAll: (token: string) => Promise<unknown>;\n /** Settings → Notifications, through the provider's link component. */\n settingsHref: string;\n /** Whether a rejection means the link is no good. Default: a 400 with\n * `code: \"unsubscribe_invalid\"`. Anything else is \"try again\". */\n isInvalid?: (error: unknown) => boolean;\n /** The heading. Default `labels.unsubscribeTitle`; `null` draws none, for a page that\n * puts it in `AuthLayout`'s `title`. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype Phase = \"ready\" | \"these\" | \"all\" | \"stopped-these\" | \"stopped-all\" | \"invalid\" | \"failed\";\n\n/**\n * The body of an app's signed-out `/unsubscribe` page (docs/notifications-harmonization.md\n * §3.6, §4.2) — the page a notification mail's footer links to. It works signed in or\n * out (auth §7), and is not a card.\n *\n * **Nothing happens on a GET**: link scanners fetch every URL, so the page asks first —\n * \"Stop these e-mails\", or \"Stop all notification e-mails from {app}\" — and only a press\n * POSTs. A mail client's one-click POST goes to the server's route directly, without\n * this page. Account mail is not a notification and keeps coming; the page says so, and\n * then how to turn the mail back on (Settings → Notifications).\n *\n * A token that does not parse is \"this link doesn't work\" at once, with no request; the\n * server's refusal (`unsubscribe_invalid`) says the same. Any other failure is \"try\n * again\", the buttons still there. A valid token always answers 200 — also when the\n * switch was already off or the account is gone — so the done state reveals nothing.\n */\nexport function UnsubscribePanel({\n token,\n appName,\n typeLabel,\n onStop,\n onStopAll,\n settingsHref,\n isInvalid = defaultIsInvalid,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: UnsubscribePanelProps) {\n const labels = useNotificationsLabels(labelsProp);\n const parses = unsubscribeTokenType(token) !== null;\n const [phase, setPhase] = useState<Phase>(\"ready\");\n const mounted = useMounted();\n const outcomeRef = useRef<HTMLDivElement>(null);\n const shown: Phase = parses ? phase : \"invalid\";\n const done = shown === \"stopped-these\" || shown === \"stopped-all\";\n useFocusWhen(done, outcomeRef);\n\n const stop = (which: \"these\" | \"all\") => {\n if (shown !== \"ready\" && shown !== \"failed\") return;\n setPhase(which);\n const call = which === \"these\" ? onStop : onStopAll;\n call(token).then(\n () => {\n if (mounted.current) setPhase(which === \"these\" ? \"stopped-these\" : \"stopped-all\");\n },\n (error: unknown) => {\n if (mounted.current) setPhase(isInvalid(error) ? \"invalid\" : \"failed\");\n },\n );\n };\n\n const settingsLink = (\n <Button href={settingsHref} variant=\"secondary\">\n {labels.openSettings}\n </Button>\n );\n\n let body: ReactNode;\n if (shown === \"invalid\") {\n body = (\n <EmptyState\n tone=\"danger\"\n icon={<Link2Off />}\n title={labels.invalidLink}\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n );\n } else if (done) {\n body = (\n <div ref={outcomeRef} tabIndex={-1} className=\"outline-none\">\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={shown === \"stopped-these\" ? labels.stoppedThese : labels.stoppedAll(appName)}\n hint={\n <>\n {labels.accountMail} {labels.turnBackOn}\n </>\n }\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n </div>\n );\n } else {\n const working = shown === \"these\" || shown === \"all\";\n body = (\n <>\n <p className={INTRO_CLASS}>{typeLabel ? labels.unsubscribeAbout(typeLabel) : labels.unsubscribeGeneric}</p>\n <p className={INTRO_CLASS}>{labels.accountMail}</p>\n <div className=\"flex flex-col gap-2 sm:flex-row sm:flex-wrap\">\n <Button\n type=\"button\"\n pending={shown === \"these\"}\n disabled={working && shown !== \"these\"}\n onClick={() => stop(\"these\")}\n >\n {labels.stopThese}\n </Button>\n <Button\n type=\"button\"\n variant=\"secondary\"\n pending={shown === \"all\"}\n disabled={working && shown !== \"all\"}\n onClick={() => stop(\"all\")}\n >\n {labels.stopAll(appName)}\n </Button>\n </div>\n {shown === \"failed\" && <FailureLine>{labels.failed}</FailureLine>}\n </>\n );\n }\n\n return (\n <div {...rest} data-state={shown} className={cn(\"space-y-3\", className)}>\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.unsubscribeTitle}</Heading>}\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/unsubscribe-panel.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, Link2Off } from \"lucide-react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { errorBodyWithCode } from \"../auth/auth-errors\";\nimport { httpStatusOf } from \"../lib/http-status\";\nimport { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from \"../auth/status-parts\";\nimport type { AuthHeadingLevel } from \"../auth/status-parts\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { useMounted } from \"../hooks/use-mounted\";\nimport { FailureLine, useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\n\n/** A type id, as the server's registry allows it (§2.1). */\nconst TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;\n/** base64url, unpadded, as the token's `<data>` is written (§3.6). */\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\n\n/**\n * The notification type an unsubscribe token names, read from its `<data>` half —\n * `v1.<base64url(\"<user_id>:<type id>\")>.<mac>` (docs/notifications-harmonization.md\n * §3.6) — or `null` for a token that does not parse. Only the claim is read: the MAC is\n * the server's to check, so a forged token still names a type here and the server\n * refuses it. For the page's words (\"You get e-mails about: Overdue invoices\").\n */\nexport function unsubscribeTokenType(token: string): string | null {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const [version, data, mac] = parts;\n if (version !== \"v1\" || !BASE64URL.test(data) || !BASE64URL.test(mac) || data.length % 4 === 1) return null;\n let claim: string;\n try {\n claim = atob(data.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (data.length % 4)) % 4));\n } catch {\n return null;\n }\n const colon = claim.indexOf(\":\");\n if (colon < 1) return null;\n const type = claim.slice(colon + 1);\n return TYPE_ID.test(type) ? type : null;\n}\n\n/** server-kit's `UnsubscribeTokenError`: 400 `{code: \"unsubscribe_invalid\"}`. A body\n * thrown as it came, with no status, counts on its code. */\nfunction defaultIsInvalid(error: unknown): boolean {\n const coded = errorBodyWithCode(error, (code) => code === \"unsubscribe_invalid\") !== undefined;\n const status = httpStatusOf(error);\n return coded && (status === undefined || status === 400);\n}\n\nexport interface UnsubscribePanelProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The `token` from the page's query (`/unsubscribe?token=…`). */\n token: string;\n /** The app's name, for \"Stop all notification e-mails from {app}\". */\n appName: string;\n /** The type's name in the app's words, from {@link unsubscribeTokenType}. Without it\n * the page says only that notification mail comes. */\n typeLabel?: string;\n /** `POST /notifications/unsubscribe/{token}`: that type's e-mail off. */\n onStop: (token: string) => Promise<unknown>;\n /** `POST /notifications/unsubscribe/{token}/all`: every e-mail switch off. */\n onStopAll: (token: string) => Promise<unknown>;\n /** Settings → Notifications, through the provider's link component. */\n settingsHref: string;\n /** Whether a rejection means the link is no good. Default: a 400 with\n * `code: \"unsubscribe_invalid\"`. Anything else is \"try again\". */\n isInvalid?: (error: unknown) => boolean;\n /** The heading. Default `labels.unsubscribeTitle`; `null` draws none, for a page that\n * puts it in `AuthLayout`'s `title`. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype Phase = \"ready\" | \"these\" | \"all\" | \"stopped-these\" | \"stopped-all\" | \"invalid\" | \"failed\";\n\n/**\n * The body of an app's signed-out `/unsubscribe` page (docs/notifications-harmonization.md\n * §3.6, §4.2) — the page a notification mail's footer links to. It works signed in or\n * out (auth §7), and is not a card.\n *\n * **Nothing happens on a GET**: link scanners fetch every URL, so the page asks first —\n * \"Stop these e-mails\", or \"Stop all notification e-mails from {app}\" — and only a press\n * POSTs. A mail client's one-click POST goes to the server's route directly, without\n * this page. Account mail is not a notification and keeps coming; the page says so, and\n * then how to turn the mail back on (Settings → Notifications).\n *\n * A token that does not parse is \"this link doesn't work\" at once, with no request; the\n * server's refusal (`unsubscribe_invalid`) says the same. Any other failure is \"try\n * again\", the buttons still there. A valid token always answers 200 — also when the\n * switch was already off or the account is gone — so the done state reveals nothing.\n */\nexport function UnsubscribePanel({\n token,\n appName,\n typeLabel,\n onStop,\n onStopAll,\n settingsHref,\n isInvalid = defaultIsInvalid,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: UnsubscribePanelProps) {\n const labels = useNotificationsLabels(labelsProp);\n const parses = unsubscribeTokenType(token) !== null;\n const [phase, setPhase] = useState<Phase>(\"ready\");\n const mounted = useMounted();\n const outcomeRef = useRef<HTMLDivElement>(null);\n const shown: Phase = parses ? phase : \"invalid\";\n const done = shown === \"stopped-these\" || shown === \"stopped-all\";\n useFocusWhen(done, outcomeRef);\n\n const stop = (which: \"these\" | \"all\") => {\n if (shown !== \"ready\" && shown !== \"failed\") return;\n setPhase(which);\n const call = which === \"these\" ? onStop : onStopAll;\n call(token).then(\n () => {\n if (mounted.current) setPhase(which === \"these\" ? \"stopped-these\" : \"stopped-all\");\n },\n (error: unknown) => {\n if (mounted.current) setPhase(isInvalid(error) ? \"invalid\" : \"failed\");\n },\n );\n };\n\n const settingsLink = (\n <Button href={settingsHref} variant=\"secondary\">\n {labels.openSettings}\n </Button>\n );\n\n let body: ReactNode;\n if (shown === \"invalid\") {\n body = (\n <EmptyState\n tone=\"danger\"\n icon={<Link2Off />}\n title={labels.invalidLink}\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n );\n } else if (done) {\n body = (\n <div ref={outcomeRef} tabIndex={-1} className=\"outline-none\">\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={shown === \"stopped-these\" ? labels.stoppedThese : labels.stoppedAll(appName)}\n hint={labels.stoppedHint(labels.accountMail, labels.turnBackOn)}\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n </div>\n );\n } else {\n const working = shown === \"these\" || shown === \"all\";\n body = (\n <>\n <p className={INTRO_CLASS}>{typeLabel ? labels.unsubscribeAbout(typeLabel) : labels.unsubscribeGeneric}</p>\n <p className={INTRO_CLASS}>{labels.accountMail}</p>\n <div className=\"flex flex-col gap-2 sm:flex-row sm:flex-wrap\">\n <Button\n type=\"button\"\n pending={shown === \"these\"}\n disabled={working && shown !== \"these\"}\n onClick={() => stop(\"these\")}\n >\n {labels.stopThese}\n </Button>\n <Button\n type=\"button\"\n variant=\"secondary\"\n pending={shown === \"all\"}\n disabled={working && shown !== \"all\"}\n onClick={() => stop(\"all\")}\n >\n {labels.stopAll(appName)}\n </Button>\n </div>\n {shown === \"failed\" && <FailureLine>{labels.failed}</FailureLine>}\n </>\n );\n }\n\n return (\n <div {...rest} data-state={shown} className={cn(\"space-y-3\", className)}>\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.unsubscribeTitle}</Heading>}\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -74,7 +74,8 @@ export interface PushDevice {
74
74
  * the permission was not granted or push is off on the server; rejects with a
75
75
  * {@link PushDeviceError} or the app's request's error. */
76
76
  enable: () => Promise<boolean>;
77
- /** Tell the server first, then the browser, so a dropped device stops at once. */
77
+ /** Tell the server first, then the browser, so a dropped device stops at once. At a
78
+ * sign-out, with no card mounted: {@link releaseThisDevicePush}. */
78
79
  disable: () => Promise<void>;
79
80
  sendTest: () => Promise<{
80
81
  sent: number;
@@ -85,6 +86,43 @@ export declare function notifyPushSubscriptionChanged(): void;
85
86
  /** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED
86
87
  * app (Add to Home Screen). */
87
88
  export declare function pushSupported(): boolean;
89
+ /**
90
+ * Release this browser's push at an EXPLICIT sign-out (0.52, docs/notifications-
91
+ * harmonization.md §10.6; refactor audit 2026-10-30, FLW-2) — without the settings card
92
+ * mounted, which `usePushDevice().disable` needs.
93
+ *
94
+ * A sign-out left the subscription registered to the account, so the device went on
95
+ * showing that account's notifications, title and body (keksdose: invoices, bookings,
96
+ * shares), to whoever used it next — while the same sign-out wiped the mirror, the outbox
97
+ * and the query cache "for the next person on this device".
98
+ *
99
+ * Call it with the app's `unregister` (`POST /notifications/devices/unregister
100
+ * {endpoint}`) BEFORE the session is cleared, while the token is still valid:
101
+ *
102
+ * async function logOut() {
103
+ * await releaseThisDevicePush(api.unregisterDevice).catch(() => undefined);
104
+ * clear();
105
+ * }
106
+ *
107
+ * The server first, then the browser — and the browser EVEN WHEN THE SERVER REFUSED (0.52
108
+ * code review, this file): `disable`'s order left a sign-out on a flaky network, or one
109
+ * that met a 5xx, with the device still subscribed. Unsubscribed, the push service answers
110
+ * the server's next send with 410 and the server prunes the row. The browser's unsubscribe
111
+ * is cut at 15 s (`PushDeviceError("push_timeout")`). Then it tells every mounted
112
+ * {@link usePushDevice} ({@link PUSH_SUBSCRIPTION_CHANGED}).
113
+ *
114
+ * Resolves whether this browser held a subscription; `false` at once where push is
115
+ * unsupported or was never set up — no service worker registration, or none ever active
116
+ * (0.52 code review: most devices, which used to reject with `no_service_worker`, after
117
+ * 10 s for a worker stuck installing). It asks the registration, not `ready`: a
118
+ * subscription belongs to the registration, and a sign-out waits for no worker. Rejects
119
+ * as the app's request did — once the browser has let go — or with `push_timeout`. Best
120
+ * effort at a sign-out: catch it, a sign-out must not stop on push.
121
+ *
122
+ * Only an explicit sign-out (Marcel's decision 2 of the 2026-10-30 audit): a session that
123
+ * merely expired keeps its device, and whoever signs back in turns push on again.
124
+ */
125
+ export declare function releaseThisDevicePush(unregister: (endpoint: string) => Promise<unknown>): Promise<boolean>;
88
126
  /**
89
127
  * This browser's push for the signed-in account. Hand it one function per endpoint
90
128
  * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { useMediaQuery } from "../hooks/use-media-query.js";
4
- import { httpStatusOf } from "../auth/auth-errors.js";
4
+ import { httpStatusOf } from "../lib/http-status.js";
5
5
  import { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from "./push-worker.js";
6
6
  class PushDeviceError extends Error {
7
7
  code;
@@ -39,6 +39,31 @@ async function activeRegistration() {
39
39
  return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
40
40
  }
41
41
  const defaultEndpointTaken = (error) => httpStatusOf(error) === 409;
42
+ async function releaseSubscription(unregister) {
43
+ const registration = await activeRegistration();
44
+ const subscription = await registration.pushManager.getSubscription();
45
+ if (!subscription) return false;
46
+ await unregister(subscription.endpoint);
47
+ await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, "push_timeout");
48
+ return true;
49
+ }
50
+ async function releaseThisDevicePush(unregister) {
51
+ if (!pushSupported()) return false;
52
+ try {
53
+ const registration = await navigator.serviceWorker.getRegistration();
54
+ if (!registration?.active) return false;
55
+ const subscription = await registration.pushManager.getSubscription();
56
+ if (!subscription) return false;
57
+ try {
58
+ await unregister(subscription.endpoint);
59
+ } finally {
60
+ await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, "push_timeout");
61
+ }
62
+ return true;
63
+ } finally {
64
+ notifyPushSubscriptionChanged();
65
+ }
66
+ }
42
67
  function usePushDevice(options) {
43
68
  const latest = useRef(options);
44
69
  useEffect(() => {
@@ -183,12 +208,7 @@ function usePushDevice(options) {
183
208
  if (!supported) return;
184
209
  setBusy(true);
185
210
  try {
186
- const registration = await activeRegistration();
187
- const subscription = await registration.pushManager.getSubscription();
188
- if (subscription) {
189
- await latest.current.unregister(subscription.endpoint);
190
- await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, "push_timeout");
191
- }
211
+ await releaseSubscription((endpoint) => latest.current.unregister(endpoint));
192
212
  if (mounted.current) {
193
213
  setSubscribed(false);
194
214
  setForeignSubscription(false);
@@ -220,6 +240,7 @@ export {
220
240
  PushDeviceError,
221
241
  notifyPushSubscriptionChanged,
222
242
  pushSupported,
243
+ releaseThisDevicePush,
223
244
  usePushDevice
224
245
  };
225
246
  //# sourceMappingURL=use-push-device.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/use-push-device.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { httpStatusOf } from \"../auth/auth-errors\";\nimport { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from \"./push-worker\";\nimport type { PushSubscriptionInput } from \"./push-worker\";\n\nexport { PUSH_SUBSCRIPTION_CHANGED };\nexport type { PushSubscriptionInput };\n\n/**\n * This browser's Web Push subscription for the signed-in account\n * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's\n * `shared/push/use-push.ts`, lifted. The service worker with its `push` and\n * `notificationclick` handlers stays the app's; this hook owns the browser's side:\n * permission, subscribe with the server's VAPID key, register the subscription, and\n * unsubscribe. The kit makes no request of its own: the app hands it one function per\n * endpoint.\n */\n\n/** The browser's notification permission, or `\"unsupported\"` where it has no push. */\nexport type PushPermission = NotificationPermission | \"unsupported\";\n\n/** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */\nexport type PushDeviceErrorCode = \"no_service_worker\" | \"push_timeout\";\n\n/**\n * The two failures the card has words of its own for: no service worker registered (or\n * one not ready in 10 s) — \"reload and try again\" — and a browser push service that did\n * not answer in 15 s — \"check your connection\". Anything else is the app's request\n * failing, rejected as it came.\n */\nexport class PushDeviceError extends Error {\n readonly code: PushDeviceErrorCode;\n constructor(code: PushDeviceErrorCode) {\n super(code);\n this.name = \"PushDeviceError\";\n this.code = code;\n }\n}\n\nexport interface UsePushDeviceOptions {\n /** `GET /notifications/push-key` → its `key`; `\"\"` when push is off on the server. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered\n * once: the hook unsubscribes and registers a fresh endpoint. */\n register: (subscription: PushSubscriptionInput) => Promise<unknown>;\n /** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a\n * URL. */\n unregister: (endpoint: string) => Promise<unknown>;\n /** `GET /notifications/devices`: the account's own list, which says whether THIS\n * account holds this browser's subscription. */\n devices: () => Promise<readonly { endpoint: string }[]>;\n /** `POST /notifications/test` → how many devices the push service accepted it for. */\n test: () => Promise<{ sent: number }>;\n /** Whether `register` refused another account's endpoint. Default: HTTP 409. */\n isEndpointTaken?: (error: unknown) => boolean;\n /** After an enable or a disable: the app refreshes its device queries. */\n onChange?: () => void;\n}\n\n/** What {@link usePushDevice} answers. */\nexport interface PushDevice {\n /** The browser exposes service workers, `PushManager` and `Notification`. */\n supported: boolean;\n /** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */\n configured: boolean | undefined;\n /** Running as an installed app: `display-mode: standalone`, or iOS's\n * `navigator.standalone`. */\n installed: boolean;\n /** iPhone or iPad — where push reaches only the installed app. */\n ios: boolean;\n permission: PushPermission;\n /** THIS account holds this browser's subscription. An unreachable device list falls\n * back to the browser's own answer. */\n subscribed: boolean;\n /** This browser holds a subscription that belongs to another account (two accounts in\n * one browser profile): `subscribed` is false, and turning on mints a fresh one. */\n foreignSubscription: boolean;\n /** This browser's subscription endpoint, or `null` — marks \"This device\" in a list. */\n currentEndpoint: string | null;\n /** An enable or a disable is running. */\n busy: boolean;\n /** Ask for permission (the browser's one prompt), subscribe and register. `false` when\n * the permission was not granted or push is off on the server; rejects with a\n * {@link PushDeviceError} or the app's request's error. */\n enable: () => Promise<boolean>;\n /** Tell the server first, then the browser, so a dropped device stops at once. */\n disable: () => Promise<void>;\n sendTest: () => Promise<{ sent: number }>;\n}\n\n/*\n * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted\n * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by\n * `PushDevicesSetting` when it removes this device — so an app's own card on the same\n * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it\n * knows what it changed. Since 0.46 also the message a service worker posts after a\n * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced\n * for the kit; keksdose's was `\"keksdose:push-subscription-changed\"`.\n */\n\n/** Tell every {@link usePushDevice} that this browser's subscription changed. */\nexport function notifyPushSubscriptionChanged(): void {\n if (typeof window !== \"undefined\") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));\n}\n\n/** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED\n * app (Add to Home Screen). */\nexport function pushSupported(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof navigator !== \"undefined\" &&\n \"serviceWorker\" in navigator &&\n \"PushManager\" in window &&\n \"Notification\" in window\n );\n}\n\n/** iPhone or iPad. iPadOS 13+ reports itself as a Mac; the touch points tell it apart. */\nfunction isIos(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent;\n return /iphone|ipad|ipod/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);\n}\n\nconst READY_TIMEOUT_MS = 10_000;\nconst PUSH_TIMEOUT_MS = 15_000;\n\n/** `promise`, or a {@link PushDeviceError} after `ms`. The timer is cleared either way. */\nfunction within<T>(promise: Promise<T>, ms: number, code: PushDeviceErrorCode): Promise<T> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n return Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new PushDeviceError(code)), ms);\n }),\n ]).finally(() => clearTimeout(timer));\n}\n\n/**\n * The active registration. `navigator.serviceWorker.ready` never settles while the origin\n * has no registration, so a missing one fails at once, and one stuck installing fails\n * after 10 s (keksdose: enable() hung with the button latched before its worker was\n * served).\n */\nasync function activeRegistration(): Promise<ServiceWorkerRegistration> {\n const existing = await navigator.serviceWorker.getRegistration();\n if (!existing) throw new PushDeviceError(\"no_service_worker\");\n return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, \"no_service_worker\");\n}\n\nconst defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;\n\n/**\n * This browser's push for the signed-in account. Hand it one function per endpoint\n * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.\n *\n * **\"Subscribed\" means this account**, not \"this browser holds a subscription\": a\n * browser profile's subscription has no account in it, so a second account in the same\n * profile would read the first's as its own (keksdose). The endpoint is checked against\n * the account's own `devices()`; when that list cannot be read (offline), the browser's\n * answer stands — a wrong \"on\" costs what it did before the check, a wrong \"off\" invites\n * re-subscribing a device that is fine.\n *\n * **A 409 from `register`** — the endpoint belongs to another account, and the server\n * will not re-home what it cannot prove this browser holds — unsubscribes and registers\n * a fresh endpoint, once.\n *\n * **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both\n * `PushDeviceError(\"no_service_worker\")`; the browser's subscribe and unsubscribe are cut\n * at 15 s, `PushDeviceError(\"push_timeout\")` — so `busy` can never latch.\n *\n * Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.\n */\nexport function usePushDevice(options: UsePushDeviceOptions): PushDevice {\n // The app's functions are usually new on every render; the effects read the latest.\n const latest = useRef(options);\n useEffect(() => {\n latest.current = options;\n });\n\n const supported = pushSupported();\n const ios = isIos();\n const standalone = useMediaQuery(\"(display-mode: standalone)\", false);\n const installed =\n standalone ||\n (typeof navigator !== \"undefined\" && (navigator as Navigator & { standalone?: boolean }).standalone === true);\n\n const [permission, setPermission] = useState<PushPermission>(() =>\n supported ? Notification.permission : \"unsupported\",\n );\n const [configured, setConfigured] = useState<boolean | undefined>(undefined);\n const [subscribed, setSubscribed] = useState(false);\n const [foreignSubscription, setForeignSubscription] = useState(false);\n const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const mounted = useRef(true);\n // Set while this hook fires the event itself: it already knows what changed, and a\n // re-read could only be worse — an app whose `devices()` answers from a cache would\n // hand back the list from before the change.\n const announcing = useRef(false);\n const announce = useCallback(() => {\n announcing.current = true;\n try {\n notifyPushSubscriptionChanged();\n } finally {\n announcing.current = false;\n }\n latest.current.onChange?.();\n }, []);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // Whether the server has keys (\"\" → push is off there). Once per mount.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n latest.current.pushKey().then(\n (key) => {\n if (!cancelled) setConfigured(key !== \"\");\n },\n () => {\n /* unknown: the card shows the ordinary states */\n },\n );\n return () => {\n cancelled = true;\n };\n }, [supported]);\n\n // What this browser holds, and whether it is this account's — at mount, and whenever\n // a subscription changes anywhere in the app.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n const sync = async () => {\n try {\n const registration = await activeRegistration();\n const existing = await registration.pushManager.getSubscription();\n if (cancelled) return;\n // Read again: the person may have changed it in the browser's settings.\n setPermission(Notification.permission);\n if (!existing) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n return;\n }\n let mine: boolean;\n try {\n const devices = await latest.current.devices();\n mine = devices.some((device) => device.endpoint === existing.endpoint);\n } catch {\n mine = true;\n }\n if (cancelled) return;\n setCurrentEndpoint(existing.endpoint);\n setSubscribed(mine);\n setForeignSubscription(!mine);\n } catch {\n /* no active service worker yet: not subscribed */\n }\n };\n const heard = () => {\n if (!announcing.current) void sync();\n };\n // The worker's word after it re-registered a rotated subscription (0.46).\n const fromWorker = (event: MessageEvent) => {\n if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();\n };\n const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;\n void sync();\n window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.addEventListener?.(\"message\", fromWorker);\n return () => {\n cancelled = true;\n window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.removeEventListener?.(\"message\", fromWorker);\n };\n }, [supported]);\n\n const enable = useCallback(async (): Promise<boolean> => {\n if (!supported) return false;\n const { pushKey, register, isEndpointTaken = defaultEndpointTaken } = latest.current;\n setBusy(true);\n try {\n const granted = await Notification.requestPermission();\n if (mounted.current) setPermission(granted);\n if (granted !== \"granted\") return false;\n\n const key = await pushKey();\n if (!key) {\n if (mounted.current) setConfigured(false);\n return false;\n }\n if (mounted.current) setConfigured(true);\n\n const registration = await activeRegistration();\n // The one place the browser is asked for a subscription, so the timeout and the\n // `{userVisibleOnly, applicationServerKey}` literal are on both paths.\n const subscribe = (reuse: boolean) =>\n within(\n (async () =>\n (reuse ? await registration.pushManager.getSubscription() : null) ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: keyBytes(key),\n })))(),\n PUSH_TIMEOUT_MS,\n \"push_timeout\",\n );\n\n let subscription = await subscribe(true);\n let input = inputOf(subscription);\n if (!input) return false;\n try {\n await register(input);\n } catch (error) {\n if (!isEndpointTaken(error)) throw error;\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n subscription = await subscribe(false);\n input = inputOf(subscription);\n if (!input) return false;\n await register(input);\n }\n if (mounted.current) {\n setSubscribed(true);\n setForeignSubscription(false);\n setCurrentEndpoint(input.endpoint);\n }\n announce();\n return true;\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const disable = useCallback(async (): Promise<void> => {\n if (!supported) return;\n setBusy(true);\n try {\n const registration = await activeRegistration();\n const subscription = await registration.pushManager.getSubscription();\n if (subscription) {\n // The server first: a dropped device stops receiving even if the browser's\n // unsubscribe below fails.\n await latest.current.unregister(subscription.endpoint);\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n }\n if (mounted.current) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n }\n announce();\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const sendTest = useCallback(() => latest.current.test(), []);\n\n return {\n supported,\n configured,\n installed,\n ios,\n permission,\n subscribed,\n foreignSubscription,\n currentEndpoint,\n busy,\n enable,\n disable,\n sendTest,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/use-push-device.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { httpStatusOf } from \"../lib/http-status\";\nimport { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from \"./push-worker\";\nimport type { PushSubscriptionInput } from \"./push-worker\";\n\nexport { PUSH_SUBSCRIPTION_CHANGED };\nexport type { PushSubscriptionInput };\n\n/**\n * This browser's Web Push subscription for the signed-in account\n * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's\n * `shared/push/use-push.ts`, lifted. The service worker with its `push` and\n * `notificationclick` handlers stays the app's; this hook owns the browser's side:\n * permission, subscribe with the server's VAPID key, register the subscription, and\n * unsubscribe. The kit makes no request of its own: the app hands it one function per\n * endpoint.\n */\n\n/** The browser's notification permission, or `\"unsupported\"` where it has no push. */\nexport type PushPermission = NotificationPermission | \"unsupported\";\n\n/** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */\nexport type PushDeviceErrorCode = \"no_service_worker\" | \"push_timeout\";\n\n/**\n * The two failures the card has words of its own for: no service worker registered (or\n * one not ready in 10 s) — \"reload and try again\" — and a browser push service that did\n * not answer in 15 s — \"check your connection\". Anything else is the app's request\n * failing, rejected as it came.\n */\nexport class PushDeviceError extends Error {\n readonly code: PushDeviceErrorCode;\n constructor(code: PushDeviceErrorCode) {\n super(code);\n this.name = \"PushDeviceError\";\n this.code = code;\n }\n}\n\nexport interface UsePushDeviceOptions {\n /** `GET /notifications/push-key` → its `key`; `\"\"` when push is off on the server. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered\n * once: the hook unsubscribes and registers a fresh endpoint. */\n register: (subscription: PushSubscriptionInput) => Promise<unknown>;\n /** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a\n * URL. */\n unregister: (endpoint: string) => Promise<unknown>;\n /** `GET /notifications/devices`: the account's own list, which says whether THIS\n * account holds this browser's subscription. */\n devices: () => Promise<readonly { endpoint: string }[]>;\n /** `POST /notifications/test` → how many devices the push service accepted it for. */\n test: () => Promise<{ sent: number }>;\n /** Whether `register` refused another account's endpoint. Default: HTTP 409. */\n isEndpointTaken?: (error: unknown) => boolean;\n /** After an enable or a disable: the app refreshes its device queries. */\n onChange?: () => void;\n}\n\n/** What {@link usePushDevice} answers. */\nexport interface PushDevice {\n /** The browser exposes service workers, `PushManager` and `Notification`. */\n supported: boolean;\n /** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */\n configured: boolean | undefined;\n /** Running as an installed app: `display-mode: standalone`, or iOS's\n * `navigator.standalone`. */\n installed: boolean;\n /** iPhone or iPad — where push reaches only the installed app. */\n ios: boolean;\n permission: PushPermission;\n /** THIS account holds this browser's subscription. An unreachable device list falls\n * back to the browser's own answer. */\n subscribed: boolean;\n /** This browser holds a subscription that belongs to another account (two accounts in\n * one browser profile): `subscribed` is false, and turning on mints a fresh one. */\n foreignSubscription: boolean;\n /** This browser's subscription endpoint, or `null` — marks \"This device\" in a list. */\n currentEndpoint: string | null;\n /** An enable or a disable is running. */\n busy: boolean;\n /** Ask for permission (the browser's one prompt), subscribe and register. `false` when\n * the permission was not granted or push is off on the server; rejects with a\n * {@link PushDeviceError} or the app's request's error. */\n enable: () => Promise<boolean>;\n /** Tell the server first, then the browser, so a dropped device stops at once. At a\n * sign-out, with no card mounted: {@link releaseThisDevicePush}. */\n disable: () => Promise<void>;\n sendTest: () => Promise<{ sent: number }>;\n}\n\n/*\n * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted\n * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by\n * `PushDevicesSetting` when it removes this device — so an app's own card on the same\n * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it\n * knows what it changed. Since 0.46 also the message a service worker posts after a\n * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced\n * for the kit; keksdose's was `\"keksdose:push-subscription-changed\"`.\n */\n\n/** Tell every {@link usePushDevice} that this browser's subscription changed. */\nexport function notifyPushSubscriptionChanged(): void {\n if (typeof window !== \"undefined\") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));\n}\n\n/** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED\n * app (Add to Home Screen). */\nexport function pushSupported(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof navigator !== \"undefined\" &&\n \"serviceWorker\" in navigator &&\n \"PushManager\" in window &&\n \"Notification\" in window\n );\n}\n\n/** iPhone or iPad. iPadOS 13+ reports itself as a Mac; the touch points tell it apart. */\nfunction isIos(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent;\n return /iphone|ipad|ipod/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);\n}\n\nconst READY_TIMEOUT_MS = 10_000;\nconst PUSH_TIMEOUT_MS = 15_000;\n\n/** `promise`, or a {@link PushDeviceError} after `ms`. The timer is cleared either way. */\nfunction within<T>(promise: Promise<T>, ms: number, code: PushDeviceErrorCode): Promise<T> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n return Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new PushDeviceError(code)), ms);\n }),\n ]).finally(() => clearTimeout(timer));\n}\n\n/**\n * The active registration. `navigator.serviceWorker.ready` never settles while the origin\n * has no registration, so a missing one fails at once, and one stuck installing fails\n * after 10 s (keksdose: enable() hung with the button latched before its worker was\n * served).\n */\nasync function activeRegistration(): Promise<ServiceWorkerRegistration> {\n const existing = await navigator.serviceWorker.getRegistration();\n if (!existing) throw new PushDeviceError(\"no_service_worker\");\n return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, \"no_service_worker\");\n}\n\nconst defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;\n\n/**\n * Drop this browser's subscription when the person turns push off: the server first, so a\n * dropped device stops receiving even if the browser's unsubscribe fails, then the\n * browser, cut at 15 s. Whether there was one. A refused server keeps the browser's\n * subscription: the card says the turn-off failed, still reads on, and the person tries\n * again. {@link usePushDevice}'s `disable`; a sign-out has its own\n * ({@link releaseThisDevicePush}).\n */\nasync function releaseSubscription(unregister: (endpoint: string) => Promise<unknown>): Promise<boolean> {\n const registration = await activeRegistration();\n const subscription = await registration.pushManager.getSubscription();\n if (!subscription) return false;\n await unregister(subscription.endpoint);\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n return true;\n}\n\n/**\n * Release this browser's push at an EXPLICIT sign-out (0.52, docs/notifications-\n * harmonization.md §10.6; refactor audit 2026-10-30, FLW-2) — without the settings card\n * mounted, which `usePushDevice().disable` needs.\n *\n * A sign-out left the subscription registered to the account, so the device went on\n * showing that account's notifications, title and body (keksdose: invoices, bookings,\n * shares), to whoever used it next — while the same sign-out wiped the mirror, the outbox\n * and the query cache \"for the next person on this device\".\n *\n * Call it with the app's `unregister` (`POST /notifications/devices/unregister\n * {endpoint}`) BEFORE the session is cleared, while the token is still valid:\n *\n * async function logOut() {\n * await releaseThisDevicePush(api.unregisterDevice).catch(() => undefined);\n * clear();\n * }\n *\n * The server first, then the browser — and the browser EVEN WHEN THE SERVER REFUSED (0.52\n * code review, this file): `disable`'s order left a sign-out on a flaky network, or one\n * that met a 5xx, with the device still subscribed. Unsubscribed, the push service answers\n * the server's next send with 410 and the server prunes the row. The browser's unsubscribe\n * is cut at 15 s (`PushDeviceError(\"push_timeout\")`). Then it tells every mounted\n * {@link usePushDevice} ({@link PUSH_SUBSCRIPTION_CHANGED}).\n *\n * Resolves whether this browser held a subscription; `false` at once where push is\n * unsupported or was never set up — no service worker registration, or none ever active\n * (0.52 code review: most devices, which used to reject with `no_service_worker`, after\n * 10 s for a worker stuck installing). It asks the registration, not `ready`: a\n * subscription belongs to the registration, and a sign-out waits for no worker. Rejects\n * as the app's request did — once the browser has let go — or with `push_timeout`. Best\n * effort at a sign-out: catch it, a sign-out must not stop on push.\n *\n * Only an explicit sign-out (Marcel's decision 2 of the 2026-10-30 audit): a session that\n * merely expired keeps its device, and whoever signs back in turns push on again.\n */\nexport async function releaseThisDevicePush(unregister: (endpoint: string) => Promise<unknown>): Promise<boolean> {\n if (!pushSupported()) return false;\n try {\n const registration = await navigator.serviceWorker.getRegistration();\n // A worker never active holds no subscription: `subscribe` needs an active one.\n if (!registration?.active) return false;\n const subscription = await registration.pushManager.getSubscription();\n if (!subscription) return false;\n try {\n await unregister(subscription.endpoint);\n } finally {\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n }\n return true;\n } finally {\n notifyPushSubscriptionChanged();\n }\n}\n\n/**\n * This browser's push for the signed-in account. Hand it one function per endpoint\n * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.\n *\n * **\"Subscribed\" means this account**, not \"this browser holds a subscription\": a\n * browser profile's subscription has no account in it, so a second account in the same\n * profile would read the first's as its own (keksdose). The endpoint is checked against\n * the account's own `devices()`; when that list cannot be read (offline), the browser's\n * answer stands — a wrong \"on\" costs what it did before the check, a wrong \"off\" invites\n * re-subscribing a device that is fine.\n *\n * **A 409 from `register`** — the endpoint belongs to another account, and the server\n * will not re-home what it cannot prove this browser holds — unsubscribes and registers\n * a fresh endpoint, once.\n *\n * **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both\n * `PushDeviceError(\"no_service_worker\")`; the browser's subscribe and unsubscribe are cut\n * at 15 s, `PushDeviceError(\"push_timeout\")` — so `busy` can never latch.\n *\n * Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.\n */\nexport function usePushDevice(options: UsePushDeviceOptions): PushDevice {\n // The app's functions are usually new on every render; the effects read the latest.\n const latest = useRef(options);\n useEffect(() => {\n latest.current = options;\n });\n\n const supported = pushSupported();\n const ios = isIos();\n const standalone = useMediaQuery(\"(display-mode: standalone)\", false);\n const installed =\n standalone ||\n (typeof navigator !== \"undefined\" && (navigator as Navigator & { standalone?: boolean }).standalone === true);\n\n const [permission, setPermission] = useState<PushPermission>(() =>\n supported ? Notification.permission : \"unsupported\",\n );\n const [configured, setConfigured] = useState<boolean | undefined>(undefined);\n const [subscribed, setSubscribed] = useState(false);\n const [foreignSubscription, setForeignSubscription] = useState(false);\n const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const mounted = useRef(true);\n // Set while this hook fires the event itself: it already knows what changed, and a\n // re-read could only be worse — an app whose `devices()` answers from a cache would\n // hand back the list from before the change.\n const announcing = useRef(false);\n const announce = useCallback(() => {\n announcing.current = true;\n try {\n notifyPushSubscriptionChanged();\n } finally {\n announcing.current = false;\n }\n latest.current.onChange?.();\n }, []);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // Whether the server has keys (\"\" → push is off there). Once per mount.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n latest.current.pushKey().then(\n (key) => {\n if (!cancelled) setConfigured(key !== \"\");\n },\n () => {\n /* unknown: the card shows the ordinary states */\n },\n );\n return () => {\n cancelled = true;\n };\n }, [supported]);\n\n // What this browser holds, and whether it is this account's — at mount, and whenever\n // a subscription changes anywhere in the app.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n const sync = async () => {\n try {\n const registration = await activeRegistration();\n const existing = await registration.pushManager.getSubscription();\n if (cancelled) return;\n // Read again: the person may have changed it in the browser's settings.\n setPermission(Notification.permission);\n if (!existing) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n return;\n }\n let mine: boolean;\n try {\n const devices = await latest.current.devices();\n mine = devices.some((device) => device.endpoint === existing.endpoint);\n } catch {\n mine = true;\n }\n if (cancelled) return;\n setCurrentEndpoint(existing.endpoint);\n setSubscribed(mine);\n setForeignSubscription(!mine);\n } catch {\n /* no active service worker yet: not subscribed */\n }\n };\n const heard = () => {\n if (!announcing.current) void sync();\n };\n // The worker's word after it re-registered a rotated subscription (0.46).\n const fromWorker = (event: MessageEvent) => {\n if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();\n };\n const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;\n void sync();\n window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.addEventListener?.(\"message\", fromWorker);\n return () => {\n cancelled = true;\n window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.removeEventListener?.(\"message\", fromWorker);\n };\n }, [supported]);\n\n const enable = useCallback(async (): Promise<boolean> => {\n if (!supported) return false;\n const { pushKey, register, isEndpointTaken = defaultEndpointTaken } = latest.current;\n setBusy(true);\n try {\n const granted = await Notification.requestPermission();\n if (mounted.current) setPermission(granted);\n if (granted !== \"granted\") return false;\n\n const key = await pushKey();\n if (!key) {\n if (mounted.current) setConfigured(false);\n return false;\n }\n if (mounted.current) setConfigured(true);\n\n const registration = await activeRegistration();\n // The one place the browser is asked for a subscription, so the timeout and the\n // `{userVisibleOnly, applicationServerKey}` literal are on both paths.\n const subscribe = (reuse: boolean) =>\n within(\n (async () =>\n (reuse ? await registration.pushManager.getSubscription() : null) ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: keyBytes(key),\n })))(),\n PUSH_TIMEOUT_MS,\n \"push_timeout\",\n );\n\n let subscription = await subscribe(true);\n let input = inputOf(subscription);\n if (!input) return false;\n try {\n await register(input);\n } catch (error) {\n if (!isEndpointTaken(error)) throw error;\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n subscription = await subscribe(false);\n input = inputOf(subscription);\n if (!input) return false;\n await register(input);\n }\n if (mounted.current) {\n setSubscribed(true);\n setForeignSubscription(false);\n setCurrentEndpoint(input.endpoint);\n }\n announce();\n return true;\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const disable = useCallback(async (): Promise<void> => {\n if (!supported) return;\n setBusy(true);\n try {\n await releaseSubscription((endpoint) => latest.current.unregister(endpoint));\n if (mounted.current) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n }\n announce();\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const sendTest = useCallback(() => latest.current.test(), []);\n\n return {\n supported,\n configured,\n installed,\n ios,\n permission,\n subscribed,\n foreignSubscription,\n currentEndpoint,\n busy,\n enable,\n disable,\n sendTest,\n };\n}\n"],"mappings":"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","names":[]}
@@ -88,7 +88,9 @@ export interface QueryPersistence {
88
88
  /** Everything `<PersistQueryClientProvider persistOptions>` needs. */
89
89
  persistOptions: Omit<PersistQueryClientOptions, "queryClient">;
90
90
  /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this
91
- * key's persisted copy. Best-effort; never throws. */
91
+ * key's persisted copy. In every tab (0.52): the persistences of this key in the other tabs
92
+ * stop writing too, and delete the key once more after any save of theirs under way.
93
+ * Best-effort; never throws. */
92
94
  clear: (queryClient: QueryClient) => Promise<void>;
93
95
  /** The company switch (or any change of key without a sign-out): stops writing under this
94
96
  * key — a save already scheduled is dropped too — and empties the in-memory cache, so
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
3
3
  import { del, get, keys, set } from "idb-keyval";
4
+ import { listenOn, postOnce } from "../lib/broadcast.js";
4
5
  const DEFAULT_SCRUB_FIELDS = Object.freeze([
5
6
  "iban",
6
7
  "qr_iban",
@@ -78,6 +79,20 @@ function createQueryPersistence({
78
79
  const key = `${prefix}${user}${company === void 0 || company === null || company === "" ? "" : `-${company}`}`;
79
80
  const denied = new Set(denyRoots);
80
81
  let retired = false;
82
+ const across = `eifi1-query-cache:${key}`;
83
+ let unlisten = null;
84
+ const retire = () => {
85
+ retired = true;
86
+ unlisten?.();
87
+ unlisten = null;
88
+ };
89
+ const listen = () => {
90
+ unlisten ??= listenOn(across, (data) => {
91
+ if (data !== "clear") return;
92
+ retire();
93
+ Promise.resolve().then(() => storage.removeItem(key)).catch(() => void 0);
94
+ });
95
+ };
81
96
  const keyAllowed = (queryKey) => !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);
82
97
  const shouldDehydrateQuery = (query) => {
83
98
  if (query.state.status !== "success" || !keyAllowed(query.queryKey)) return false;
@@ -136,6 +151,7 @@ function createQueryPersistence({
136
151
  // Only the provider's restore reads: mounting this persistence again re-arms it.
137
152
  getItem: async (k) => {
138
153
  retired = false;
154
+ listen();
139
155
  return await storage.getItem(k) ?? null;
140
156
  },
141
157
  setItem: async (k, v) => {
@@ -151,14 +167,15 @@ function createQueryPersistence({
151
167
  deserialize
152
168
  });
153
169
  const leave = async (queryClient) => {
154
- retired = true;
170
+ retire();
155
171
  try {
156
172
  queryClient.clear();
157
173
  } catch {
158
174
  }
159
175
  };
160
176
  const clear = async (queryClient) => {
161
- retired = true;
177
+ retire();
178
+ postOnce(across, "clear");
162
179
  try {
163
180
  queryClient.clear();
164
181
  await persister.removeClient();