@eifi1/ui-kit 0.46.0 → 0.47.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 (148) hide show
  1. package/README.md +12 -11
  2. package/dist/admin/user-roster.d.ts +6 -2
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/components/data-table-row-actions.d.ts +22 -5
  6. package/dist/components/data-table-row-actions.js +14 -5
  7. package/dist/components/data-table-row-actions.js.map +1 -1
  8. package/dist/components/data-table.d.ts +16 -1
  9. package/dist/components/data-table.js +20 -1
  10. package/dist/components/data-table.js.map +1 -1
  11. package/dist/components/row-actions.d.ts +37 -5
  12. package/dist/components/row-actions.js +22 -5
  13. package/dist/components/row-actions.js.map +1 -1
  14. package/dist/components/tabs.d.ts +24 -16
  15. package/dist/components/tabs.js +37 -31
  16. package/dist/components/tabs.js.map +1 -1
  17. package/dist/components/translation-review.js +3 -1
  18. package/dist/components/translation-review.js.map +1 -1
  19. package/dist/components/ui-labels.js +1 -1
  20. package/dist/components/ui-labels.js.map +1 -1
  21. package/dist/data-table.d.ts +1 -0
  22. package/dist/data-table.js +2 -0
  23. package/dist/data-table.js.map +1 -1
  24. package/dist/eslint/index.js +1 -1
  25. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  26. package/dist/feedback/feedback-attachment-download.js +59 -0
  27. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  28. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  29. package/dist/feedback/feedback-comment-thread.js +21 -2
  30. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  31. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  32. package/dist/feedback/feedback-entry-file.js +76 -0
  33. package/dist/feedback/feedback-entry-file.js.map +1 -0
  34. package/dist/feedback/feedback-public-list.d.ts +18 -5
  35. package/dist/feedback/feedback-public-list.js +8 -0
  36. package/dist/feedback/feedback-public-list.js.map +1 -1
  37. package/dist/feedback/feedback-public-types.d.ts +7 -2
  38. package/dist/feedback/feedback-replies.d.ts +26 -9
  39. package/dist/feedback/feedback-replies.js +20 -10
  40. package/dist/feedback/feedback-replies.js.map +1 -1
  41. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  42. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  43. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  44. package/dist/feedback/feedback-row-detail.js +10 -54
  45. package/dist/feedback/feedback-row-detail.js.map +1 -1
  46. package/dist/feedback.d.ts +1 -0
  47. package/dist/feedback.js +1 -0
  48. package/dist/feedback.js.map +1 -1
  49. package/dist/i18n/german.js +1 -6
  50. package/dist/i18n/german.js.map +1 -1
  51. package/dist/i18n/locales/en.js +1 -6
  52. package/dist/i18n/locales/en.js.map +2 -2
  53. package/dist/i18n/locales/es.js +1 -6
  54. package/dist/i18n/locales/es.js.map +1 -1
  55. package/dist/i18n/locales/fr.js +1 -6
  56. package/dist/i18n/locales/fr.js.map +1 -1
  57. package/dist/i18n/locales/hu.js +1 -6
  58. package/dist/i18n/locales/hu.js.map +1 -1
  59. package/dist/i18n/locales/it.js +1 -6
  60. package/dist/i18n/locales/it.js.map +1 -1
  61. package/dist/i18n/locales/zh.js +1 -6
  62. package/dist/i18n/locales/zh.js.map +1 -1
  63. package/dist/i18n/review.js +0 -1
  64. package/dist/i18n/review.js.map +1 -1
  65. package/dist/index.d.ts +5 -7
  66. package/dist/index.js +14 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/notifications/device-zone-report.d.ts +7 -0
  69. package/dist/notifications/device-zone-report.js +8 -1
  70. package/dist/notifications/device-zone-report.js.map +1 -1
  71. package/dist/notifications/feedback-notification-types.d.ts +14 -1
  72. package/dist/notifications/feedback-notification-types.js +5 -1
  73. package/dist/notifications/feedback-notification-types.js.map +1 -1
  74. package/dist/notifications/notification-bell.d.ts +1 -1
  75. package/dist/notifications/notification-bell.js.map +1 -1
  76. package/dist/notifications/notification-inbox.d.ts +12 -3
  77. package/dist/notifications/notification-inbox.js +2 -2
  78. package/dist/notifications/notification-inbox.js.map +1 -1
  79. package/dist/notifications/notification-types.d.ts +0 -3
  80. package/dist/notifications/notifications-labels.d.ts +2 -13
  81. package/dist/notifications/notifications-labels.js +0 -4
  82. package/dist/notifications/notifications-labels.js.map +1 -1
  83. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  84. package/dist/notifications/quiet-hours-setting.js +8 -3
  85. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  86. package/dist/offline/local-first.d.ts +9 -0
  87. package/dist/offline/local-first.js +14 -0
  88. package/dist/offline/local-first.js.map +1 -1
  89. package/dist/offline.d.ts +1 -1
  90. package/dist/offline.js +2 -1
  91. package/dist/offline.js.map +1 -1
  92. package/dist/sync/db.d.ts +17 -0
  93. package/dist/sync/db.js +10 -1
  94. package/dist/sync/db.js.map +1 -1
  95. package/dist/sync/engine.d.ts +11 -9
  96. package/dist/sync/engine.js.map +1 -1
  97. package/dist/sync/hooks.d.ts +5 -0
  98. package/dist/sync/hooks.js.map +1 -1
  99. package/dist/sync.d.ts +2 -2
  100. package/dist/sync.js +5 -2
  101. package/dist/sync.js.map +1 -1
  102. package/dist/theme/theme-store.d.ts +4 -4
  103. package/dist/theme/theme-store.js +1 -0
  104. package/dist/theme/theme-store.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin/user-roster.tsx +12 -3
  107. package/src/components/data-table-row-actions.tsx +33 -9
  108. package/src/components/data-table.tsx +46 -3
  109. package/src/components/row-actions.tsx +73 -10
  110. package/src/components/tabs.tsx +66 -44
  111. package/src/components/translation-review.tsx +3 -1
  112. package/src/components/ui-labels.ts +1 -1
  113. package/src/data-table.ts +3 -0
  114. package/src/feedback/feedback-attachment-download.tsx +77 -0
  115. package/src/feedback/feedback-comment-thread.tsx +42 -7
  116. package/src/feedback/feedback-entry-file.tsx +150 -0
  117. package/src/feedback/feedback-public-list.tsx +25 -4
  118. package/src/feedback/feedback-public-types.ts +7 -2
  119. package/src/feedback/feedback-replies.tsx +46 -19
  120. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  121. package/src/feedback/feedback-row-detail.tsx +35 -86
  122. package/src/feedback.ts +1 -0
  123. package/src/i18n/german.ts +1 -6
  124. package/src/i18n/locales/es.ts +1 -6
  125. package/src/i18n/locales/fr.ts +1 -6
  126. package/src/i18n/locales/hu.ts +1 -6
  127. package/src/i18n/locales/it.ts +1 -6
  128. package/src/i18n/locales/zh.ts +1 -6
  129. package/src/i18n/review.ts +0 -1
  130. package/src/index.ts +19 -5
  131. package/src/notifications/device-zone-report.ts +14 -1
  132. package/src/notifications/feedback-notification-types.ts +19 -1
  133. package/src/notifications/notification-bell.tsx +1 -1
  134. package/src/notifications/notification-inbox.tsx +15 -5
  135. package/src/notifications/notification-types.ts +0 -3
  136. package/src/notifications/notifications-labels.ts +2 -17
  137. package/src/notifications/quiet-hours-setting.tsx +16 -11
  138. package/src/offline/local-first.ts +24 -0
  139. package/src/offline.ts +1 -1
  140. package/src/sync/db.ts +22 -0
  141. package/src/sync/engine.ts +11 -9
  142. package/src/sync/hooks.ts +5 -0
  143. package/src/sync.ts +2 -2
  144. package/src/theme/theme-store.ts +13 -5
  145. package/dist/notifications/time-zone-select.d.ts +0 -53
  146. package/dist/notifications/time-zone-select.js +0 -76
  147. package/dist/notifications/time-zone-select.js.map +0 -1
  148. package/src/notifications/time-zone-select.tsx +0 -129
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/notification-inbox.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Bell, CheckCheck, Trash2 } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { IconButton } from \"../components/icon-button\";\nimport { List, ListItem } from \"../components/list\";\nimport { Skeleton } from \"../components/skeleton\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\n\n/**\n * One inbox row as `GET /notifications/inbox` answers it (server-kit `InboxItem`, §3.7):\n * the title and body already in the recipient's language.\n */\nexport interface NotificationInboxItem<Id extends string | number = string | number> {\n id: Id;\n /** The type id, for the row's icon. */\n notification_type: string;\n title: string;\n body: string;\n /** A path in the app, an absolute URL, or `null` (nothing to open). */\n url: string | null;\n /** ISO date-time. */\n created_at: string;\n /** ISO date-time, or `null` while unread. */\n read_at: string | null;\n /** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on\n * the row — kastlan's inbox holds every company's rows. */\n company?: string | null;\n}\n\nexport interface NotificationInboxProps<Id extends string | number = string | number>\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The rows, newest first; `undefined` while the first page loads. */\n items: readonly NotificationInboxItem<Id>[] | undefined;\n /** Every unread row of the account (`unread_count`), not only this page's. */\n unreadCount: number;\n loading?: boolean;\n /** `POST /notifications/inbox/read {ids}` — these rows, or `null` for all. */\n onMarkRead: (ids: Id[] | null) => Promise<unknown> | void;\n /** `DELETE /notifications/inbox`. Asked first, in the header. */\n onClear: () => Promise<unknown> | void;\n /**\n * Open a row: the kit marks it read first, then calls this, and the app routes to\n * `item.url` (kastlan switches the acting company first). Every row is a button then.\n * Left out, a row with a `url` is a real link — an in-app path through the provider's\n * router link, an absolute URL in a new tab — that marks itself read on a click or a\n * middle click (keksdose #451), and a row without one marks itself read.\n */\n onOpen?: (item: NotificationInboxItem<Id>) => void;\n /** The row's glyph for a type — the app's map (keksdose's `TYPE_ICONS`). `undefined`,\n * or a type it does not know (a retired one), is the `Bell`. */\n icon?: (type: string) => LucideIcon | undefined;\n /** More rows than shown: a \"Show more\" row calls `onLoadMore` (the API pages by\n * `offset`). */\n hasMore?: boolean;\n onLoadMore?: () => void;\n /** The next page is loading. */\n loadingMore?: boolean;\n /** A row's time. Default: the provider's locale and `timeZone`, date and time. */\n formatDate?: (iso: string) => string;\n /** The heading. Default `labels.inboxTitle` (\"Inbox\"). */\n title?: ReactNode;\n /** The app's own words for a failed request, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/**\n * The in-app inbox (0.46, docs/notifications-harmonization.md §10.1), keksdose's list\n * lifted: newest first, an icon per type (`Bell` for one the app does not map), the\n * title and two lines of body, the time, and — kastlan — the company the row is about.\n * Unread rows are bold with a dot. Props only: the app keeps the data (poll the badge\n * every {@link INBOX_BADGE_INTERVAL}, invalidate on its worker's push message).\n *\n * - **Opening a row marks it read** (see `onOpen`).\n * - **\"Mark all as read\"** while anything is unread; **\"Clear all\"** asks first, in the\n * header — no `ConfirmProvider` needed — with focus on Cancel.\n * - **\"Show more\"** with `hasMore`; the list scrolls inside a box of at most\n * `min(24rem, 70vh)`, so it never pushes the settings below it off the page.\n *\n * Under a write lock the header's actions are `commit` controls exempt from billing.\n */\nexport function NotificationInbox<Id extends string | number = string | number>({\n items,\n unreadCount,\n loading,\n onMarkRead,\n onClear,\n onOpen,\n icon,\n hasMore = false,\n onLoadMore,\n loadingMore = false,\n formatDate: formatDateProp,\n title,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: NotificationInboxProps<Id>) {\n const labels = useNotificationsLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { formatDate } = useKitFormat();\n const [asking, setAsking] = useState(false);\n const [busy, setBusy] = useState<\"read\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const headingId = useId();\n const cancelRef = useRef<HTMLButtonElement>(null);\n const clearRef = useRef<HTMLButtonElement>(null);\n const [returning, setReturning] = useState(false);\n useEffect(() => {\n if (asking) cancelRef.current?.focus();\n else if (returning) clearRef.current?.focus();\n }, [asking, returning]);\n\n const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"dateTime\"));\n const iconOf = (type: string): LucideIcon => icon?.(type) ?? Bell;\n\n const run = (which: \"read\" | \"clear\", call: () => unknown, after?: () => void) => {\n setBusy(which);\n setFailure(null);\n settle(call).then(\n () => {\n if (!mounted.current) return;\n setBusy(null);\n after?.();\n },\n (error: unknown) => {\n if (!mounted.current) return;\n setBusy(null);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n /** Mark one row read; a failure is said under the list, the row stays as it is. */\n const markOne = (item: NotificationInboxItem<Id>) => {\n if (item.read_at !== null) return;\n settle(() => onMarkRead([item.id])).catch((error: unknown) => {\n if (mounted.current) setFailure(describeFailure(describeError?.(error), labels.failed));\n });\n };\n\n const leave = () => {\n setReturning(true);\n setAsking(false);\n };\n const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n event.stopPropagation();\n leave();\n }\n };\n\n const count = items?.length ?? 0;\n\n const header = asking ? (\n <div className=\"space-y-2 border-b border-subtle px-3 py-2\" data-inbox-confirm=\"\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.clearPrompt}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"clear\"}\n onClick={() => run(\"clear\", onClear, () => setAsking(false))}\n onKeyDown={escapeLeaves}\n >\n {labels.clearAll}\n </Button>\n <Button ref={cancelRef} type=\"button\" size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {common.cancel}\n </Button>\n </div>\n </div>\n ) : (\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-2\">\n <SettingsCardTitle id={headingId}>{title ?? labels.inboxTitle}</SettingsCardTitle>\n <div className=\"flex items-center gap-0.5\">\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit={COMMIT_EXCEPT_BILLING}\n onClick={() => run(\"read\", () => onMarkRead(null))}\n disabled={unreadCount === 0 || busy !== null}\n label={labels.markAllRead}\n >\n <CheckCheck />\n </IconButton>\n <IconButton\n ref={clearRef}\n type=\"button\"\n size=\"xs\"\n tone=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n onClick={() => {\n setReturning(false);\n setAsking(true);\n }}\n disabled={count === 0 || busy !== null}\n label={labels.clearAll}\n >\n <Trash2 />\n </IconButton>\n </div>\n </div>\n );\n\n let body: ReactNode;\n if (loading || items === undefined) {\n body = <Skeleton lines={3} label={labels.inboxLoading} className=\"p-3\" />;\n } else if (items.length === 0) {\n body = <EmptyState variant=\"inline\" title={labels.inboxEmpty} className=\"px-3 py-2\" />;\n } else {\n body = (\n // Named by the heading — except while \"Clear all\" asks, when the heading is away.\n <List className=\"p-1\" aria-labelledby={asking ? undefined : headingId} aria-label={asking ? labels.inboxTitle : undefined}>\n {items.map((item) => {\n const row = {\n title: item.title,\n subtitle: item.body,\n subtitleLines: 2 as const,\n overline: item.company ?? undefined,\n meta: timeOf(item.created_at),\n icon: iconOf(item.notification_type),\n unread: item.read_at === null,\n align: \"start\" as const,\n };\n if (onOpen) {\n return (\n <ListItem\n key={item.id}\n {...row}\n data-inbox-item={String(item.id)}\n onClick={() => {\n markOne(item);\n onOpen(item);\n }}\n />\n );\n }\n if (!item.url) {\n return <ListItem key={item.id} {...row} data-inbox-item={String(item.id)} onClick={() => markOne(item)} />;\n }\n // A real link, so a middle click opens a background tab; a middle click fires no\n // `click`, hence `onAuxClick` (keksdose #451).\n return (\n <ListItem\n key={item.id}\n {...row}\n data-inbox-item={String(item.id)}\n href={item.url}\n external={!item.url.startsWith(\"/\")}\n onClick={() => markOne(item)}\n onAuxClick={(event) => {\n if (event.button === 1) markOne(item);\n }}\n />\n );\n })}\n {hasMore && onLoadMore && (\n <li className=\"px-2 py-1\">\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" pending={loadingMore} onClick={onLoadMore} className=\"w-full\">\n {labels.showMore}\n </Button>\n </li>\n )}\n </List>\n );\n }\n\n return (\n <Card {...rest} className={cn(\"overflow-hidden p-0\", className)}>\n {header}\n <div className=\"max-h-[min(24rem,70vh)] overflow-y-auto overscroll-contain\">{body}</div>\n {failure !== null && (\n <div className=\"px-3 pb-3\">\n <FailureLine>{failure}</FailureLine>\n </div>\n )}\n </Card>\n );\n}\n\n/** How often an app polls the unread count for the badge (keksdose's 60 s, `limit:1`). */\nexport const INBOX_BADGE_INTERVAL = 60_000;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/notification-inbox.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { Bell, CheckCheck, Trash2 } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { IconButton } from \"../components/icon-button\";\nimport { List, ListItem } from \"../components/list\";\nimport { Skeleton } from \"../components/skeleton\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\n\n/**\n * One inbox row as `GET /notifications/inbox` answers it (server-kit `InboxItem`, §3.7):\n * the title and body already in the recipient's language.\n */\nexport interface NotificationInboxItem<Id extends string | number = string | number> {\n id: Id;\n /** The type id, for the row's icon. */\n notification_type: string;\n title: string;\n body: string;\n /** A path in the app, an absolute URL, or `null` (nothing to open). */\n url: string | null;\n /** ISO date-time. */\n created_at: string;\n /** ISO date-time, or `null` while unread. */\n read_at: string | null;\n /** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on\n * the row — kastlan's inbox holds every company's rows. */\n company?: string | null;\n /** 0.46.1 (server-kit 0.16.1 `InboxItem.company_id`): the same company by its id, so\n * `onOpen` switches to it exactly (two companies may share a name). */\n company_id?: number | string | null;\n}\n\n/** The inbox request that failed, as `describeError` gets it (0.46.1): one row marked read,\n * \"Mark all as read\", or \"Clear all\". */\nexport type NotificationInboxAction = \"read\" | \"readAll\" | \"clear\";\n\nexport interface NotificationInboxProps<Id extends string | number = string | number>\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The rows, newest first; `undefined` while the first page loads. */\n items: readonly NotificationInboxItem<Id>[] | undefined;\n /** Every unread row of the account (`unread_count`), not only this page's. */\n unreadCount: number;\n loading?: boolean;\n /** `POST /notifications/inbox/read {ids}` — these rows, or `null` for all. */\n onMarkRead: (ids: Id[] | null) => Promise<unknown> | void;\n /** `DELETE /notifications/inbox`. Asked first, in the header. */\n onClear: () => Promise<unknown> | void;\n /**\n * Open a row: the kit marks it read first, then calls this, and the app routes to\n * `item.url` (kastlan switches the acting company, `item.company_id`, first). Every row is a button then.\n * Left out, a row with a `url` is a real link — an in-app path through the provider's\n * router link, an absolute URL in a new tab — that marks itself read on a click or a\n * middle click (keksdose #451), and a row without one marks itself read.\n */\n onOpen?: (item: NotificationInboxItem<Id>) => void;\n /** The row's glyph for a type — the app's map (keksdose's `TYPE_ICONS`). `undefined`,\n * or a type it does not know (a retired one), is the `Bell`. */\n icon?: (type: string) => LucideIcon | undefined;\n /** More rows than shown: a \"Show more\" row calls `onLoadMore` (the API pages by\n * `offset`). */\n hasMore?: boolean;\n onLoadMore?: () => void;\n /** The next page is loading. */\n loadingMore?: boolean;\n /** A row's time. Default: the provider's locale and `timeZone`, date and time. */\n formatDate?: (iso: string) => string;\n /** The heading. Default `labels.inboxTitle` (\"Inbox\"). */\n title?: ReactNode;\n /** The app's own words for a failed request, or `undefined` for the kit's `failed`.\n * `action` (0.46.1) says which request failed: `\"read\"` (one row), `\"readAll\"` (\"Mark\n * all as read\") or `\"clear\"` (\"Clear all\") — so a failed mark-read is not worded as a\n * failed clear. */\n describeError?: (error: unknown, action: NotificationInboxAction) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/**\n * The in-app inbox (0.46, docs/notifications-harmonization.md §10.1), keksdose's list\n * lifted: newest first, an icon per type (`Bell` for one the app does not map), the\n * title and two lines of body, the time, and — kastlan — the company the row is about.\n * Unread rows are bold with a dot. Props only: the app keeps the data (poll the badge\n * every {@link INBOX_BADGE_INTERVAL}, invalidate on its worker's push message).\n *\n * - **Opening a row marks it read** (see `onOpen`).\n * - **\"Mark all as read\"** while anything is unread; **\"Clear all\"** asks first, in the\n * header — no `ConfirmProvider` needed — with focus on Cancel.\n * - **\"Show more\"** with `hasMore`; the list scrolls inside a box of at most\n * `min(24rem, 70vh)`, so it never pushes the settings below it off the page.\n *\n * Under a write lock the header's actions are `commit` controls exempt from billing.\n */\nexport function NotificationInbox<Id extends string | number = string | number>({\n items,\n unreadCount,\n loading,\n onMarkRead,\n onClear,\n onOpen,\n icon,\n hasMore = false,\n onLoadMore,\n loadingMore = false,\n formatDate: formatDateProp,\n title,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: NotificationInboxProps<Id>) {\n const labels = useNotificationsLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { formatDate } = useKitFormat();\n const [asking, setAsking] = useState(false);\n const [busy, setBusy] = useState<\"read\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const headingId = useId();\n const cancelRef = useRef<HTMLButtonElement>(null);\n const clearRef = useRef<HTMLButtonElement>(null);\n const [returning, setReturning] = useState(false);\n useEffect(() => {\n if (asking) cancelRef.current?.focus();\n else if (returning) clearRef.current?.focus();\n }, [asking, returning]);\n\n const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"dateTime\"));\n const iconOf = (type: string): LucideIcon => icon?.(type) ?? Bell;\n\n const run = (which: \"read\" | \"clear\", call: () => unknown, after?: () => void) => {\n setBusy(which);\n setFailure(null);\n settle(call).then(\n () => {\n if (!mounted.current) return;\n setBusy(null);\n after?.();\n },\n (error: unknown) => {\n if (!mounted.current) return;\n setBusy(null);\n setFailure(describeFailure(describeError?.(error, which === \"read\" ? \"readAll\" : \"clear\"), labels.failed));\n },\n );\n };\n\n /** Mark one row read; a failure is said under the list, the row stays as it is. */\n const markOne = (item: NotificationInboxItem<Id>) => {\n if (item.read_at !== null) return;\n settle(() => onMarkRead([item.id])).catch((error: unknown) => {\n if (mounted.current) setFailure(describeFailure(describeError?.(error, \"read\"), labels.failed));\n });\n };\n\n const leave = () => {\n setReturning(true);\n setAsking(false);\n };\n const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n event.stopPropagation();\n leave();\n }\n };\n\n const count = items?.length ?? 0;\n\n const header = asking ? (\n <div className=\"space-y-2 border-b border-subtle px-3 py-2\" data-inbox-confirm=\"\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.clearPrompt}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"clear\"}\n onClick={() => run(\"clear\", onClear, () => setAsking(false))}\n onKeyDown={escapeLeaves}\n >\n {labels.clearAll}\n </Button>\n <Button ref={cancelRef} type=\"button\" size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {common.cancel}\n </Button>\n </div>\n </div>\n ) : (\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-2\">\n <SettingsCardTitle id={headingId}>{title ?? labels.inboxTitle}</SettingsCardTitle>\n <div className=\"flex items-center gap-0.5\">\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit={COMMIT_EXCEPT_BILLING}\n onClick={() => run(\"read\", () => onMarkRead(null))}\n disabled={unreadCount === 0 || busy !== null}\n label={labels.markAllRead}\n >\n <CheckCheck />\n </IconButton>\n <IconButton\n ref={clearRef}\n type=\"button\"\n size=\"xs\"\n tone=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n onClick={() => {\n setReturning(false);\n setAsking(true);\n }}\n disabled={count === 0 || busy !== null}\n label={labels.clearAll}\n >\n <Trash2 />\n </IconButton>\n </div>\n </div>\n );\n\n let body: ReactNode;\n if (loading || items === undefined) {\n body = <Skeleton lines={3} label={labels.inboxLoading} className=\"p-3\" />;\n } else if (items.length === 0) {\n body = <EmptyState variant=\"inline\" title={labels.inboxEmpty} className=\"px-3 py-2\" />;\n } else {\n body = (\n // Named by the heading — except while \"Clear all\" asks, when the heading is away.\n <List className=\"p-1\" aria-labelledby={asking ? undefined : headingId} aria-label={asking ? labels.inboxTitle : undefined}>\n {items.map((item) => {\n const row = {\n title: item.title,\n subtitle: item.body,\n subtitleLines: 2 as const,\n overline: item.company ?? undefined,\n meta: timeOf(item.created_at),\n icon: iconOf(item.notification_type),\n unread: item.read_at === null,\n align: \"start\" as const,\n };\n if (onOpen) {\n return (\n <ListItem\n key={item.id}\n {...row}\n data-inbox-item={String(item.id)}\n onClick={() => {\n markOne(item);\n onOpen(item);\n }}\n />\n );\n }\n if (!item.url) {\n return <ListItem key={item.id} {...row} data-inbox-item={String(item.id)} onClick={() => markOne(item)} />;\n }\n // A real link, so a middle click opens a background tab; a middle click fires no\n // `click`, hence `onAuxClick` (keksdose #451).\n return (\n <ListItem\n key={item.id}\n {...row}\n data-inbox-item={String(item.id)}\n href={item.url}\n external={!item.url.startsWith(\"/\")}\n onClick={() => markOne(item)}\n onAuxClick={(event) => {\n if (event.button === 1) markOne(item);\n }}\n />\n );\n })}\n {hasMore && onLoadMore && (\n <li className=\"px-2 py-1\">\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" pending={loadingMore} onClick={onLoadMore} className=\"w-full\">\n {labels.showMore}\n </Button>\n </li>\n )}\n </List>\n );\n }\n\n return (\n <Card {...rest} className={cn(\"overflow-hidden p-0\", className)}>\n {header}\n <div className=\"max-h-[min(24rem,70vh)] overflow-y-auto overscroll-contain\">{body}</div>\n {failure !== null && (\n <div className=\"px-3 pb-3\">\n <FailureLine>{failure}</FailureLine>\n </div>\n )}\n </Card>\n );\n}\n\n/** How often an app polls the unread count for the badge (keksdose's 60 s, `limit:1`). */\nexport const INBOX_BADGE_INTERVAL = 60_000;\n"],"mappings":"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","names":[]}
@@ -64,9 +64,6 @@ export interface NotificationSettingsPatch<Id extends string = string> {
64
64
  digest_cadence?: DigestCadence;
65
65
  quiet_hours_start?: string | null;
66
66
  quiet_hours_end?: string | null;
67
- /** @deprecated 0.46 — the zone is the account's, automatic only (§10.3), and server-kit 0.16 refuses it here;
68
- * removed in 0.47. No kit card sends it. */
69
- timezone?: string;
70
67
  }
71
68
  /** One registered push device, as `GET /notifications/devices` lists it (§3.5), oldest
72
69
  * first. The owner gets the whole endpoint, which marks "this device". */
@@ -15,7 +15,7 @@ export interface NotificationRegionLabels {
15
15
  /**
16
16
  * Every string the notification parts render — the `notifications` namespace of
17
17
  * `<UiKitProvider labels>`, overridable per instance through `labels`: the types card,
18
- * quiet hours and the zone picker, this device's push, the device list, and the
18
+ * quiet hours, this device's push, the device list, and the
19
19
  * unsubscribe page. Cancel is `common.cancel`.
20
20
  */
21
21
  export interface NotificationsLabels {
@@ -23,9 +23,6 @@ export interface NotificationsLabels {
23
23
  typesTitle: string;
24
24
  /** The line under it. */
25
25
  typesDescription: string;
26
- /** @deprecated 0.46 — the card's channels are columns now (`channelEmail`); removed in 0.47. Unused,
27
- * kept so an app's override still type-checks. */
28
- byEmail: string;
29
26
  /** 0.46 (§10.2): the channel columns' headings, and each switch's name. */
30
27
  channelApp: string;
31
28
  channelPush: string;
@@ -55,15 +52,7 @@ export interface NotificationsLabels {
55
52
  quietUntil: string;
56
53
  /** One time set, the other not: quiet hours fail open until both are. */
57
54
  quietIncomplete: string;
58
- /** The zone picker's label. */
59
- timeZone: string;
60
- /** @deprecated 0.46 — the zone is automatic (§10.3), the card offers no device zone; removed in 0.47.
61
- * Unused, kept so an app's override still type-checks. */
62
- deviceZone: (zone: string) => string;
63
- /** @deprecated 0.46 — the zone is automatic (§10.3), nothing to take; removed in 0.47. Unused, kept
64
- * so an app's override still type-checks. */
65
- useDeviceZone: string;
66
- /** The zone's region headings, in the picker and in a zone's name ("Europe / Zurich"). */
55
+ /** The zone's region headings, in a zone's name ("Europe / Zurich"). */
67
56
  regions: NotificationRegionLabels;
68
57
  pushTitle: string;
69
58
  pushDescription: string;
@@ -2,7 +2,6 @@
2
2
  const DEFAULT_NOTIFICATIONS_LABELS = {
3
3
  typesTitle: "Notify me about",
4
4
  typesDescription: "Choose what you hear about, and how.",
5
- byEmail: "By e-mail",
6
5
  channelApp: "In the app",
7
6
  channelPush: "Push",
8
7
  channelEmail: "E-mail",
@@ -18,9 +17,6 @@ const DEFAULT_NOTIFICATIONS_LABELS = {
18
17
  quietFrom: "From",
19
18
  quietUntil: "Until",
20
19
  quietIncomplete: "Set both a start and an end time. Quiet hours take effect once both are set.",
21
- timeZone: "Time zone",
22
- deviceZone: (zone) => `This device is in ${zone}.`,
23
- useDeviceZone: "Use it",
24
20
  regions: {
25
21
  Africa: "Africa",
26
22
  America: "Americas",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/notifications-labels.ts"],"sourcesContent":["// The words of the notification cards (docs/notifications-harmonization.md §4.2), in a\n// module with no component code: the English catalogue (src/i18n/defaults.ts) imports\n// them without the components, so `en` loads as one chunk like the other languages\n// (0.34, KS U8). The cards read it through `useKitLabels(\"notifications\", …)`.\n\n/** The ten IANA regions `Intl` lists zones under — keksdose's `tz_region_*` — in the\n * kit's words. A region a runtime adds later keeps its IANA name. */\nexport interface NotificationRegionLabels {\n Africa: string;\n America: string;\n Antarctica: string;\n Arctic: string;\n Asia: string;\n Atlantic: string;\n Australia: string;\n Europe: string;\n Indian: string;\n Pacific: string;\n}\n\n/**\n * Every string the notification parts render — the `notifications` namespace of\n * `<UiKitProvider labels>`, overridable per instance through `labels`: the types card,\n * quiet hours and the zone picker, this device's push, the device list, and the\n * unsubscribe page. Cancel is `common.cancel`.\n */\nexport interface NotificationsLabels {\n /* ── The types card ─────────────────────────────────────────────────── */\n /** The types card's title. */\n typesTitle: string;\n /** The line under it. */\n typesDescription: string;\n /** @deprecated 0.46 — the card's channels are columns now (`channelEmail`); removed in 0.47. Unused,\n * kept so an app's override still type-checks. */\n byEmail: string;\n /** 0.46 (§10.2): the channel columns' headings, and each switch's name. */\n channelApp: string;\n channelPush: string;\n channelEmail: string;\n /** Under every e-mail switch while the account's address is unverified (decision 16):\n * no notification mail goes to an unverified address. */\n emailUnverified: string;\n /** The digest's cadence select. */\n digestCadence: string;\n digestWeekly: string;\n digestMonthly: string;\n /** While the settings load. */\n typesLoading: string;\n /** The server answered no types for this account. */\n typesEmpty: string;\n\n /* ── Quiet hours ────────────────────────────────────────────────────── */\n quietTitle: string;\n /** The line under the title; `inbox`: the app keeps an inbox, which a held push still\n * reaches; `held` (0.46, §10.4): the app queues a held push and sends it when the\n * window ends, instead of dropping it. */\n quietDescription: (inbox: boolean, held?: boolean) => string;\n /** 0.46 (§10.3, automatic only): the card names the account's zone, read-only, given\n * the zone as `zoneName` writes it (\"Europe / Zurich\"); it follows the device last used. */\n quietZone: (zone: string) => string;\n /** The start time's label. */\n quietFrom: string;\n /** The end time's label. */\n quietUntil: string;\n /** One time set, the other not: quiet hours fail open until both are. */\n quietIncomplete: string;\n /** The zone picker's label. */\n timeZone: string;\n /** @deprecated 0.46 — the zone is automatic (§10.3), the card offers no device zone; removed in 0.47.\n * Unused, kept so an app's override still type-checks. */\n deviceZone: (zone: string) => string;\n /** @deprecated 0.46 — the zone is automatic (§10.3), nothing to take; removed in 0.47. Unused, kept\n * so an app's override still type-checks. */\n useDeviceZone: string;\n /** The zone's region headings, in the picker and in a zone's name (\"Europe / Zurich\"). */\n regions: NotificationRegionLabels;\n\n /* ── This device ────────────────────────────────────────────────────── */\n pushTitle: string;\n pushDescription: string;\n /** The browser has no push. */\n unsupported: string;\n /** iOS outside the installed app, given the app's name. */\n iosInstall: (app: string) => string;\n /** The server has no VAPID keys. */\n notConfigured: string;\n /** The browser's permission is denied. */\n blocked: string;\n /** This device gets notifications for the signed-in account. */\n on: string;\n off: string;\n /** This browser's subscription belongs to another account. */\n otherAccount: string;\n enable: string;\n disable: string;\n sendTest: string;\n /** Turning on did not finish (the permission prompt dismissed, a refusal). */\n enableFailed: string;\n /** No service worker, or one that is not ready in 10 s. */\n noServiceWorker: string;\n /** The browser's push service did not answer in 15 s. */\n pushTimeout: string;\n /** Given how many devices the push service accepted the test for (≥ 1). */\n testSent: (count: number) => string;\n /** The test reached no device. */\n testNone: string;\n testFailed: string;\n /** The explainer before the browser's one permission prompt. */\n explainerTitle: string;\n explainerBody: (app: string) => string;\n explainerHint: string;\n explainerContinue: string;\n\n /* ── The device list ────────────────────────────────────────────────── */\n devicesTitle: string;\n devicesDescription: string;\n devicesLoading: string;\n devicesEmpty: string;\n /** A device whose user agent says nothing the kit recognises. */\n unknownDevice: string;\n /** The chip on this browser's row. */\n thisDevice: string;\n /** Given the registration date, formatted. */\n added: (date: string) => string;\n /** Given the last accepted delivery, formatted. */\n lastDelivery: (date: string) => string;\n noDelivery: string;\n /** Given the failed deliveries in a row (≥ 1). */\n failures: (count: number) => string;\n /** A row's button, visible text — and the confirm's answer. */\n remove: string;\n /** The same button's accessible name, given the device: it starts with `remove`. */\n removeItem: (device: string) => string;\n /** The question the row asks before it removes. */\n removePrompt: string;\n /** 0.46 (§10.2): the disclosure on a device's row listing the types with push on. */\n deviceTypes: string;\n /** The line inside it. */\n deviceTypesDescription: string;\n /** No type has push on for the account. */\n deviceTypesEmpty: string;\n\n /* ── The inbox and the bell (0.46, §10.1) ───────────────────────────── */\n /** The inbox's heading (keksdose's \"Inbox\"). */\n inboxTitle: string;\n inboxLoading: string;\n /** Nothing in the inbox. */\n inboxEmpty: string;\n markAllRead: string;\n /** The header's clear button, and the confirm's answer. */\n clearAll: string;\n /** The question asked before the inbox is cleared. */\n clearPrompt: string;\n /** The row that loads the next page. */\n showMore: string;\n /** The bell's name. */\n bellLabel: string;\n /** Read after the bell's name, given the unread count (≥ 1). */\n bellUnread: (count: number) => string;\n /** The bell panel's link to the app's inbox. */\n allNotifications: string;\n\n /* ── The unsubscribe page ───────────────────────────────────────────── */\n unsubscribeTitle: string;\n /** Given the type's name in the app's words. */\n unsubscribeAbout: (type: string) => string;\n /** No type name to give. */\n unsubscribeGeneric: string;\n stopThese: string;\n stopAll: (app: string) => string;\n stoppedThese: string;\n stoppedAll: (app: string) => string;\n /** Account mail is not a notification, and keeps coming. */\n accountMail: string;\n turnBackOn: string;\n openSettings: string;\n /** A token the server refused, or one that does not parse. */\n invalidLink: string;\n\n /* ── Every card ─────────────────────────────────────────────────────── */\n /** A request failed, and the app had no words of its own. */\n failed: string;\n}\n\nexport const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {\n typesTitle: \"Notify me about\",\n typesDescription: \"Choose what you hear about, and how.\",\n byEmail: \"By e-mail\",\n channelApp: \"In the app\",\n channelPush: \"Push\",\n channelEmail: \"E-mail\",\n emailUnverified: \"Confirm your address to receive these.\",\n digestCadence: \"Frequency\",\n digestWeekly: \"Weekly\",\n digestMonthly: \"Monthly\",\n typesLoading: \"Loading notification settings…\",\n typesEmpty: \"There are no notifications for this account.\",\n\n quietTitle: \"Quiet hours\",\n quietDescription: (inbox, held) =>\n held\n ? inbox\n ? \"Push notifications wait during this window and arrive when it ends. They reach your inbox at once.\"\n : \"Push notifications wait during this window and arrive when it ends.\"\n : inbox\n ? \"No push notifications during this window. They still reach your inbox.\"\n : \"No push notifications during this window.\",\n quietZone: (zone) => `In your time zone (${zone}), which follows the device you last used.`,\n quietFrom: \"From\",\n quietUntil: \"Until\",\n quietIncomplete: \"Set both a start and an end time. Quiet hours take effect once both are set.\",\n timeZone: \"Time zone\",\n deviceZone: (zone) => `This device is in ${zone}.`,\n useDeviceZone: \"Use it\",\n regions: {\n Africa: \"Africa\",\n America: \"Americas\",\n Antarctica: \"Antarctica\",\n Arctic: \"Arctic\",\n Asia: \"Asia\",\n Atlantic: \"Atlantic\",\n Australia: \"Australia\",\n Europe: \"Europe\",\n Indian: \"Indian Ocean\",\n Pacific: \"Pacific\",\n },\n\n pushTitle: \"Push notifications\",\n pushDescription: \"Notifications on this device, also while the app is closed.\",\n unsupported: \"This browser doesn’t support push notifications.\",\n iosInstall: (app) =>\n `On iPhone or iPad, add ${app} to your Home Screen first: iOS delivers notifications only to the installed app.`,\n notConfigured: \"Push notifications aren’t set up on this server.\",\n blocked: \"Notifications are blocked in this browser’s settings.\",\n on: \"Notifications are on for this device.\",\n off: \"Notifications are off for this device.\",\n otherAccount:\n \"This browser gets notifications for another account. Turn them on here to get them for the account you’re signed in with.\",\n enable: \"Turn on notifications\",\n disable: \"Turn off\",\n sendTest: \"Send a test\",\n enableFailed: \"Couldn’t turn on notifications.\",\n noServiceWorker: \"Couldn’t reach the app’s service worker. Reload the page and try again.\",\n pushTimeout: \"The browser’s push service didn’t answer. Check your connection and try again.\",\n testSent: (count) => (count === 1 ? \"Test sent to 1 device\" : `Test sent to ${count} devices`),\n testNone: \"No device accepted the test. Turn notifications on for this device first.\",\n testFailed: \"Couldn’t send a test notification.\",\n explainerTitle: \"Turn on notifications?\",\n explainerBody: (app) =>\n `${app} sends a notification only for the kinds you choose, and you can turn them off at any time.`,\n explainerHint: \"Your browser asks for permission next.\",\n explainerContinue: \"Continue\",\n\n devicesTitle: \"Notification devices\",\n devicesDescription: \"Every device that gets push notifications for your account. Remove any you no longer use.\",\n devicesLoading: \"Loading devices…\",\n devicesEmpty: \"No device gets notifications yet.\",\n unknownDevice: \"Unknown device\",\n thisDevice: \"This device\",\n added: (date) => `Added ${date}`,\n lastDelivery: (date) => `Last delivery ${date}`,\n noDelivery: \"No delivery yet\",\n failures: (count) =>\n count === 1 ? \"1 failed delivery in a row\" : `${count} failed deliveries in a row`,\n remove: \"Remove\",\n removeItem: (device) => `Remove ${device}`,\n removePrompt: \"Stop sending notifications to this device?\",\n deviceTypes: \"Types on this device\",\n deviceTypesDescription: \"Turn a type off to mute it on this device only.\",\n deviceTypesEmpty: \"No type has push notifications turned on.\",\n\n inboxTitle: \"Inbox\",\n inboxLoading: \"Loading notifications…\",\n inboxEmpty: \"You’re all caught up.\",\n markAllRead: \"Mark all as read\",\n clearAll: \"Clear all\",\n clearPrompt: \"Delete all notifications from your inbox?\",\n showMore: \"Show more\",\n bellLabel: \"Notifications\",\n bellUnread: (count) => `${count} unread`,\n allNotifications: \"All notifications\",\n\n unsubscribeTitle: \"E-mail notifications\",\n unsubscribeAbout: (type) => `You get e-mails about: ${type}.`,\n unsubscribeGeneric: \"You get notification e-mails.\",\n stopThese: \"Stop these e-mails\",\n stopAll: (app) => `Stop all notification e-mails from ${app}`,\n stoppedThese: \"Done. You won’t get these e-mails any more.\",\n stoppedAll: (app) => `Done. You won’t get notification e-mails from ${app} any more.`,\n accountMail: \"E-mails about your account still come.\",\n turnBackOn: \"You can turn them back on at any time in Settings.\",\n openSettings: \"Open Settings\",\n invalidLink: \"This link doesn’t work any more. You can turn e-mails off in Settings instead.\",\n\n failed: \"That didn’t work. Please try again.\",\n};\n"],"mappings":";AAwLO,MAAM,+BAAoD;AAAA,EAC/D,YAAY;AAAA,EACZ,kBAAkB;AAAA,EAClB,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,eAAe;AAAA,EACf,cAAc;AAAA,EACd,YAAY;AAAA,EAEZ,YAAY;AAAA,EACZ,kBAAkB,CAAC,OAAO,SACxB,OACI,QACE,uGACA,wEACF,QACE,2EACA;AAAA,EACR,WAAW,CAAC,SAAS,sBAAsB,IAAI;AAAA,EAC/C,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,YAAY,CAAC,SAAS,qBAAqB,IAAI;AAAA,EAC/C,eAAe;AAAA,EACf,SAAS;AAAA,IACP,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,MAAM;AAAA,IACN,UAAU;AAAA,IACV,WAAW;AAAA,IACX,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,SAAS;AAAA,EACX;AAAA,EAEA,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,YAAY,CAAC,QACX,0BAA0B,GAAG;AAAA,EAC/B,eAAe;AAAA,EACf,SAAS;AAAA,EACT,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,cACE;AAAA,EACF,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,UAAU,CAAC,UAAW,UAAU,IAAI,0BAA0B,gBAAgB,KAAK;AAAA,EACnF,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe,CAAC,QACd,GAAG,GAAG;AAAA,EACR,eAAe;AAAA,EACf,mBAAmB;AAAA,EAEnB,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,OAAO,CAAC,SAAS,SAAS,IAAI;AAAA,EAC9B,cAAc,CAAC,SAAS,iBAAiB,IAAI;AAAA,EAC7C,YAAY;AAAA,EACZ,UAAU,CAAC,UACT,UAAU,IAAI,+BAA+B,GAAG,KAAK;AAAA,EACvD,QAAQ;AAAA,EACR,YAAY,CAAC,WAAW,UAAU,MAAM;AAAA,EACxC,cAAc;AAAA,EACd,aAAa;AAAA,EACb,wBAAwB;AAAA,EACxB,kBAAkB;AAAA,EAElB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,UAAU;AAAA,EACV,aAAa;AAAA,EACb,UAAU;AAAA,EACV,WAAW;AAAA,EACX,YAAY,CAAC,UAAU,GAAG,KAAK;AAAA,EAC/B,kBAAkB;AAAA,EAElB,kBAAkB;AAAA,EAClB,kBAAkB,CAAC,SAAS,0BAA0B,IAAI;AAAA,EAC1D,oBAAoB;AAAA,EACpB,WAAW;AAAA,EACX,SAAS,CAAC,QAAQ,sCAAsC,GAAG;AAAA,EAC3D,cAAc;AAAA,EACd,YAAY,CAAC,QAAQ,sDAAiD,GAAG;AAAA,EACzE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,aAAa;AAAA,EAEb,QAAQ;AACV;","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/notifications-labels.ts"],"sourcesContent":["// The words of the notification cards (docs/notifications-harmonization.md §4.2), in a\n// module with no component code: the English catalogue (src/i18n/defaults.ts) imports\n// them without the components, so `en` loads as one chunk like the other languages\n// (0.34, KS U8). The cards read it through `useKitLabels(\"notifications\", …)`.\n\n/** The ten IANA regions `Intl` lists zones under — keksdose's `tz_region_*` — in the\n * kit's words. A region a runtime adds later keeps its IANA name. */\nexport interface NotificationRegionLabels {\n Africa: string;\n America: string;\n Antarctica: string;\n Arctic: string;\n Asia: string;\n Atlantic: string;\n Australia: string;\n Europe: string;\n Indian: string;\n Pacific: string;\n}\n\n/**\n * Every string the notification parts render — the `notifications` namespace of\n * `<UiKitProvider labels>`, overridable per instance through `labels`: the types card,\n * quiet hours, this device's push, the device list, and the\n * unsubscribe page. Cancel is `common.cancel`.\n */\nexport interface NotificationsLabels {\n /* ── The types card ─────────────────────────────────────────────────── */\n /** The types card's title. */\n typesTitle: string;\n /** The line under it. */\n typesDescription: string;\n /** 0.46 (§10.2): the channel columns' headings, and each switch's name. */\n channelApp: string;\n channelPush: string;\n channelEmail: string;\n /** Under every e-mail switch while the account's address is unverified (decision 16):\n * no notification mail goes to an unverified address. */\n emailUnverified: string;\n /** The digest's cadence select. */\n digestCadence: string;\n digestWeekly: string;\n digestMonthly: string;\n /** While the settings load. */\n typesLoading: string;\n /** The server answered no types for this account. */\n typesEmpty: string;\n\n /* ── Quiet hours ────────────────────────────────────────────────────── */\n quietTitle: string;\n /** The line under the title; `inbox`: the app keeps an inbox, which a held push still\n * reaches; `held` (0.46, §10.4): the app queues a held push and sends it when the\n * window ends, instead of dropping it. */\n quietDescription: (inbox: boolean, held?: boolean) => string;\n /** 0.46 (§10.3, automatic only): the card names the account's zone, read-only, given\n * the zone as `zoneName` writes it (\"Europe / Zurich\"); it follows the device last used. */\n quietZone: (zone: string) => string;\n /** The start time's label. */\n quietFrom: string;\n /** The end time's label. */\n quietUntil: string;\n /** One time set, the other not: quiet hours fail open until both are. */\n quietIncomplete: string;\n /** The zone's region headings, in a zone's name (\"Europe / Zurich\"). */\n regions: NotificationRegionLabels;\n\n /* ── This device ────────────────────────────────────────────────────── */\n pushTitle: string;\n pushDescription: string;\n /** The browser has no push. */\n unsupported: string;\n /** iOS outside the installed app, given the app's name. */\n iosInstall: (app: string) => string;\n /** The server has no VAPID keys. */\n notConfigured: string;\n /** The browser's permission is denied. */\n blocked: string;\n /** This device gets notifications for the signed-in account. */\n on: string;\n off: string;\n /** This browser's subscription belongs to another account. */\n otherAccount: string;\n enable: string;\n disable: string;\n sendTest: string;\n /** Turning on did not finish (the permission prompt dismissed, a refusal). */\n enableFailed: string;\n /** No service worker, or one that is not ready in 10 s. */\n noServiceWorker: string;\n /** The browser's push service did not answer in 15 s. */\n pushTimeout: string;\n /** Given how many devices the push service accepted the test for (≥ 1). */\n testSent: (count: number) => string;\n /** The test reached no device. */\n testNone: string;\n testFailed: string;\n /** The explainer before the browser's one permission prompt. */\n explainerTitle: string;\n explainerBody: (app: string) => string;\n explainerHint: string;\n explainerContinue: string;\n\n /* ── The device list ────────────────────────────────────────────────── */\n devicesTitle: string;\n devicesDescription: string;\n devicesLoading: string;\n devicesEmpty: string;\n /** A device whose user agent says nothing the kit recognises. */\n unknownDevice: string;\n /** The chip on this browser's row. */\n thisDevice: string;\n /** Given the registration date, formatted. */\n added: (date: string) => string;\n /** Given the last accepted delivery, formatted. */\n lastDelivery: (date: string) => string;\n noDelivery: string;\n /** Given the failed deliveries in a row (≥ 1). */\n failures: (count: number) => string;\n /** A row's button, visible text — and the confirm's answer. */\n remove: string;\n /** The same button's accessible name, given the device: it starts with `remove`. */\n removeItem: (device: string) => string;\n /** The question the row asks before it removes. */\n removePrompt: string;\n /** 0.46 (§10.2): the disclosure on a device's row listing the types with push on. */\n deviceTypes: string;\n /** The line inside it. */\n deviceTypesDescription: string;\n /** No type has push on for the account. */\n deviceTypesEmpty: string;\n\n /* ── The inbox and the bell (0.46, §10.1) ───────────────────────────── */\n /** The inbox's heading (keksdose's \"Inbox\"). */\n inboxTitle: string;\n inboxLoading: string;\n /** Nothing in the inbox. */\n inboxEmpty: string;\n markAllRead: string;\n /** The header's clear button, and the confirm's answer. */\n clearAll: string;\n /** The question asked before the inbox is cleared. */\n clearPrompt: string;\n /** The row that loads the next page. */\n showMore: string;\n /** The bell's name. */\n bellLabel: string;\n /** Read after the bell's name, given the unread count (≥ 1). */\n bellUnread: (count: number) => string;\n /** The bell panel's link to the app's inbox. */\n allNotifications: string;\n\n /* ── The unsubscribe page ───────────────────────────────────────────── */\n unsubscribeTitle: string;\n /** Given the type's name in the app's words. */\n unsubscribeAbout: (type: string) => string;\n /** No type name to give. */\n unsubscribeGeneric: string;\n stopThese: string;\n stopAll: (app: string) => string;\n stoppedThese: string;\n stoppedAll: (app: string) => string;\n /** Account mail is not a notification, and keeps coming. */\n accountMail: string;\n turnBackOn: string;\n openSettings: string;\n /** A token the server refused, or one that does not parse. */\n invalidLink: string;\n\n /* ── Every card ─────────────────────────────────────────────────────── */\n /** A request failed, and the app had no words of its own. */\n failed: string;\n}\n\nexport const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {\n typesTitle: \"Notify me about\",\n typesDescription: \"Choose what you hear about, and how.\",\n channelApp: \"In the app\",\n channelPush: \"Push\",\n channelEmail: \"E-mail\",\n emailUnverified: \"Confirm your address to receive these.\",\n digestCadence: \"Frequency\",\n digestWeekly: \"Weekly\",\n digestMonthly: \"Monthly\",\n typesLoading: \"Loading notification settings…\",\n typesEmpty: \"There are no notifications for this account.\",\n\n quietTitle: \"Quiet hours\",\n quietDescription: (inbox, held) =>\n held\n ? inbox\n ? \"Push notifications wait during this window and arrive when it ends. They reach your inbox at once.\"\n : \"Push notifications wait during this window and arrive when it ends.\"\n : inbox\n ? \"No push notifications during this window. They still reach your inbox.\"\n : \"No push notifications during this window.\",\n quietZone: (zone) => `In your time zone (${zone}), which follows the device you last used.`,\n quietFrom: \"From\",\n quietUntil: \"Until\",\n quietIncomplete: \"Set both a start and an end time. Quiet hours take effect once both are set.\",\n regions: {\n Africa: \"Africa\",\n America: \"Americas\",\n Antarctica: \"Antarctica\",\n Arctic: \"Arctic\",\n Asia: \"Asia\",\n Atlantic: \"Atlantic\",\n Australia: \"Australia\",\n Europe: \"Europe\",\n Indian: \"Indian Ocean\",\n Pacific: \"Pacific\",\n },\n\n pushTitle: \"Push notifications\",\n pushDescription: \"Notifications on this device, also while the app is closed.\",\n unsupported: \"This browser doesn’t support push notifications.\",\n iosInstall: (app) =>\n `On iPhone or iPad, add ${app} to your Home Screen first: iOS delivers notifications only to the installed app.`,\n notConfigured: \"Push notifications aren’t set up on this server.\",\n blocked: \"Notifications are blocked in this browser’s settings.\",\n on: \"Notifications are on for this device.\",\n off: \"Notifications are off for this device.\",\n otherAccount:\n \"This browser gets notifications for another account. Turn them on here to get them for the account you’re signed in with.\",\n enable: \"Turn on notifications\",\n disable: \"Turn off\",\n sendTest: \"Send a test\",\n enableFailed: \"Couldn’t turn on notifications.\",\n noServiceWorker: \"Couldn’t reach the app’s service worker. Reload the page and try again.\",\n pushTimeout: \"The browser’s push service didn’t answer. Check your connection and try again.\",\n testSent: (count) => (count === 1 ? \"Test sent to 1 device\" : `Test sent to ${count} devices`),\n testNone: \"No device accepted the test. Turn notifications on for this device first.\",\n testFailed: \"Couldn’t send a test notification.\",\n explainerTitle: \"Turn on notifications?\",\n explainerBody: (app) =>\n `${app} sends a notification only for the kinds you choose, and you can turn them off at any time.`,\n explainerHint: \"Your browser asks for permission next.\",\n explainerContinue: \"Continue\",\n\n devicesTitle: \"Notification devices\",\n devicesDescription: \"Every device that gets push notifications for your account. Remove any you no longer use.\",\n devicesLoading: \"Loading devices…\",\n devicesEmpty: \"No device gets notifications yet.\",\n unknownDevice: \"Unknown device\",\n thisDevice: \"This device\",\n added: (date) => `Added ${date}`,\n lastDelivery: (date) => `Last delivery ${date}`,\n noDelivery: \"No delivery yet\",\n failures: (count) =>\n count === 1 ? \"1 failed delivery in a row\" : `${count} failed deliveries in a row`,\n remove: \"Remove\",\n removeItem: (device) => `Remove ${device}`,\n removePrompt: \"Stop sending notifications to this device?\",\n deviceTypes: \"Types on this device\",\n deviceTypesDescription: \"Turn a type off to mute it on this device only.\",\n deviceTypesEmpty: \"No type has push notifications turned on.\",\n\n inboxTitle: \"Inbox\",\n inboxLoading: \"Loading notifications…\",\n inboxEmpty: \"You’re all caught up.\",\n markAllRead: \"Mark all as read\",\n clearAll: \"Clear all\",\n clearPrompt: \"Delete all notifications from your inbox?\",\n showMore: \"Show more\",\n bellLabel: \"Notifications\",\n bellUnread: (count) => `${count} unread`,\n allNotifications: \"All notifications\",\n\n unsubscribeTitle: \"E-mail notifications\",\n unsubscribeAbout: (type) => `You get e-mails about: ${type}.`,\n unsubscribeGeneric: \"You get notification e-mails.\",\n stopThese: \"Stop these e-mails\",\n stopAll: (app) => `Stop all notification e-mails from ${app}`,\n stoppedThese: \"Done. You won’t get these e-mails any more.\",\n stoppedAll: (app) => `Done. You won’t get notification e-mails from ${app} any more.`,\n accountMail: \"E-mails about your account still come.\",\n turnBackOn: \"You can turn them back on at any time in Settings.\",\n openSettings: \"Open Settings\",\n invalidLink: \"This link doesn’t work any more. You can turn e-mails off in Settings instead.\",\n\n failed: \"That didn’t work. Please try again.\",\n};\n"],"mappings":";AA6KO,MAAM,+BAAoD;AAAA,EAC/D,YAAY;AAAA,EACZ,kBAAkB;AAAA,EAClB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,eAAe;AAAA,EACf,cAAc;AAAA,EACd,YAAY;AAAA,EAEZ,YAAY;AAAA,EACZ,kBAAkB,CAAC,OAAO,SACxB,OACI,QACE,uGACA,wEACF,QACE,2EACA;AAAA,EACR,WAAW,CAAC,SAAS,sBAAsB,IAAI;AAAA,EAC/C,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,iBAAiB;AAAA,EACjB,SAAS;AAAA,IACP,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,MAAM;AAAA,IACN,UAAU;AAAA,IACV,WAAW;AAAA,IACX,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,SAAS;AAAA,EACX;AAAA,EAEA,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,YAAY,CAAC,QACX,0BAA0B,GAAG;AAAA,EAC/B,eAAe;AAAA,EACf,SAAS;AAAA,EACT,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,cACE;AAAA,EACF,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,UAAU,CAAC,UAAW,UAAU,IAAI,0BAA0B,gBAAgB,KAAK;AAAA,EACnF,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe,CAAC,QACd,GAAG,GAAG;AAAA,EACR,eAAe;AAAA,EACf,mBAAmB;AAAA,EAEnB,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,OAAO,CAAC,SAAS,SAAS,IAAI;AAAA,EAC9B,cAAc,CAAC,SAAS,iBAAiB,IAAI;AAAA,EAC7C,YAAY;AAAA,EACZ,UAAU,CAAC,UACT,UAAU,IAAI,+BAA+B,GAAG,KAAK;AAAA,EACvD,QAAQ;AAAA,EACR,YAAY,CAAC,WAAW,UAAU,MAAM;AAAA,EACxC,cAAc;AAAA,EACd,aAAa;AAAA,EACb,wBAAwB;AAAA,EACxB,kBAAkB;AAAA,EAElB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,UAAU;AAAA,EACV,aAAa;AAAA,EACb,UAAU;AAAA,EACV,WAAW;AAAA,EACX,YAAY,CAAC,UAAU,GAAG,KAAK;AAAA,EAC/B,kBAAkB;AAAA,EAElB,kBAAkB;AAAA,EAClB,kBAAkB,CAAC,SAAS,0BAA0B,IAAI;AAAA,EAC1D,oBAAoB;AAAA,EACpB,WAAW;AAAA,EACX,SAAS,CAAC,QAAQ,sCAAsC,GAAG;AAAA,EAC3D,cAAc;AAAA,EACd,YAAY,CAAC,QAAQ,sDAAiD,GAAG;AAAA,EACzE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,aAAa;AAAA,EAEb,QAAQ;AACV;","names":[]}
@@ -7,10 +7,6 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
7
7
  start: string | null;
8
8
  /** The `GET`'s `quiet_hours_end`. */
9
9
  end: string | null;
10
- /** The zone the 0.36 card showed, an IANA name, or `null`.
11
- * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
12
- * Shown read-only while `zone` is left out; the card no longer offers a picker. */
13
- timezone?: string | null;
14
10
  /**
15
11
  * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
16
12
  * `device_timezone`, the zone of the device last used; `null` while no device has
@@ -28,7 +24,7 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
28
24
  * rejects.
29
25
  */
30
26
  onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
31
- /** The zone named while neither `zone` nor `timezone` names one: the one the server
27
+ /** The zone named while `zone` names none: the one the server
32
28
  * applies. Default `"Europe/Zurich"`, every app's. */
33
29
  fallbackZone?: string;
34
30
  /** The app keeps an inbox, and the description says a held push still reaches it. */
@@ -51,9 +47,8 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
51
47
  *
52
48
  * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
53
49
  * one is set the card says so: the window fails open until both are.
54
- * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
55
- * `fallbackZone`.
50
+ * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.
56
51
  *
57
52
  * Under a write lock the fields are `commit` controls exempt from billing.
58
53
  */
59
- export declare function QuietHoursSetting({ start, end, timezone, zone, held, onChange, fallbackZone, inbox, disabled, describeError, labels: labelsProp, className, ...rest }: QuietHoursSettingProps): import("react").JSX.Element;
54
+ export declare function QuietHoursSetting({ start, end, zone, held, onChange, fallbackZone, inbox, disabled, describeError, labels: labelsProp, className, ...rest }: QuietHoursSettingProps): import("react").JSX.Element;
@@ -9,12 +9,17 @@ import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes.js";
9
9
  import { SettingsCardTitle } from "../settings/settings-heading.js";
10
10
  import { settle, useMounted } from "../account/account-parts.js";
11
11
  import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts.js";
12
- import { zoneName } from "./time-zone-select.js";
13
12
  const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
13
+ function zoneName(zone, regions) {
14
+ const slash = zone.indexOf("/");
15
+ if (slash < 0) return zone;
16
+ const region = zone.slice(0, slash);
17
+ const named = Object.prototype.hasOwnProperty.call(regions, region) ? regions[region] : region;
18
+ return `${named} / ${zone.slice(slash + 1).replace(/_/g, " ").replace(/\//g, " / ")}`;
19
+ }
14
20
  function QuietHoursSetting({
15
21
  start,
16
22
  end,
17
- timezone = null,
18
23
  zone,
19
24
  held = false,
20
25
  onChange,
@@ -34,7 +39,7 @@ function QuietHoursSetting({
34
39
  const shown = (field, server) => overlay.has(field) ? overlay.get(field) ?? null : server;
35
40
  const startShown = shown("quiet_hours_start", start);
36
41
  const endShown = shown("quiet_hours_end", end);
37
- const accountZone = (zone === void 0 ? timezone : zone) ?? fallbackZone;
42
+ const accountZone = zone ?? fallbackZone;
38
43
  const save = (patch) => {
39
44
  const fields = Object.keys(patch);
40
45
  setFailure(null);
@@ -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 { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The zone the 0.36 card showed, an IANA name, or `null`.\n * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.\n * Shown read-only while `zone` is left out; the card no longer offers a picker. */\n timezone?: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while neither `zone` nor `timezone` names one: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else\n * `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone = null,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":";AAkIM,SACE,KADF;AAlIN,SAAS,OAAO,gBAAgB;AAIhC,SAAS,UAAU;AACnB,SAAS,YAAY;AACrB,SAAS,iBAAiB;AAC1B,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,yBAAyB;AAClC,SAAS,QAAQ,kBAAkB;AACnC,SAAS,iBAAiB,aAAa,wBAAwB,uBAAuB;AAGtF,SAAS,gBAAgB;AAGzB,MAAM,wBAAwB;AA2DvB,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,WAAW;AAAA,EACX;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA2B;AACzB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,UAAU,gBAA+B,GAAG,KAAK,IAAI,GAAG,EAAE;AAChE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAoB,IAAI;AACtD,QAAM,UAAU,WAAW;AAC3B,QAAM,KAAK,MAAM;AAEjB,QAAM,QAAQ,CAAC,OAAmB,WAChC,QAAQ,IAAI,KAAK,IAAK,QAAQ,IAAI,KAAK,KAAK,OAAQ;AACtD,QAAM,aAAa,MAAM,qBAAqB,KAAK;AACnD,QAAM,WAAW,MAAM,mBAAmB,GAAG;AAC7C,QAAM,eAAe,SAAS,SAAY,WAAW,SAAS;AAE9D,QAAM,OAAO,CAAC,UAAqC;AACjD,UAAM,SAAS,OAAO,KAAK,KAAK;AAChC,eAAW,IAAI;AACf,eAAW,SAAS,OAAQ,SAAQ,IAAI,OAAO,MAAM,KAAK,KAAK,IAAI;AACnE,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE;AAAA,MAC5B,MAAM;AACJ,YAAI,QAAQ,QAAS,YAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,IAAI;AAAA,MAC7E;AAAA,MACA,CAAC,UAAmB;AAClB,YAAI,CAAC,QAAQ,QAAS;AACtB,mBAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,KAAK;AACvD,mBAAW,gBAAgB,gBAAgB,KAAK,GAAG,OAAO,MAAM,CAAC;AAAA,MACnE;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,OAAgD,UAAkB;AAClF,UAAM,OAAO,SAAS;AACtB,QAAI,SAAS,MAAM,OAAO,UAAU,sBAAsB,QAAQ,GAAG,EAAG;AACxE,SAAK,EAAE,CAAC,KAAK,GAAG,KAAK,CAAC;AAAA,EACxB;AAEA,QAAM,SAAS,QAAQ,cAAc,QAAQ;AAC7C,QAAM,UAAU,QAAQ,UAAU,MAAM,QAAQ,QAAQ;AAExD,SACE,qBAAC,QAAM,GAAG,MAAM,WAAW,GAAG,iBAAiB,SAAS,GACtD;AAAA,yBAAC,SACC;AAAA,0BAAC,qBAAmB,iBAAO,YAAW;AAAA,MACtC,oBAAC,SAAI,WAAW,wBAAyB,iBAAO,iBAAiB,OAAO,IAAI,GAAE;AAAA,OAChF;AAAA,IACA,qBAAC,SAAI,WAAU,kCACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,cAAc;AAAA,UACrB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,qBAAqB,KAAK;AAAA,UAC7D,WAAU;AAAA;AAAA,MACZ;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,YAAY;AAAA,UACnB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,mBAAmB,KAAK;AAAA,UAC3D,WAAU;AAAA;AAAA,MACZ;AAAA,OACF;AAAA,IACC,WACC,oBAAC,OAAE,WAAU,wBAAuB,yBAAsB,IACvD,iBAAO,iBACV;AAAA,IAED,UACC,oBAAC,OAAE,WAAW,wBAAwB,mBAAgB,IACnD,iBAAO,UAAU,SAAS,aAAa,OAAO,OAAO,CAAC,GACzD;AAAA,IAEF,oBAAC,eAAa,mBAAQ;AAAA,KACxB;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { 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.slice(slash + 1).replace(/_/g, \" \").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":[]}
@@ -8,6 +8,15 @@
8
8
  * enough although the server keeps microseconds: `Date.parse` truncates them.
9
9
  */
10
10
  export declare function stampAfter(previous: unknown): string;
11
+ /**
12
+ * A name-based UUID, version 5 (RFC 4122 §4.3: SHA-1 of the namespace's 16 bytes and the
13
+ * name's UTF-8), the same as Python's `uuid.uuid5(uuid.UUID(namespace), name)` — for an id
14
+ * the device and the server derive alike (§2: the per-owner vote id
15
+ * `uuid5(namespace, "<item>:<invitation>")`). Lower case, hyphenated. `namespace` is a UUID,
16
+ * hyphenated or not, in either case; anything else is a `TypeError`. Uses `crypto.subtle`
17
+ * (a secure context: https or localhost).
18
+ */
19
+ export declare function uuid5(namespace: string, name: string): Promise<string>;
11
20
  /**
12
21
  * The connection, not the server, failed: the request had no answer (an axios-like error
13
22
  * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as
@@ -5,6 +5,19 @@ function stampAfter(previous) {
5
5
  const prior = typeof previous === "string" ? Date.parse(previous) : Number.NaN;
6
6
  return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
7
7
  }
8
+ async function uuid5(namespace, name) {
9
+ const hex = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(namespace) ? namespace.replaceAll("-", "") : null;
10
+ if (!hex) throw new TypeError(`uuid5: the namespace must be a UUID, not ${JSON.stringify(namespace)}`);
11
+ const nameBytes = new TextEncoder().encode(name);
12
+ const input = new Uint8Array(16 + nameBytes.length);
13
+ for (let i = 0; i < 16; i += 1) input[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
14
+ input.set(nameBytes, 16);
15
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", input)).slice(0, 16);
16
+ bytes[6] = bytes[6] & 15 | 80;
17
+ bytes[8] = bytes[8] & 63 | 128;
18
+ const out = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
19
+ return `${out.slice(0, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}-${out.slice(16, 20)}-${out.slice(20)}`;
20
+ }
8
21
  function isOfflineError(err) {
9
22
  if (isTransientRefreshFailure(err)) return true;
10
23
  if (typeof err !== "object" || err === null) return false;
@@ -34,6 +47,7 @@ export {
34
47
  isOfflineError,
35
48
  readLocalFirst,
36
49
  stampAfter,
50
+ uuid5,
37
51
  writeLocalFirst
38
52
  };
39
53
  //# sourceMappingURL=local-first.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/offline/local-first.ts"],"sourcesContent":["import { isTransientRefreshFailure } from \"../lib/session-refresh\";\n\n/*\n * The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a\n * page and a sync engine both need, with no store behind them. One module that both\n * `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads\n * `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports\n * no package at all (packaging-contract.test.ts holds that).\n */\n\n/**\n * An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past\n * `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).\n *\n * `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock\n * with the server's. A row the server just wrote carries the server's time, so an edit\n * stamped by a clock a second slow would lose and come straight back. The millisecond is\n * enough although the server keeps microseconds: `Date.parse` truncates them.\n */\nexport function stampAfter(previous: unknown): string {\n const now = Date.now();\n const prior = typeof previous === \"string\" ? Date.parse(previous) : Number.NaN;\n return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();\n}\n\n/**\n * The connection, not the server, failed: the request had no answer (an axios-like error\n * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as\n * passing because the server was out of reach ({@link isTransientRefreshFailure}). A real\n * HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.\n */\nexport function isOfflineError(err: unknown): boolean {\n if (isTransientRefreshFailure(err)) return true;\n if (typeof err !== \"object\" || err === null) return false;\n const bag = err as { isAxiosError?: unknown; response?: unknown };\n if (bag.isAxiosError === true || \"response\" in bag) return bag.response == null;\n return err instanceof TypeError;\n}\n\n/**\n * Read from the server, and from the device's copy only when the connection failed\n * ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer\n * before it is returned (to refresh the copy, say); a throw from it counts as a failure of\n * the online path. The plain form of keksdose's net.ts:58, without its sealed-household\n * and cold-start branches (those stay keksdose's).\n */\nexport async function readLocalFirst<T>(\n online: () => Promise<T>,\n offline: () => Promise<T>,\n onOnline?: (result: T) => void | Promise<void>,\n): Promise<T> {\n try {\n const result = await online();\n await onOnline?.(result);\n return result;\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return offline();\n }\n}\n\n/**\n * Write through the server first (`rest`), and queue the change on the device (`queue`)\n * only when the connection failed; any other failure throws, nothing queued. Online writes\n * stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both\n * paths, so a REST create that timed out after the server applied it is not created twice\n * (D16).\n */\nexport async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {\n try {\n return await rest();\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return queue();\n }\n}\n"],"mappings":";AAAA,SAAS,iCAAiC;AAmBnC,SAAS,WAAW,UAA2B;AACpD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,QAAQ,OAAO,aAAa,WAAW,KAAK,MAAM,QAAQ,IAAI,OAAO;AAC3E,SAAO,IAAI,KAAK,OAAO,SAAS,KAAK,KAAK,SAAS,MAAM,QAAQ,IAAI,GAAG,EAAE,YAAY;AACxF;AAQO,SAAS,eAAe,KAAuB;AACpD,MAAI,0BAA0B,GAAG,EAAG,QAAO;AAC3C,MAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,QAAO;AACpD,QAAM,MAAM;AACZ,MAAI,IAAI,iBAAiB,QAAQ,cAAc,IAAK,QAAO,IAAI,YAAY;AAC3E,SAAO,eAAe;AACxB;AASA,eAAsB,eACpB,QACA,SACA,UACY;AACZ,MAAI;AACF,UAAM,SAAS,MAAM,OAAO;AAC5B,UAAM,WAAW,MAAM;AACvB,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,QAAQ;AAAA,EACjB;AACF;AASA,eAAsB,gBAAmB,MAAwB,OAAqC;AACpG,MAAI;AACF,WAAO,MAAM,KAAK;AAAA,EACpB,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,MAAM;AAAA,EACf;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/offline/local-first.ts"],"sourcesContent":["import { isTransientRefreshFailure } from \"../lib/session-refresh\";\n\n/*\n * The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a\n * page and a sync engine both need, with no store behind them. One module that both\n * `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads\n * `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports\n * no package at all (packaging-contract.test.ts holds that).\n */\n\n/**\n * An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past\n * `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).\n *\n * `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock\n * with the server's. A row the server just wrote carries the server's time, so an edit\n * stamped by a clock a second slow would lose and come straight back. The millisecond is\n * enough although the server keeps microseconds: `Date.parse` truncates them.\n */\nexport function stampAfter(previous: unknown): string {\n const now = Date.now();\n const prior = typeof previous === \"string\" ? Date.parse(previous) : Number.NaN;\n return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();\n}\n\n/**\n * A name-based UUID, version 5 (RFC 4122 §4.3: SHA-1 of the namespace's 16 bytes and the\n * name's UTF-8), the same as Python's `uuid.uuid5(uuid.UUID(namespace), name)` — for an id\n * the device and the server derive alike (§2: the per-owner vote id\n * `uuid5(namespace, \"<item>:<invitation>\")`). Lower case, hyphenated. `namespace` is a UUID,\n * hyphenated or not, in either case; anything else is a `TypeError`. Uses `crypto.subtle`\n * (a secure context: https or localhost).\n */\nexport async function uuid5(namespace: string, name: string): Promise<string> {\n const hex = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(namespace)\n ? namespace.replaceAll(\"-\", \"\")\n : null;\n if (!hex) throw new TypeError(`uuid5: the namespace must be a UUID, not ${JSON.stringify(namespace)}`);\n const nameBytes = new TextEncoder().encode(name);\n const input = new Uint8Array(16 + nameBytes.length);\n for (let i = 0; i < 16; i += 1) input[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n input.set(nameBytes, 16);\n const bytes = new Uint8Array(await crypto.subtle.digest(\"SHA-1\", input)).slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // the RFC 4122 variant\n const out = Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n return `${out.slice(0, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}-${out.slice(16, 20)}-${out.slice(20)}`;\n}\n\n/**\n * The connection, not the server, failed: the request had no answer (an axios-like error\n * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as\n * passing because the server was out of reach ({@link isTransientRefreshFailure}). A real\n * HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.\n */\nexport function isOfflineError(err: unknown): boolean {\n if (isTransientRefreshFailure(err)) return true;\n if (typeof err !== \"object\" || err === null) return false;\n const bag = err as { isAxiosError?: unknown; response?: unknown };\n if (bag.isAxiosError === true || \"response\" in bag) return bag.response == null;\n return err instanceof TypeError;\n}\n\n/**\n * Read from the server, and from the device's copy only when the connection failed\n * ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer\n * before it is returned (to refresh the copy, say); a throw from it counts as a failure of\n * the online path. The plain form of keksdose's net.ts:58, without its sealed-household\n * and cold-start branches (those stay keksdose's).\n */\nexport async function readLocalFirst<T>(\n online: () => Promise<T>,\n offline: () => Promise<T>,\n onOnline?: (result: T) => void | Promise<void>,\n): Promise<T> {\n try {\n const result = await online();\n await onOnline?.(result);\n return result;\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return offline();\n }\n}\n\n/**\n * Write through the server first (`rest`), and queue the change on the device (`queue`)\n * only when the connection failed; any other failure throws, nothing queued. Online writes\n * stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both\n * paths, so a REST create that timed out after the server applied it is not created twice\n * (D16).\n */\nexport async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {\n try {\n return await rest();\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return queue();\n }\n}\n"],"mappings":";AAAA,SAAS,iCAAiC;AAmBnC,SAAS,WAAW,UAA2B;AACpD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,QAAQ,OAAO,aAAa,WAAW,KAAK,MAAM,QAAQ,IAAI,OAAO;AAC3E,SAAO,IAAI,KAAK,OAAO,SAAS,KAAK,KAAK,SAAS,MAAM,QAAQ,IAAI,GAAG,EAAE,YAAY;AACxF;AAUA,eAAsB,MAAM,WAAmB,MAA+B;AAC5E,QAAM,MAAM,sEAAsE,KAAK,SAAS,IAC5F,UAAU,WAAW,KAAK,EAAE,IAC5B;AACJ,MAAI,CAAC,IAAK,OAAM,IAAI,UAAU,4CAA4C,KAAK,UAAU,SAAS,CAAC,EAAE;AACrG,QAAM,YAAY,IAAI,YAAY,EAAE,OAAO,IAAI;AAC/C,QAAM,QAAQ,IAAI,WAAW,KAAK,UAAU,MAAM;AAClD,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK,EAAG,OAAM,CAAC,IAAI,OAAO,SAAS,IAAI,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;AAC1F,QAAM,IAAI,WAAW,EAAE;AACvB,QAAM,QAAQ,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,SAAS,KAAK,CAAC,EAAE,MAAM,GAAG,EAAE;AACpF,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,MAAM,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC7E,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,EAAE,CAAC;AAC1G;AAQO,SAAS,eAAe,KAAuB;AACpD,MAAI,0BAA0B,GAAG,EAAG,QAAO;AAC3C,MAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,QAAO;AACpD,QAAM,MAAM;AACZ,MAAI,IAAI,iBAAiB,QAAQ,cAAc,IAAK,QAAO,IAAI,YAAY;AAC3E,SAAO,eAAe;AACxB;AASA,eAAsB,eACpB,QACA,SACA,UACY;AACZ,MAAI;AACF,UAAM,SAAS,MAAM,OAAO;AAC5B,UAAM,WAAW,MAAM;AACvB,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,QAAQ;AAAA,EACjB;AACF;AASA,eAAsB,gBAAmB,MAAwB,OAAqC;AACpG,MAAI;AACF,WAAO,MAAM,KAAK;AAAA,EACpB,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,MAAM;AAAA,EACf;AACF;","names":[]}
package/dist/offline.d.ts CHANGED
@@ -8,4 +8,4 @@ export { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/se
8
8
  export type { RegisterServiceWorker, ServiceWorkerUpdates } from "./offline/service-worker-updates.js";
9
9
  export { SharedComputerChoice, DEFAULT_SHARED_COMPUTER_LABELS, isSharedComputer, setSharedComputer, } from "./offline/shared-computer.js";
10
10
  export type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from "./offline/shared-computer.js";
11
- export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
11
+ export { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from "./offline/local-first.js";
package/dist/offline.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  isSharedComputer,
10
10
  setSharedComputer
11
11
  } from "./offline/shared-computer.js";
12
- import { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
12
+ import { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from "./offline/local-first.js";
13
13
  export {
14
14
  DEFAULT_PWA_UPDATE_PROMPT_LABELS,
15
15
  DEFAULT_SCRUB_FIELDS,
@@ -28,6 +28,7 @@ export {
28
28
  setSharedComputer,
29
29
  stampAfter,
30
30
  useServiceWorkerUpdate,
31
+ uuid5,
31
32
  writeLocalFirst
32
33
  };
33
34
  //# sourceMappingURL=offline.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from \"./offline/query-persistence\";\nexport type {\n QueryPersistence,\n QueryPersistenceOptions,\n QueryPersistenceStorage,\n RemoveStaleQueryCopiesOptions,\n ScrubField,\n} from \"./offline/query-persistence\";\nexport { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from \"./offline/sign-out-pending-dialog\";\nexport type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from \"./offline/sign-out-pending-dialog\";\nexport { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./offline/pwa-update-prompt\";\nexport type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from \"./offline/pwa-update-prompt\";\n// 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.\nexport { createServiceWorkerUpdates, useServiceWorkerUpdate } from \"./offline/service-worker-updates\";\nexport type { RegisterServiceWorker, ServiceWorkerUpdates } from \"./offline/service-worker-updates\";\nexport {\n SharedComputerChoice,\n DEFAULT_SHARED_COMPUTER_LABELS,\n isSharedComputer,\n setSharedComputer,\n} from \"./offline/shared-computer\";\nexport type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from \"./offline/shared-computer\";\n// 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.\nexport { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAQA,SAAS,wBAAwB,sBAAsB,aAAa,8BAA8B;AAQlG,SAAS,sBAAsB,8CAA8C;AAE7E,SAAS,iBAAiB,wCAAwC;AAGlE,SAAS,4BAA4B,8BAA8B;AAEnE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gBAAgB,gBAAgB,YAAY,uBAAuB;","names":[]}
1
+ {"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from \"./offline/query-persistence\";\nexport type {\n QueryPersistence,\n QueryPersistenceOptions,\n QueryPersistenceStorage,\n RemoveStaleQueryCopiesOptions,\n ScrubField,\n} from \"./offline/query-persistence\";\nexport { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from \"./offline/sign-out-pending-dialog\";\nexport type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from \"./offline/sign-out-pending-dialog\";\nexport { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./offline/pwa-update-prompt\";\nexport type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from \"./offline/pwa-update-prompt\";\n// 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.\nexport { createServiceWorkerUpdates, useServiceWorkerUpdate } from \"./offline/service-worker-updates\";\nexport type { RegisterServiceWorker, ServiceWorkerUpdates } from \"./offline/service-worker-updates\";\nexport {\n SharedComputerChoice,\n DEFAULT_SHARED_COMPUTER_LABELS,\n isSharedComputer,\n setSharedComputer,\n} from \"./offline/shared-computer\";\nexport type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from \"./offline/shared-computer\";\n// 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.\nexport { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAQA,SAAS,wBAAwB,sBAAsB,aAAa,8BAA8B;AAQlG,SAAS,sBAAsB,8CAA8C;AAE7E,SAAS,iBAAiB,wCAAwC;AAGlE,SAAS,4BAA4B,8BAA8B;AAEnE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gBAAgB,gBAAgB,YAAY,OAAO,uBAAuB;","names":[]}
package/dist/sync/db.d.ts CHANGED
@@ -63,6 +63,23 @@ export declare const SYNC_DB_STORES: {
63
63
  readonly outbox: "++seq, [entity+id], status, scope";
64
64
  readonly meta: "key";
65
65
  };
66
+ /**
67
+ * The `rows` schema with an `[entity+pk]` index (0.46.1), opt-in: the D16 pages start from a
68
+ * server id (`/meters/7`) and {@link rowByPk} finds its copy row by this index. Not in
69
+ * {@link SYNC_DB_STORES}, so no database on a device changes until its app names it in a
70
+ * version of its own — kastlan, whose devices are on v2: `upgrades: [..., { version: 3,
71
+ * stores: SYNC_DB_PK_INDEX }]`. A database new to the devices may take it at once:
72
+ * `extraStores: { ...SYNC_DB_PK_INDEX }`.
73
+ */
74
+ export declare const SYNC_DB_PK_INDEX: {
75
+ readonly rows: "[entity+id], entity, [entity+pk]";
76
+ };
77
+ /**
78
+ * The copy row of the server's id `pk` in `entity` (0.46.1): by the `[entity+pk]` index when
79
+ * the database has it ({@link SYNC_DB_PK_INDEX}), else by a scan of the entity's rows.
80
+ * `undefined` when the copy holds no such row.
81
+ */
82
+ export declare function rowByPk(db: SyncDb, entity: string, pk: number): Promise<SyncedRow | undefined>;
66
83
  /**
67
84
  * Open (lazily, on first use) the sync database `name`: the engine's tables plus the app's
68
85
  * `extraStores`, at version 1 or the last of `upgrades`. One database per account (kastlan:
package/dist/sync/db.js CHANGED
@@ -5,6 +5,13 @@ const SYNC_DB_STORES = {
5
5
  outbox: "++seq, [entity+id], status, scope",
6
6
  meta: "key"
7
7
  };
8
+ const SYNC_DB_PK_INDEX = {
9
+ rows: "[entity+id], entity, [entity+pk]"
10
+ };
11
+ async function rowByPk(db, entity, pk) {
12
+ if (db.rows.schema.idxByName["[entity+pk]"]) return db.rows.where("[entity+pk]").equals([entity, pk]).first();
13
+ return db.rows.where("entity").equals(entity).filter((row) => row.pk === pk).first();
14
+ }
8
15
  function createSyncDb(name, options = {}) {
9
16
  const db = new Dexie(name);
10
17
  let stores = { ...SYNC_DB_STORES, ...options.extraStores };
@@ -18,7 +25,9 @@ function createSyncDb(name, options = {}) {
18
25
  return db;
19
26
  }
20
27
  export {
28
+ SYNC_DB_PK_INDEX,
21
29
  SYNC_DB_STORES,
22
- createSyncDb
30
+ createSyncDb,
31
+ rowByPk
23
32
  };
24
33
  //# sourceMappingURL=db.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sync/db.ts"],"sourcesContent":["import Dexie, { type Table, type Transaction } from \"dexie\";\n\n/*\n * The device's copy for the sync engine (docs/local-first-harmonization.md §2.3, D10): ONE\n * Dexie database with the engine's three tables — the rows as `/sync` carries them, the\n * outbox of changes made here, and a key/value `meta` — plus the app's own (kastlan's\n * `photos`). A generic store, not keksdose's per-entity tables.\n */\n\n/** A row as `/sync` carries it: the entity (a table name), its sync id, the server's id\n * once it has one, and its fields. */\nexport interface SyncedRow {\n entity: string;\n id: string;\n pk: number | null;\n data: Record<string, unknown>;\n /** The server's stamp of the row, as the pull carried it BESIDE `data` (kastlan's wire);\n * the next edit here is stamped after it (`stampAfter`, D2). */\n updated_at?: string | null;\n}\n\n/**\n * Where a change made here stands. `pending`: queued. `sending`: in the request now out (a\n * new edit starts a change of its own). `waiting`: refused with a hold code (a lapsed plan,\n * `holdCodes`), sent again every round. `blocked`: refused with any other code, kept, sent\n * again only on `retryBlocked()`. `rejected`: the server's rules refused it (`reason` says\n * why); never sent again.\n */\nexport type OutboxStatus = \"pending\" | \"sending\" | \"waiting\" | \"blocked\" | \"rejected\";\n\n/** A change made on this device, for `/sync`, in the scope its caller named (D11). */\nexport interface OutboxChange {\n seq?: number;\n scope: string;\n entity: string;\n id: string;\n op: \"upsert\" | \"delete\";\n data: Record<string, unknown>;\n /** The edit's time on this device (D2), stamped with `stampAfter`. */\n updated_at: string;\n status: OutboxStatus;\n /** The server's words: a rejection's `detail`, a refusal's `detail`. */\n reason?: string;\n /** The server's code: a rejection's or a refusal's `code`. */\n code?: string;\n}\n\nexport interface SyncMetaEntry {\n key: string;\n value: unknown;\n}\n\n/** The engine's database: its three tables, and the app's own under the names it gave. */\nexport type SyncDb = Dexie & {\n rows: Table<SyncedRow, [string, string]>;\n outbox: Table<OutboxChange, number>;\n meta: Table<SyncMetaEntry, string>;\n};\n\n/** A schema step of an existing database (kastlan's v2: `scope` on the outbox). */\nexport interface SyncDbUpgrade {\n version: number;\n /** Stores this version adds or changes, beside the engine's and `extraStores`. */\n stores?: Record<string, string | null>;\n upgrade?(tx: Transaction): Promise<void> | void;\n}\n\nexport interface CreateSyncDbOptions {\n /** The app's own tables, Dexie schema strings: kastlan `{ photos: \"id, roomId, status\" }`. */\n extraStores?: Record<string, string>;\n /** Later versions of a database that exists on devices already, oldest first. */\n upgrades?: readonly SyncDbUpgrade[];\n}\n\n/** The engine's own tables. `scope` is indexed: a round takes one scope's queue (D11). */\nexport const SYNC_DB_STORES = {\n rows: \"[entity+id], entity\",\n outbox: \"++seq, [entity+id], status, scope\",\n meta: \"key\",\n} as const;\n\n/**\n * Open (lazily, on first use) the sync database `name`: the engine's tables plus the app's\n * `extraStores`, at version 1 or the last of `upgrades`. One database per account (kastlan:\n * per account and company, `kastlan-handover-<user>-<company>`), and one engine on it (D15).\n *\n * An app whose database predates the engine names its next version in `upgrades` and moves\n * its data there — kastlan: `{ version: 2, upgrade: (tx) => tx.table(\"outbox\").toCollection()\n * .modify({ scope: \"handover\" }) }`. Dexie diffs the schema against the one on the device.\n * Name the app's own type for its tables: `createSyncDb<HandoverDb>(name, { extraStores })`.\n */\nexport function createSyncDb<D extends SyncDb = SyncDb>(name: string, options: CreateSyncDbOptions = {}): D {\n const db = new Dexie(name) as D;\n let stores: Record<string, string | null> = { ...SYNC_DB_STORES, ...options.extraStores };\n db.version(1).stores(stores);\n const upgrades = [...(options.upgrades ?? [])].sort((a, b) => a.version - b.version);\n for (const step of upgrades) {\n stores = { ...stores, ...step.stores };\n const version = db.version(step.version).stores(stores);\n if (step.upgrade) version.upgrade(step.upgrade);\n }\n return db;\n}\n"],"mappings":";AAAA,OAAO,WAA6C;AA2E7C,MAAM,iBAAiB;AAAA,EAC5B,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,MAAM;AACR;AAYO,SAAS,aAAwC,MAAc,UAA+B,CAAC,GAAM;AAC1G,QAAM,KAAK,IAAI,MAAM,IAAI;AACzB,MAAI,SAAwC,EAAE,GAAG,gBAAgB,GAAG,QAAQ,YAAY;AACxF,KAAG,QAAQ,CAAC,EAAE,OAAO,MAAM;AAC3B,QAAM,WAAW,CAAC,GAAI,QAAQ,YAAY,CAAC,CAAE,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;AACnF,aAAW,QAAQ,UAAU;AAC3B,aAAS,EAAE,GAAG,QAAQ,GAAG,KAAK,OAAO;AACrC,UAAM,UAAU,GAAG,QAAQ,KAAK,OAAO,EAAE,OAAO,MAAM;AACtD,QAAI,KAAK,QAAS,SAAQ,QAAQ,KAAK,OAAO;AAAA,EAChD;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/sync/db.ts"],"sourcesContent":["import Dexie, { type Table, type Transaction } from \"dexie\";\n\n/*\n * The device's copy for the sync engine (docs/local-first-harmonization.md §2.3, D10): ONE\n * Dexie database with the engine's three tables — the rows as `/sync` carries them, the\n * outbox of changes made here, and a key/value `meta` — plus the app's own (kastlan's\n * `photos`). A generic store, not keksdose's per-entity tables.\n */\n\n/** A row as `/sync` carries it: the entity (a table name), its sync id, the server's id\n * once it has one, and its fields. */\nexport interface SyncedRow {\n entity: string;\n id: string;\n pk: number | null;\n data: Record<string, unknown>;\n /** The server's stamp of the row, as the pull carried it BESIDE `data` (kastlan's wire);\n * the next edit here is stamped after it (`stampAfter`, D2). */\n updated_at?: string | null;\n}\n\n/**\n * Where a change made here stands. `pending`: queued. `sending`: in the request now out (a\n * new edit starts a change of its own). `waiting`: refused with a hold code (a lapsed plan,\n * `holdCodes`), sent again every round. `blocked`: refused with any other code, kept, sent\n * again only on `retryBlocked()`. `rejected`: the server's rules refused it (`reason` says\n * why); never sent again.\n */\nexport type OutboxStatus = \"pending\" | \"sending\" | \"waiting\" | \"blocked\" | \"rejected\";\n\n/** A change made on this device, for `/sync`, in the scope its caller named (D11). */\nexport interface OutboxChange {\n seq?: number;\n scope: string;\n entity: string;\n id: string;\n op: \"upsert\" | \"delete\";\n data: Record<string, unknown>;\n /** The edit's time on this device (D2), stamped with `stampAfter`. */\n updated_at: string;\n status: OutboxStatus;\n /** The server's words: a rejection's `detail`, a refusal's `detail`. */\n reason?: string;\n /** The server's code: a rejection's or a refusal's `code`. */\n code?: string;\n}\n\nexport interface SyncMetaEntry {\n key: string;\n value: unknown;\n}\n\n/** The engine's database: its three tables, and the app's own under the names it gave. */\nexport type SyncDb = Dexie & {\n rows: Table<SyncedRow, [string, string]>;\n outbox: Table<OutboxChange, number>;\n meta: Table<SyncMetaEntry, string>;\n};\n\n/** A schema step of an existing database (kastlan's v2: `scope` on the outbox). */\nexport interface SyncDbUpgrade {\n version: number;\n /** Stores this version adds or changes, beside the engine's and `extraStores`. */\n stores?: Record<string, string | null>;\n upgrade?(tx: Transaction): Promise<void> | void;\n}\n\nexport interface CreateSyncDbOptions {\n /** The app's own tables, Dexie schema strings: kastlan `{ photos: \"id, roomId, status\" }`. */\n extraStores?: Record<string, string>;\n /** Later versions of a database that exists on devices already, oldest first. */\n upgrades?: readonly SyncDbUpgrade[];\n}\n\n/** The engine's own tables. `scope` is indexed: a round takes one scope's queue (D11). */\nexport const SYNC_DB_STORES = {\n rows: \"[entity+id], entity\",\n outbox: \"++seq, [entity+id], status, scope\",\n meta: \"key\",\n} as const;\n\n/**\n * The `rows` schema with an `[entity+pk]` index (0.46.1), opt-in: the D16 pages start from a\n * server id (`/meters/7`) and {@link rowByPk} finds its copy row by this index. Not in\n * {@link SYNC_DB_STORES}, so no database on a device changes until its app names it in a\n * version of its own — kastlan, whose devices are on v2: `upgrades: [..., { version: 3,\n * stores: SYNC_DB_PK_INDEX }]`. A database new to the devices may take it at once:\n * `extraStores: { ...SYNC_DB_PK_INDEX }`.\n */\nexport const SYNC_DB_PK_INDEX = {\n rows: \"[entity+id], entity, [entity+pk]\",\n} as const;\n\n/**\n * The copy row of the server's id `pk` in `entity` (0.46.1): by the `[entity+pk]` index when\n * the database has it ({@link SYNC_DB_PK_INDEX}), else by a scan of the entity's rows.\n * `undefined` when the copy holds no such row.\n */\nexport async function rowByPk(db: SyncDb, entity: string, pk: number): Promise<SyncedRow | undefined> {\n if (db.rows.schema.idxByName[\"[entity+pk]\"]) return db.rows.where(\"[entity+pk]\").equals([entity, pk]).first();\n return db.rows.where(\"entity\").equals(entity).filter((row) => row.pk === pk).first();\n}\n\n/**\n * Open (lazily, on first use) the sync database `name`: the engine's tables plus the app's\n * `extraStores`, at version 1 or the last of `upgrades`. One database per account (kastlan:\n * per account and company, `kastlan-handover-<user>-<company>`), and one engine on it (D15).\n *\n * An app whose database predates the engine names its next version in `upgrades` and moves\n * its data there — kastlan: `{ version: 2, upgrade: (tx) => tx.table(\"outbox\").toCollection()\n * .modify({ scope: \"handover\" }) }`. Dexie diffs the schema against the one on the device.\n * Name the app's own type for its tables: `createSyncDb<HandoverDb>(name, { extraStores })`.\n */\nexport function createSyncDb<D extends SyncDb = SyncDb>(name: string, options: CreateSyncDbOptions = {}): D {\n const db = new Dexie(name) as D;\n let stores: Record<string, string | null> = { ...SYNC_DB_STORES, ...options.extraStores };\n db.version(1).stores(stores);\n const upgrades = [...(options.upgrades ?? [])].sort((a, b) => a.version - b.version);\n for (const step of upgrades) {\n stores = { ...stores, ...step.stores };\n const version = db.version(step.version).stores(stores);\n if (step.upgrade) version.upgrade(step.upgrade);\n }\n return db;\n}\n"],"mappings":";AAAA,OAAO,WAA6C;AA2E7C,MAAM,iBAAiB;AAAA,EAC5B,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,MAAM;AACR;AAUO,MAAM,mBAAmB;AAAA,EAC9B,MAAM;AACR;AAOA,eAAsB,QAAQ,IAAY,QAAgB,IAA4C;AACpG,MAAI,GAAG,KAAK,OAAO,UAAU,aAAa,EAAG,QAAO,GAAG,KAAK,MAAM,aAAa,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,EAAE,MAAM;AAC5G,SAAO,GAAG,KAAK,MAAM,QAAQ,EAAE,OAAO,MAAM,EAAE,OAAO,CAAC,QAAQ,IAAI,OAAO,EAAE,EAAE,MAAM;AACrF;AAYO,SAAS,aAAwC,MAAc,UAA+B,CAAC,GAAM;AAC1G,QAAM,KAAK,IAAI,MAAM,IAAI;AACzB,MAAI,SAAwC,EAAE,GAAG,gBAAgB,GAAG,QAAQ,YAAY;AACxF,KAAG,QAAQ,CAAC,EAAE,OAAO,MAAM;AAC3B,QAAM,WAAW,CAAC,GAAI,QAAQ,YAAY,CAAC,CAAE,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;AACnF,aAAW,QAAQ,UAAU;AAC3B,aAAS,EAAE,GAAG,QAAQ,GAAG,KAAK,OAAO;AACrC,UAAM,UAAU,GAAG,QAAQ,KAAK,OAAO,EAAE,OAAO,MAAM;AACtD,QAAI,KAAK,QAAS,SAAQ,QAAQ,KAAK,OAAO;AAAA,EAChD;AACA,SAAO;AACT;","names":[]}
@@ -19,12 +19,13 @@ export interface SyncStepResult {
19
19
  /** What the step keeps on the device, added to the engine's counts. */
20
20
  counts?: Partial<SyncCounts>;
21
21
  }
22
- /** Work after the push/pull, such as kastlan's photo upload. */
23
- export interface SyncStep {
22
+ /** Work after the push/pull, such as kastlan's photo upload. `D` is the app's database type
23
+ * (`createSyncDb<HandoverDb>`), so a step reaches the app's own tables without a cast. */
24
+ export interface SyncStep<D extends SyncDb = SyncDb> {
24
25
  name: string;
25
- run(db: SyncDb, ctx: SyncStepContext): Promise<SyncStepResult>;
26
+ run(db: D, ctx: SyncStepContext): Promise<SyncStepResult>;
26
27
  /** Its counts read from the device between rounds (a photo taken offline shows at once). */
27
- counts?(db: SyncDb): Promise<Partial<SyncCounts>>;
28
+ counts?(db: D): Promise<Partial<SyncCounts>>;
28
29
  }
29
30
  export interface SyncEngineState {
30
31
  phase: "idle" | "syncing" | "offline" | "error";
@@ -44,8 +45,9 @@ export interface SyncRoundResult {
44
45
  rejected: number;
45
46
  error?: unknown;
46
47
  }
47
- export interface SyncEngineOptions {
48
- db: SyncDb;
48
+ export interface SyncEngineOptions<D extends SyncDb = SyncDb> {
49
+ /** The database; its type `D` is the one the `steps` get. */
50
+ db: D;
49
51
  /** The scopes this session's role holds, in round order (kastlan: handover, meetings, meters,
50
52
  * maintenance, contacts). Read at the start of every round: roles are per company. */
51
53
  scopes: () => readonly string[];
@@ -61,7 +63,7 @@ export interface SyncEngineOptions {
61
63
  holdCodes?: readonly string[];
62
64
  /** Ask for a paged pull and follow its cursor (D12). */
63
65
  paged?: boolean;
64
- steps?: readonly SyncStep[];
66
+ steps?: readonly SyncStep<D>[];
65
67
  /** No round while this holds (kastlan: a practice tour fakes the server's answers). */
66
68
  paused?: () => boolean;
67
69
  /** The poll (30 s) and the debounce after a write (800 ms). */
@@ -106,7 +108,7 @@ export declare const DEFAULT_HOLD_CODES: readonly string[];
106
108
  * engine on the new database and stops the old one). Nothing runs until `start()`
107
109
  * (`useSyncWorker`), and `start()` does nothing on a computer marked shared.
108
110
  */
109
- export declare function createSyncEngine(options: SyncEngineOptions): SyncEngine;
111
+ export declare function createSyncEngine<D extends SyncDb = SyncDb>(options: SyncEngineOptions<D>): SyncEngine;
110
112
  /**
111
113
  * The engine kept for `options.db` (by its name), built on first use: one engine per
112
114
  * database for the session (D15), so a company switch A→B→A finds A's engine again instead
@@ -114,7 +116,7 @@ export declare function createSyncEngine(options: SyncEngineOptions): SyncEngine
114
116
  * changing values (scopes, the client) through functions. A database object opened afresh
115
117
  * under the same name replaces the kept engine (the old one is stopped).
116
118
  */
117
- export declare function syncEngineFor(options: SyncEngineOptions): SyncEngine;
119
+ export declare function syncEngineFor<D extends SyncDb = SyncDb>(options: SyncEngineOptions<D>): SyncEngine;
118
120
  /**
119
121
  * Let go of the engine {@link syncEngineFor} keeps for the database `name` (0.46): it is
120
122
  * stopped, its database closed and forgotten, so a later `syncEngineFor` builds afresh. Call it