@kelpie/ui 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/api/resources/account.d.ts +5 -0
  2. package/dist/api/resources/account.d.ts.map +1 -1
  3. package/dist/api/resources/account.js +11 -0
  4. package/dist/api/resources/account.js.map +1 -1
  5. package/dist/api/resources/apiKeys.d.ts +15 -0
  6. package/dist/api/resources/apiKeys.d.ts.map +1 -0
  7. package/dist/api/resources/apiKeys.js +50 -0
  8. package/dist/api/resources/apiKeys.js.map +1 -0
  9. package/dist/app/KelpieApp.d.ts.map +1 -1
  10. package/dist/app/KelpieApp.js +3 -1
  11. package/dist/app/KelpieApp.js.map +1 -1
  12. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  13. package/dist/components/ActivitiesPanel.js +10 -7
  14. package/dist/components/ActivitiesPanel.js.map +1 -1
  15. package/dist/components/ApiKeysPanel.d.ts +20 -0
  16. package/dist/components/ApiKeysPanel.d.ts.map +1 -0
  17. package/dist/components/ApiKeysPanel.js +70 -0
  18. package/dist/components/ApiKeysPanel.js.map +1 -0
  19. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  20. package/dist/components/DecisionsPanel.js +3 -1
  21. package/dist/components/DecisionsPanel.js.map +1 -1
  22. package/dist/components/NotesPanel.d.ts.map +1 -1
  23. package/dist/components/NotesPanel.js +3 -1
  24. package/dist/components/NotesPanel.js.map +1 -1
  25. package/dist/components/Shell.d.ts.map +1 -1
  26. package/dist/components/Shell.js +1 -0
  27. package/dist/components/Shell.js.map +1 -1
  28. package/dist/lib/dates.d.ts +25 -5
  29. package/dist/lib/dates.d.ts.map +1 -1
  30. package/dist/lib/dates.js +49 -20
  31. package/dist/lib/dates.js.map +1 -1
  32. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  33. package/dist/pages/DecisionsPage.js +4 -2
  34. package/dist/pages/DecisionsPage.js.map +1 -1
  35. package/dist/pages/HiringPage.d.ts.map +1 -1
  36. package/dist/pages/HiringPage.js +3 -1
  37. package/dist/pages/HiringPage.js.map +1 -1
  38. package/dist/pages/PlanningPage.js +1 -1
  39. package/dist/pages/PlanningPage.js.map +1 -1
  40. package/dist/pages/account/AccountLayout.d.ts.map +1 -1
  41. package/dist/pages/account/AccountLayout.js +1 -0
  42. package/dist/pages/account/AccountLayout.js.map +1 -1
  43. package/dist/pages/account/PersonalApiKeysPage.d.ts +9 -0
  44. package/dist/pages/account/PersonalApiKeysPage.d.ts.map +1 -0
  45. package/dist/pages/account/PersonalApiKeysPage.js +14 -0
  46. package/dist/pages/account/PersonalApiKeysPage.js.map +1 -0
  47. package/dist/pages/account/PreferencesPage.js +1 -1
  48. package/dist/pages/account/PreferencesPage.js.map +1 -1
  49. package/dist/pages/account/SecurityPage.d.ts.map +1 -1
  50. package/dist/pages/account/SecurityPage.js +3 -2
  51. package/dist/pages/account/SecurityPage.js.map +1 -1
  52. package/dist/pages/admin/ApiKeysPage.d.ts +9 -0
  53. package/dist/pages/admin/ApiKeysPage.d.ts.map +1 -0
  54. package/dist/pages/admin/ApiKeysPage.js +20 -0
  55. package/dist/pages/admin/ApiKeysPage.js.map +1 -0
  56. package/dist/pages/admin/McpPage.d.ts.map +1 -1
  57. package/dist/pages/admin/McpPage.js +6 -3
  58. package/dist/pages/admin/McpPage.js.map +1 -1
  59. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  60. package/dist/pages/admin/WebhookDeliveries.js +4 -2
  61. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  62. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  63. package/dist/pages/admin/WebhooksPage.js +5 -3
  64. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  65. package/dist/pages/dashboard/DashboardPage.d.ts.map +1 -1
  66. package/dist/pages/dashboard/DashboardPage.js +6 -4
  67. package/dist/pages/dashboard/DashboardPage.js.map +1 -1
  68. package/dist/pages/dashboard/sections.d.ts +6 -3
  69. package/dist/pages/dashboard/sections.d.ts.map +1 -1
  70. package/dist/pages/dashboard/sections.js +6 -6
  71. package/dist/pages/dashboard/sections.js.map +1 -1
  72. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  73. package/dist/pages/forms/SubmissionsTable.js +3 -1
  74. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  75. package/dist/pages/handbook/HandbookPage.d.ts.map +1 -1
  76. package/dist/pages/handbook/HandbookPage.js +3 -1
  77. package/dist/pages/handbook/HandbookPage.js.map +1 -1
  78. package/package.json +2 -2
  79. package/src/api/resources/account.ts +13 -0
  80. package/src/api/resources/apiKeys.ts +60 -0
  81. package/src/app/KelpieApp.tsx +4 -0
  82. package/src/components/ActivitiesPanel.tsx +15 -4
  83. package/src/components/ApiKeysPanel.tsx +307 -0
  84. package/src/components/DecisionsPanel.tsx +5 -2
  85. package/src/components/NotesPanel.tsx +4 -1
  86. package/src/components/Shell.tsx +1 -0
  87. package/src/lib/dates.ts +54 -20
  88. package/src/pages/DecisionsPage.tsx +6 -2
  89. package/src/pages/HiringPage.tsx +3 -1
  90. package/src/pages/PlanningPage.tsx +1 -1
  91. package/src/pages/account/AccountLayout.tsx +1 -0
  92. package/src/pages/account/PersonalApiKeysPage.tsx +27 -0
  93. package/src/pages/account/PreferencesPage.tsx +1 -1
  94. package/src/pages/account/SecurityPage.tsx +4 -1
  95. package/src/pages/admin/ApiKeysPage.tsx +43 -0
  96. package/src/pages/admin/McpPage.tsx +7 -4
  97. package/src/pages/admin/WebhookDeliveries.tsx +4 -2
  98. package/src/pages/admin/WebhooksPage.tsx +5 -3
  99. package/src/pages/dashboard/DashboardPage.tsx +14 -4
  100. package/src/pages/dashboard/sections.tsx +9 -3
  101. package/src/pages/forms/SubmissionsTable.tsx +3 -1
  102. package/src/pages/handbook/HandbookPage.tsx +3 -1
package/src/lib/dates.ts CHANGED
@@ -4,17 +4,32 @@
4
4
  *
5
5
  * `now` is a parameter rather than a call to `new Date()` inside, because a
6
6
  * function that reads the clock cannot be tested against a fixed expectation.
7
+ * `timezone` is a parameter for the same reason, and because two people in
8
+ * different zones should see the day change at different moments. Resolve it
9
+ * once with `resolveTimezone` and pass the result in.
10
+ *
11
+ * The locale is left undefined throughout, which asks `Intl` for the runtime's
12
+ * own — the browser's, for every real caller. Formatters are built per call
13
+ * rather than cached, matching `dayIn` on the server: the zone varies by
14
+ * caller, and a cache keyed on it would only grow.
7
15
  */
8
16
 
9
- const DATE_TIME = new Intl.DateTimeFormat('en-AU', {
10
- day: 'numeric',
11
- month: 'short',
12
- hour: 'numeric',
13
- minute: '2-digit',
14
- })
17
+ /** The account's own choice, falling back to the workspace's, then to UTC. */
18
+ export function resolveTimezone(
19
+ userTimezone: string | undefined,
20
+ workspaceTimezone: string | undefined,
21
+ ): string {
22
+ return userTimezone ?? workspaceTimezone ?? 'UTC'
23
+ }
15
24
 
16
- export function formatDateTime(value: Date): string {
17
- return DATE_TIME.format(value)
25
+ export function formatDateTime(value: Date, timezone: string): string {
26
+ return new Intl.DateTimeFormat(undefined, {
27
+ day: 'numeric',
28
+ month: 'short',
29
+ hour: 'numeric',
30
+ minute: '2-digit',
31
+ timeZone: timezone,
32
+ }).format(value)
18
33
  }
19
34
 
20
35
  function plural(count: number, unit: string): string {
@@ -28,11 +43,11 @@ function plural(count: number, unit: string): string {
28
43
  * panel means the clocks disagree, and phrasing it as elapsed time would hide
29
44
  * that.
30
45
  */
31
- export function formatRelativeTime(value: Date, now: Date = new Date()): string {
46
+ export function formatRelativeTime(value: Date, timezone: string, now: Date = new Date()): string {
32
47
  const elapsedMs = now.getTime() - value.getTime()
33
48
 
34
49
  if (elapsedMs < 0) {
35
- return formatDateTime(value)
50
+ return formatDateTime(value, timezone)
36
51
  }
37
52
 
38
53
  const seconds = Math.round(elapsedMs / 1000)
@@ -64,27 +79,46 @@ export function formatRelativeTime(value: Date, now: Date = new Date()): string
64
79
  return months < 12 ? plural(months, 'month') : plural(Math.round(months / 12), 'year')
65
80
  }
66
81
 
67
- const DAY = new Intl.DateTimeFormat('en-AU', { day: 'numeric', month: 'short', year: 'numeric' })
82
+ const DAY = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
68
83
 
69
84
  /**
70
85
  * A date-only field (`YYYY-MM-DD`), as the mockup writes one. Parsed at midday
71
86
  * so no timezone west or east of UTC shifts it onto a neighbouring day.
87
+ *
88
+ * Takes no `timezone`: the value is already a specific calendar day, usually
89
+ * one the server resolved against the workspace's own zone, not an instant to
90
+ * re-project into whoever is looking at it.
72
91
  */
73
92
  export function formatDay(iso: string): string {
74
93
  return DAY.format(new Date(`${iso}T12:00:00`))
75
94
  }
76
95
 
77
96
  /** A moment whose time of day does not matter to the reader, e.g. when a decision was made. */
78
- export function formatDate(value: Date): string {
79
- return DAY.format(value)
97
+ export function formatDate(value: Date, timezone: string): string {
98
+ return new Intl.DateTimeFormat(undefined, {
99
+ day: 'numeric',
100
+ month: 'short',
101
+ year: 'numeric',
102
+ timeZone: timezone,
103
+ }).format(value)
80
104
  }
81
105
 
82
- const MONTH = new Intl.DateTimeFormat('en-AU', { month: 'long' })
83
- const MONTH_AND_YEAR = new Intl.DateTimeFormat('en-AU', { month: 'long', year: 'numeric' })
106
+ /** The year `value` falls in, within `timezone`, as digits — for comparison, not display. */
107
+ function yearIn(value: Date, timezone: string): string {
108
+ return new Intl.DateTimeFormat(undefined, { year: 'numeric', timeZone: timezone }).format(value)
109
+ }
84
110
 
85
- /** The heading a timeline groups under. The year is dropped for the current one. */
86
- export function monthLabel(value: Date, now: Date = new Date()): string {
87
- return value.getFullYear() === now.getFullYear()
88
- ? MONTH.format(value)
89
- : MONTH_AND_YEAR.format(value)
111
+ /**
112
+ * The heading a timeline groups under. The year is dropped for the current one.
113
+ *
114
+ * "Current" is judged in `timezone`, not the machine's own: a moment near a
115
+ * year boundary is still December in one zone while already January somewhere
116
+ * else, and the heading should agree with whichever zone it is rendered in.
117
+ */
118
+ export function monthLabel(value: Date, timezone: string, now: Date = new Date()): string {
119
+ return yearIn(value, timezone) === yearIn(now, timezone)
120
+ ? new Intl.DateTimeFormat(undefined, { month: 'long', timeZone: timezone }).format(value)
121
+ : new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric', timeZone: timezone }).format(
122
+ value,
123
+ )
90
124
  }
@@ -2,6 +2,7 @@ import type { Decision, RecordTargetType } from '@kelpie/schemas'
2
2
  import { useMemo, useState } from 'react'
3
3
  import { Link } from 'react-router'
4
4
 
5
+ import { useTimezone } from '../api/resources/account.ts'
5
6
  import { useCompanies } from '../api/resources/companies.ts'
6
7
  import { useDeals } from '../api/resources/deals.ts'
7
8
  import { useDecisions } from '../api/resources/decisions.ts'
@@ -127,6 +128,7 @@ export function DecisionsPage(): React.JSX.Element {
127
128
  const decisions = useDecisions({ term: term.trim().length > 0 ? term.trim() : undefined })
128
129
  const directory = useTargetDirectory()
129
130
  const members = useMembers()
131
+ const timezone = useTimezone()
130
132
 
131
133
  const columns: readonly Column<Decision>[] = [
132
134
  {
@@ -149,14 +151,16 @@ export function DecisionsPage(): React.JSX.Element {
149
151
  {
150
152
  key: 'decided',
151
153
  header: 'Decided',
152
- render: (decision) => <span className="text-ink-muted">{formatDate(decision.decidedAt)}</span>,
154
+ render: (decision) => (
155
+ <span className="text-ink-muted">{formatDate(decision.decidedAt, timezone)}</span>
156
+ ),
153
157
  },
154
158
  {
155
159
  key: 'due',
156
160
  header: 'By',
157
161
  render: (decision) => (
158
162
  <span className="text-ink-muted">
159
- {decision.dueAt === null ? '—' : formatDate(decision.dueAt)}
163
+ {decision.dueAt === null ? '—' : formatDate(decision.dueAt, timezone)}
160
164
  </span>
161
165
  ),
162
166
  },
@@ -3,6 +3,7 @@ import { useState } from 'react'
3
3
  import type { FormEvent } from 'react'
4
4
  import { useNavigate } from 'react-router'
5
5
 
6
+ import { useTimezone } from '../api/resources/account.ts'
6
7
  import { useCreateRole, useRoles } from '../api/resources/roles.ts'
7
8
  import { Chip } from '../components/Chip.tsx'
8
9
  import { DataTable } from '../components/DataTable.tsx'
@@ -27,6 +28,7 @@ export function HiringPage(): React.JSX.Element {
27
28
  const roles = useRoles({ term: term.trim().length > 0 ? term.trim() : undefined })
28
29
  const counts = useRoleCandidateCounts(roles.records.map((role) => role.id))
29
30
  const createRole = useCreateRole()
31
+ const timezone = useTimezone()
30
32
 
31
33
  function reset(): void {
32
34
  setAdding(false)
@@ -76,7 +78,7 @@ export function HiringPage(): React.JSX.Element {
76
78
  {
77
79
  key: 'created',
78
80
  header: 'Created',
79
- render: (role) => formatDate(role.createdAt),
81
+ render: (role) => formatDate(role.createdAt, timezone),
80
82
  },
81
83
  ]
82
84
 
@@ -33,7 +33,7 @@ const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
33
33
  /** How many of a day's items a calendar cell shows before summarising the rest. */
34
34
  const CELL_ITEM_LIMIT = 3
35
35
 
36
- const MONTH_AND_YEAR = new Intl.DateTimeFormat('en-AU', { month: 'long', year: 'numeric' })
36
+ const MONTH_AND_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' })
37
37
 
38
38
  export function PlanningPage(): React.JSX.Element {
39
39
  const [view, setView] = useState<ViewMode>('list')
@@ -20,6 +20,7 @@ const CORE_TABS: readonly NavItem[] = [
20
20
  { id: 'profile', to: '/account/profile', label: 'Profile', order: 100 },
21
21
  { id: 'security', to: '/account/security', label: 'Security', order: 200 },
22
22
  { id: 'preferences', to: '/account/preferences', label: 'Preferences', order: 300 },
23
+ { id: 'api-keys', to: '/account/api-keys', label: 'API keys', order: 400 },
23
24
  ]
24
25
 
25
26
  function tabClass({ isActive }: { isActive: boolean }): string {
@@ -0,0 +1,27 @@
1
+ import { ApiKeysPanel } from '../../components/ApiKeysPanel.tsx'
2
+ import { PageHeader } from '../../components/PageHeader.tsx'
3
+
4
+ /**
5
+ * Personal keys: act as the signed-in member against the public API and MCP.
6
+ *
7
+ * No role gate. `GET /v1/api-keys?kind=personal` scopes itself to the caller —
8
+ * every member manages their own, and an admin cannot see or revoke a
9
+ * colleague's; removing their membership is how you cut that off.
10
+ */
11
+
12
+ export function PersonalApiKeysPage(): React.JSX.Element {
13
+ return (
14
+ <div className="animate-slide-in space-y-4">
15
+ <PageHeader
16
+ title="API keys"
17
+ description="Personal keys act as you against the public API and MCP. Workspace keys live under Admin."
18
+ />
19
+ <ApiKeysPanel
20
+ kind="personal"
21
+ namePlaceholder="Laptop Claude"
22
+ createTitle="Create personal API key"
23
+ emptyMessage="No personal API keys yet."
24
+ />
25
+ </div>
26
+ )
27
+ }
@@ -133,7 +133,7 @@ function PreferencesForm({
133
133
  <div className="max-w-md">
134
134
  <Field
135
135
  label="Timezone"
136
- hint="Stored on your account. Kelpie does not format dates by it yet."
136
+ hint="Stored on your account. Dates across Kelpie use this zone."
137
137
  >
138
138
  <EntitySearch
139
139
  options={zones.map((zone) => ({ id: zone, label: zone }))}
@@ -7,6 +7,7 @@ import {
7
7
  useAccountSessions,
8
8
  useChangePassword,
9
9
  useRevokeAccountSession,
10
+ useTimezone,
10
11
  } from '../../api/resources/account.ts'
11
12
  import { PageHeader } from '../../components/PageHeader.tsx'
12
13
  import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
@@ -208,6 +209,8 @@ function SessionRow({
208
209
  readonly onRevoke: () => void
209
210
  readonly isRevoking: boolean
210
211
  }): React.JSX.Element {
212
+ const timezone = useTimezone()
213
+
211
214
  return (
212
215
  <tr className="border-b border-border last:border-0">
213
216
  <td className="px-4 py-3">
@@ -219,7 +222,7 @@ function SessionRow({
219
222
  is on the wire and a session says where it is from once something can
220
223
  tell. */}
221
224
  <td className="px-4 py-3 text-ink-muted">{session.location ?? 'Unknown'}</td>
222
- <td className="px-4 py-3 text-ink-muted">{formatDateTime(session.lastActiveAt)}</td>
225
+ <td className="px-4 py-3 text-ink-muted">{formatDateTime(session.lastActiveAt, timezone)}</td>
223
226
  <td className="px-4 py-3 text-right">
224
227
  {!session.current && (
225
228
  <button
@@ -0,0 +1,43 @@
1
+ import { useSession } from '../../api/resources/session.ts'
2
+ import { ApiKeysPanel } from '../../components/ApiKeysPanel.tsx'
3
+ import { PageHeader } from '../../components/PageHeader.tsx'
4
+
5
+ /**
6
+ * Workspace keys, for shared agents and CI.
7
+ *
8
+ * `GET /v1/api-keys?kind=workspace` needs the admin role; the server answers
9
+ * `403` to anyone else. This page shows that plainly instead of rendering an
10
+ * empty table, the same shape `WebhooksPage` uses for the same reason.
11
+ */
12
+
13
+ export function ApiKeysPage(): React.JSX.Element {
14
+ const { session } = useSession()
15
+ const isAdmin = session?.role === 'owner' || session?.role === 'admin'
16
+
17
+ return (
18
+ <div className="animate-slide-in mx-auto max-w-4xl space-y-4">
19
+ <PageHeader
20
+ title="API keys"
21
+ description="Workspace keys for shared agents and CI. Personal keys live under Account → API keys."
22
+ />
23
+ {isAdmin ? (
24
+ <ApiKeysPanel
25
+ kind="workspace"
26
+ namePlaceholder="CI pipeline"
27
+ createTitle="Create API key"
28
+ emptyMessage="No API keys yet."
29
+ />
30
+ ) : (
31
+ <MemberNotice />
32
+ )}
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function MemberNotice(): React.JSX.Element {
38
+ return (
39
+ <p className="rounded-md border border-border px-4 py-3 text-[13px] text-ink-muted">
40
+ Workspace keys are managed by workspace admins.
41
+ </p>
42
+ )
43
+ }
@@ -3,6 +3,7 @@ import type { AgentRun, McpTool } from '@kelpie/schemas'
3
3
  import { useMemo, useState } from 'react'
4
4
  import { Link } from 'react-router'
5
5
 
6
+ import { useTimezone } from '../../api/resources/account.ts'
6
7
  import {
7
8
  useAgentRuns,
8
9
  useAgents,
@@ -107,8 +108,8 @@ export function McpPage(): React.JSX.Element {
107
108
  </div>
108
109
  <p className="mt-1 text-[12px] text-ink-muted">
109
110
  Paste into your MCP client config, and replace the bearer token with a workspace key from{' '}
110
- <Link to="/admin/workspace" className="text-accent hover:underline">
111
- workspace settings
111
+ <Link to="/admin/api-keys" className="text-accent hover:underline">
112
+ API keys
112
113
  </Link>
113
114
  .
114
115
  </p>
@@ -133,6 +134,7 @@ function RegisteredAgents(): React.JSX.Element {
133
134
  const { records, isLoading, error } = useAgents()
134
135
  const createAgent = useCreateAgent()
135
136
  const deleteAgent = useDeleteAgent()
137
+ const timezone = useTimezone()
136
138
  const [adding, setAdding] = useState(false)
137
139
  const [name, setName] = useState('')
138
140
  const [endpoint, setEndpoint] = useState('')
@@ -253,7 +255,7 @@ function RegisteredAgents(): React.JSX.Element {
253
255
  <span>
254
256
  {agent.lastRunAt === null
255
257
  ? 'Never run'
256
- : `Last run ${formatRelativeTime(agent.lastRunAt)}`}
258
+ : `Last run ${formatRelativeTime(agent.lastRunAt, timezone)}`}
257
259
  </span>
258
260
  <button
259
261
  type="button"
@@ -283,6 +285,7 @@ function RecentRuns(): React.JSX.Element {
283
285
  const { records: runs, isLoading, error } = useAgentRuns()
284
286
  const catalog = useAllAgentTasks()
285
287
  const agents = useAgents()
288
+ const timezone = useTimezone()
286
289
 
287
290
  const taskLabels = useMemo(
288
291
  () => new Map(catalog.records.map((task) => [task.id, task.label])),
@@ -333,7 +336,7 @@ function RecentRuns(): React.JSX.Element {
333
336
  <span className={run.status === 'failed' ? 'font-medium text-danger' : 'text-ink-muted'}>
334
337
  {AGENT_RUN_STATUS_LABELS[run.status]}
335
338
  </span>
336
- <span className="text-ink-faint">{formatRelativeTime(run.updatedAt)}</span>
339
+ <span className="text-ink-faint">{formatRelativeTime(run.updatedAt, timezone)}</span>
337
340
  </div>
338
341
  </li>
339
342
  ))}
@@ -1,6 +1,7 @@
1
1
  import type { WebhookDelivery, WebhookDeliveryStatus } from '@kelpie/schemas'
2
2
  import { useState } from 'react'
3
3
 
4
+ import { useTimezone } from '../../api/resources/account.ts'
4
5
  import { useWebhookDeliveries } from '../../api/resources/webhooks.ts'
5
6
  import { Chip } from '../../components/Chip.tsx'
6
7
  import type { ChipTone } from '../../components/Chip.tsx'
@@ -37,6 +38,7 @@ const STATUS_TONES: Readonly<Record<WebhookDeliveryStatus, ChipTone>> = {
37
38
  export function WebhookDeliveries({ webhookId }: { readonly webhookId: string }): React.JSX.Element {
38
39
  const [status, setStatus] = useState<StatusFilter>('all')
39
40
  const [openPayloadId, setOpenPayloadId] = useState<string | null>(null)
41
+ const timezone = useTimezone()
40
42
  const deliveries = useWebhookDeliveries(webhookId, {
41
43
  status: status === 'all' ? undefined : status,
42
44
  })
@@ -68,7 +70,7 @@ export function WebhookDeliveries({ webhookId }: { readonly webhookId: string })
68
70
  className: 'w-32',
69
71
  render: (delivery) => (
70
72
  <span className="font-mono text-[12px] text-ink-muted">
71
- {formatDateTime(delivery.createdAt)}
73
+ {formatDateTime(delivery.createdAt, timezone)}
72
74
  </span>
73
75
  ),
74
76
  },
@@ -80,7 +82,7 @@ export function WebhookDeliveries({ webhookId }: { readonly webhookId: string })
80
82
  // reader is looking for. An em dash says "never" without claiming a time.
81
83
  render: (delivery) => (
82
84
  <span className="font-mono text-[12px] text-ink-muted">
83
- {delivery.deliveredAt === null ? '—' : formatDateTime(delivery.deliveredAt)}
85
+ {delivery.deliveredAt === null ? '—' : formatDateTime(delivery.deliveredAt, timezone)}
84
86
  </span>
85
87
  ),
86
88
  },
@@ -7,6 +7,7 @@ import type { CreatedWebhook, Webhook, WebhookEvent, WebhookStatus } from '@kelp
7
7
  import { useState } from 'react'
8
8
  import type { FormEvent } from 'react'
9
9
 
10
+ import { useTimezone } from '../../api/resources/account.ts'
10
11
  import { useSession } from '../../api/resources/session.ts'
11
12
  import {
12
13
  useCreateWebhook,
@@ -250,6 +251,7 @@ function WebhookRow({
250
251
  const updateWebhook = useUpdateWebhook()
251
252
  const deleteWebhook = useDeleteWebhook()
252
253
  const rotateSecret = useRotateWebhookSecret()
254
+ const timezone = useTimezone()
253
255
  const [isConfirmingRemoval, setIsConfirmingRemoval] = useState(false)
254
256
  const [isShowingDeliveries, setIsShowingDeliveries] = useState(false)
255
257
  const [isRotating, setIsRotating] = useState(false)
@@ -319,7 +321,7 @@ function WebhookRow({
319
321
 
320
322
  <div className="mt-3 flex flex-wrap items-center gap-4 text-[11px] text-ink-faint">
321
323
  <span className="font-mono">Secret {webhook.secretPrefix}</span>
322
- <span>Last delivery: {describeLastDelivery(webhook)}</span>
324
+ <span>Last delivery: {describeLastDelivery(webhook, timezone)}</span>
323
325
  <button
324
326
  type="button"
325
327
  aria-expanded={isShowingDeliveries}
@@ -434,10 +436,10 @@ function RotatePanel({
434
436
  }
435
437
 
436
438
  /** Both fields are null together until the first delivery settles. */
437
- function describeLastDelivery(webhook: Webhook): string {
439
+ function describeLastDelivery(webhook: Webhook, timezone: string): string {
438
440
  if (webhook.lastDeliveryAt === null || webhook.lastDeliveryStatus === null) {
439
441
  return 'Never'
440
442
  }
441
443
 
442
- return `${formatDateTime(webhook.lastDeliveryAt)} (${webhook.lastDeliveryStatus})`
444
+ return `${formatDateTime(webhook.lastDeliveryAt, timezone)} (${webhook.lastDeliveryStatus})`
443
445
  }
@@ -1,5 +1,6 @@
1
1
  import type { Dashboard } from '@kelpie/schemas'
2
2
 
3
+ import { useTimezone } from '../../api/resources/account.ts'
3
4
  import { useDashboard } from '../../api/resources/dashboard.ts'
4
5
  import { useMembers } from '../../api/resources/members.ts'
5
6
  import { useWorkspace } from '../../api/resources/workspace.ts'
@@ -20,7 +21,7 @@ import { ActivityFeed, AttentionList, DecisionsList, NotesList } from './section
20
21
 
21
22
  /** The heading date, written in the workspace's own day rather than the browser's. */
22
23
  function briefHeading(dashboard: Dashboard): string {
23
- return new Intl.DateTimeFormat('en-AU', {
24
+ return new Intl.DateTimeFormat(undefined, {
24
25
  weekday: 'long',
25
26
  month: 'short',
26
27
  day: 'numeric',
@@ -32,6 +33,7 @@ export function DashboardPage(): React.JSX.Element {
32
33
  const { dashboard, isLoading, error } = useDashboard()
33
34
  const { workspace } = useWorkspace()
34
35
  const { nameById } = useMembers()
36
+ const timezone = useTimezone()
35
37
 
36
38
  return (
37
39
  <div className="animate-fade-in">
@@ -46,6 +48,7 @@ export function DashboardPage(): React.JSX.Element {
46
48
  workspaceId={workspace?.id}
47
49
  workspaceName={workspace?.name ?? 'This workspace'}
48
50
  nameById={nameById}
51
+ timezone={timezone}
49
52
  />
50
53
  )}
51
54
  </div>
@@ -57,11 +60,13 @@ function DashboardBody({
57
60
  workspaceId,
58
61
  workspaceName,
59
62
  nameById,
63
+ timezone,
60
64
  }: {
61
65
  readonly dashboard: Dashboard
62
66
  readonly workspaceId: string | undefined
63
67
  readonly workspaceName: string
64
68
  readonly nameById: ReadonlyMap<string, string>
69
+ readonly timezone: string
65
70
  }): React.JSX.Element {
66
71
  // `generatedAt` rather than a fresh `new Date()`: every relative time on the
67
72
  // page is then measured from the same instant the counts were, so a tab left
@@ -97,11 +102,16 @@ function DashboardBody({
97
102
 
98
103
  <div className="mb-8 grid gap-8 lg:grid-cols-2">
99
104
  <AttentionList rows={attentionRows(dashboard)} />
100
- <ActivityFeed activities={dashboard.recentActivity} nameById={nameById} now={now} />
105
+ <ActivityFeed
106
+ activities={dashboard.recentActivity}
107
+ nameById={nameById}
108
+ now={now}
109
+ timezone={timezone}
110
+ />
101
111
  </div>
102
112
  <div className="grid gap-8 lg:grid-cols-2">
103
- <NotesList notes={dashboard.recentNotes} nameById={nameById} now={now} />
104
- <DecisionsList decisions={dashboard.recentDecisions} nameById={nameById} />
113
+ <NotesList notes={dashboard.recentNotes} nameById={nameById} now={now} timezone={timezone} />
114
+ <DecisionsList decisions={dashboard.recentDecisions} nameById={nameById} timezone={timezone} />
105
115
  </div>
106
116
  </div>
107
117
  )
@@ -106,10 +106,12 @@ export function ActivityFeed({
106
106
  activities,
107
107
  nameById,
108
108
  now,
109
+ timezone,
109
110
  }: {
110
111
  readonly activities: readonly DashboardActivity[]
111
112
  readonly nameById: ReadonlyMap<string, string>
112
113
  readonly now: Date
114
+ readonly timezone: string
113
115
  }): React.JSX.Element {
114
116
  return (
115
117
  <section>
@@ -145,7 +147,7 @@ export function ActivityFeed({
145
147
  dateTime={activity.createdAt.toISOString()}
146
148
  className="shrink-0 text-[11px] text-ink-faint"
147
149
  >
148
- {formatRelativeTime(activity.createdAt, now)}
150
+ {formatRelativeTime(activity.createdAt, timezone, now)}
149
151
  </time>
150
152
  </div>
151
153
  <p className="text-[11px] leading-snug text-ink-faint">
@@ -184,10 +186,12 @@ export function NotesList({
184
186
  notes,
185
187
  nameById,
186
188
  now,
189
+ timezone,
187
190
  }: {
188
191
  readonly notes: readonly DashboardNote[]
189
192
  readonly nameById: ReadonlyMap<string, string>
190
193
  readonly now: Date
194
+ readonly timezone: string
191
195
  }): React.JSX.Element {
192
196
  return (
193
197
  <section>
@@ -211,7 +215,7 @@ export function NotesList({
211
215
  <span>·</span>
212
216
  <span>{note.authorId === null ? 'System' : (nameById.get(note.authorId) ?? 'Unknown')}</span>
213
217
  <span>·</span>
214
- <span>{formatRelativeTime(note.createdAt, now)}</span>
218
+ <span>{formatRelativeTime(note.createdAt, timezone, now)}</span>
215
219
  </div>
216
220
  </li>
217
221
  ))}
@@ -224,9 +228,11 @@ export function NotesList({
224
228
  export function DecisionsList({
225
229
  decisions,
226
230
  nameById,
231
+ timezone,
227
232
  }: {
228
233
  readonly decisions: readonly DashboardDecision[]
229
234
  readonly nameById: ReadonlyMap<string, string>
235
+ readonly timezone: string
230
236
  }): React.JSX.Element {
231
237
  return (
232
238
  <section>
@@ -254,7 +260,7 @@ export function DecisionsList({
254
260
  <div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-ink-faint">
255
261
  <TargetLink target={decision} />
256
262
  <span>·</span>
257
- <span>{formatDate(decision.decidedAt)}</span>
263
+ <span>{formatDate(decision.decidedAt, timezone)}</span>
258
264
  <span>·</span>
259
265
  <span>
260
266
  {decision.ownerId === null ? '—' : (nameById.get(decision.ownerId) ?? 'Unknown')}
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
3
3
  import { Link } from 'react-router'
4
4
 
5
5
  import type { RecordListResult } from '../../api/resource.ts'
6
+ import { useTimezone } from '../../api/resources/account.ts'
6
7
  import { useCompanies } from '../../api/resources/companies.ts'
7
8
  import { usePeople } from '../../api/resources/people.ts'
8
9
  import { DataTable } from '../../components/DataTable.tsx'
@@ -31,6 +32,7 @@ export interface SubmissionsTableProps {
31
32
  export function SubmissionsTable({ form, submissions }: SubmissionsTableProps): React.JSX.Element {
32
33
  const people = usePeople({ limit: MAX_PAGE })
33
34
  const companies = useCompanies({ limit: MAX_PAGE })
35
+ const timezone = useTimezone()
34
36
 
35
37
  const nameById = useMemo(
36
38
  () => new Map([...people.records, ...companies.records].map((record) => [record.id, record.name])),
@@ -44,7 +46,7 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
44
46
  className: 'w-40',
45
47
  render: (submission) => (
46
48
  <span className="font-mono text-[12px] text-ink-muted">
47
- {formatDateTime(submission.submittedAt)}
49
+ {formatDateTime(submission.submittedAt, timezone)}
48
50
  </span>
49
51
  ),
50
52
  },
@@ -13,6 +13,7 @@ import type { HandbookPage } from '@kelpie/schemas'
13
13
  import { useEffect, useMemo, useState } from 'react'
14
14
  import { Link, useNavigate, useParams } from 'react-router'
15
15
 
16
+ import { useTimezone } from '../../api/resources/account.ts'
16
17
  import {
17
18
  useCreateHandbookPage,
18
19
  useDeleteHandbookPage,
@@ -384,6 +385,7 @@ function HandbookEditor({
384
385
  const [editing, setEditing] = useState(false)
385
386
  const [title, setTitle] = useState(page.title)
386
387
  const [body, setBody] = useState(page.body)
388
+ const timezone = useTimezone()
387
389
 
388
390
  useEffect(() => {
389
391
  setTitle(page.title)
@@ -415,7 +417,7 @@ function HandbookEditor({
415
417
  <h1 className="text-[18px] font-semibold tracking-tight text-ink">{page.title}</h1>
416
418
  )}
417
419
  <div className="mt-1 text-[11px] text-ink-faint">
418
- Updated {formatDateTime(page.updatedAt)}
420
+ Updated {formatDateTime(page.updatedAt, timezone)}
419
421
  {authorName === undefined ? '' : ` · ${authorName}`}
420
422
  </div>
421
423
  </div>