@hearthlog/shell 0.0.0-stage → 0.1.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 (80) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/app.template.html +184 -0
  4. package/src/components/ai/DecisionReviewSheet.svelte +226 -0
  5. package/src/components/ai/SuggestButton.svelte +23 -0
  6. package/src/components/ai/SuggestionChip.svelte +13 -0
  7. package/src/components/ai/SuggestionMark.svelte +15 -0
  8. package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
  9. package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
  10. package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
  11. package/src/components/calendar/source-icons.ts +19 -0
  12. package/src/components/data-table/DataTableValue.svelte +14 -0
  13. package/src/components/data-table/EntityActionsCell.svelte +36 -0
  14. package/src/components/data-table/EntityRefsCell.svelte +58 -0
  15. package/src/components/data-table/EntityValue.svelte +42 -0
  16. package/src/components/data-table/entity-columns.ts +87 -0
  17. package/src/components/data-table/sqlite-columns.ts +42 -0
  18. package/src/components/entities/CalendarSourceFields.svelte +149 -0
  19. package/src/components/history/ChangeRecord.svelte +30 -0
  20. package/src/components/history/ForkComposer.svelte +174 -0
  21. package/src/components/history/HistoryScrubber.svelte +245 -0
  22. package/src/components/history/LogHistory.svelte +334 -0
  23. package/src/components/history/LogMap.svelte +293 -0
  24. package/src/components/history/LogTree.svelte +124 -0
  25. package/src/components/layout/AccountMenu.svelte +111 -0
  26. package/src/components/layout/AppearanceOptions.svelte +78 -0
  27. package/src/components/layout/BrandWordmark.svelte +12 -0
  28. package/src/components/layout/ErrorToast.svelte +34 -0
  29. package/src/components/layout/LogCreateDrawer.svelte +111 -0
  30. package/src/components/layout/LogPanel.svelte +225 -0
  31. package/src/components/layout/LogStatusChip.svelte +137 -0
  32. package/src/components/layout/MobilePageAction.svelte +44 -0
  33. package/src/components/layout/MovedBanner.svelte +51 -0
  34. package/src/components/layout/NavBar.svelte +603 -0
  35. package/src/components/layout/NavFlyout.svelte +132 -0
  36. package/src/components/layout/NavItem.svelte +36 -0
  37. package/src/components/layout/NavMoreDialog.svelte +179 -0
  38. package/src/components/layout/PageDataStatus.svelte +36 -0
  39. package/src/components/layout/PhoneGlance.svelte +96 -0
  40. package/src/components/layout/SidebarResizer.svelte +122 -0
  41. package/src/components/layout/SyncIndicator.svelte +24 -0
  42. package/src/components/layout/feature-icons.ts +24 -0
  43. package/src/components/layout/nav-indicator.ts +128 -0
  44. package/src/components/layout/sidebar-size.ts +31 -0
  45. package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
  46. package/src/components/sync/SyncStateBadge.svelte +17 -0
  47. package/src/pages/calendar/Page.svelte +1341 -0
  48. package/src/pages/calendar/load.ts +77 -0
  49. package/src/pages/developer/DatabaseBrowser.svelte +325 -0
  50. package/src/pages/developer/Event.svelte +51 -0
  51. package/src/pages/developer/EventLogManager.svelte +268 -0
  52. package/src/pages/developer/IndexedDbStats.svelte +93 -0
  53. package/src/pages/developer/LedgerAdmin.svelte +877 -0
  54. package/src/pages/developer/LocalSqliteStats.svelte +56 -0
  55. package/src/pages/developer/Page.svelte +590 -0
  56. package/src/pages/developer/PendingWorkImport.svelte +49 -0
  57. package/src/pages/developer/PerformanceDashboard.svelte +262 -0
  58. package/src/pages/developer/StackInfo.svelte +117 -0
  59. package/src/pages/developer/SyncLog.svelte +188 -0
  60. package/src/pages/developer/load.ts +28 -0
  61. package/src/pages/entities/Page.svelte +1069 -0
  62. package/src/pages/entities/load.ts +18 -0
  63. package/src/pages/entity/Page.svelte +655 -0
  64. package/src/pages/entity/load.ts +79 -0
  65. package/src/pages/error/ErrorPage.svelte +147 -0
  66. package/src/pages/layout/Layout.svelte +597 -0
  67. package/src/pages/layout/load.ts +28 -0
  68. package/src/pages/redirects/history.ts +20 -0
  69. package/src/pages/redirects/start.ts +11 -0
  70. package/src/pages/redirects/today.ts +9 -0
  71. package/src/service-worker/index.ts +108 -0
  72. package/src/service-worker/offline-bundle.ts +116 -0
  73. package/src/service-worker/virtual.d.ts +3 -0
  74. package/src/styles/index.css +11 -0
  75. package/src/styles/shell.css +296 -0
  76. package/static/_headers +4 -0
  77. package/static/favicon.ico +0 -0
  78. package/static/offline.html +44 -0
  79. package/static/replay/sw.js +2 -0
  80. package/README.md +0 -3
@@ -0,0 +1,414 @@
1
+ <script lang="ts">
2
+ import ConfirmDialog from "@hearthlog/ui/components/ConfirmDialog.svelte";
3
+ import Drawer from "@hearthlog/ui/components/Drawer.svelte";
4
+ import EmptyState from "@hearthlog/ui/components/EmptyState.svelte";
5
+ import DataPlaceholder from "@hearthlog/ui/components/data/DataPlaceholder.svelte";
6
+ import Badge from "@hearthlog/ui/components/ui/Badge.svelte";
7
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
8
+ import Checkbox from "@hearthlog/ui/components/ui/Checkbox.svelte";
9
+ import Field from "@hearthlog/ui/components/ui/Field.svelte";
10
+ import Input from "@hearthlog/ui/components/ui/Input.svelte";
11
+ import Select from "@hearthlog/ui/components/ui/Select.svelte";
12
+ import { calendarSourceIcon } from "./source-icons.js";
13
+ import {
14
+ calendarMenuEntries,
15
+ type CalendarMenuEntry,
16
+ } from "@hearthlog/runtime/calendar/groups.js";
17
+ import type { CalendarSourceInfo } from "@hearthlog/contracts/calendar/types";
18
+ import {
19
+ createCalendarFeed,
20
+ listCalendarFeeds,
21
+ revokeCalendarFeed,
22
+ webcalUrl,
23
+ type CalendarFeedSummary,
24
+ type CalendarFeedWindow,
25
+ type CreatedCalendarFeed,
26
+ } from "@hearthlog/runtime/calendar/feeds.js";
27
+ import { formatDistanceToNow } from "date-fns";
28
+ import { requireActiveContext } from "@hearthlog/runtime/db/index.js";
29
+ import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
30
+ import { untrack } from "svelte";
31
+ import IconRss from "~icons/mdi/rss";
32
+ import IconPlus from "~icons/mdi/plus";
33
+ import IconArrowLeft from "~icons/mdi/arrow-left";
34
+ import IconCheck from "~icons/mdi/check";
35
+ import IconCheckCircle from "~icons/mdi/check-circle-outline";
36
+ import IconContentCopy from "~icons/mdi/content-copy";
37
+ import IconLinkOff from "~icons/mdi/link-off";
38
+
39
+ let {
40
+ show = $bindable(false),
41
+ sources,
42
+ }: { show: boolean; sources: CalendarSourceInfo[] } = $props();
43
+
44
+ const formId = $props.id();
45
+
46
+ const WINDOW_PRESETS: Array<{
47
+ value: string;
48
+ label: string;
49
+ windowDays: CalendarFeedWindow;
50
+ }> = [
51
+ {
52
+ value: "default",
53
+ label: "6 months back, 18 months ahead",
54
+ windowDays: { pastDays: 183, futureDays: 548 },
55
+ },
56
+ {
57
+ value: "half",
58
+ label: "1 month back, 6 months ahead",
59
+ windowDays: { pastDays: 31, futureDays: 183 },
60
+ },
61
+ {
62
+ value: "wide",
63
+ label: "1 year back, 2 years ahead",
64
+ windowDays: { pastDays: 365, futureDays: 730 },
65
+ },
66
+ {
67
+ value: "future",
68
+ label: "Future only, 1 year ahead",
69
+ windowDays: { pastDays: 0, futureDays: 365 },
70
+ },
71
+ ];
72
+
73
+ type View = "list" | "create" | "created";
74
+
75
+ let view = $state<View>("list");
76
+ let feeds = $state<CalendarFeedSummary[]>([]);
77
+ let loading = $state(false);
78
+ let busy = $state(false);
79
+ let error = $state("");
80
+ let created = $state<CreatedCalendarFeed | null>(null);
81
+ let label = $state("");
82
+ let windowPreset = $state("default");
83
+ let selectedSources = $state<Set<string>>(new Set());
84
+ let revokeId = $state<string | null>(null);
85
+ let copied = $state(false);
86
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
87
+ let wasOpen = false;
88
+
89
+ const title = $derived(view === "create" ? "New calendar feed" : "Calendar feeds");
90
+ const description = $derived(
91
+ view === "create"
92
+ ? "Publish this log to a calendar app."
93
+ : "Subscribe to this log from Google Calendar, Apple Calendar, or Thunderbird.",
94
+ );
95
+ const allSelected = $derived(
96
+ sources.length > 0 && sources.every((source) => selectedSources.has(source.key)),
97
+ );
98
+ const canCreate = $derived(selectedSources.size > 0);
99
+ const sourceEntries = $derived(calendarMenuEntries(sources));
100
+
101
+ $effect(() => {
102
+ if (show && !wasOpen) {
103
+ wasOpen = true;
104
+ openDrawer();
105
+ } else if (!show && wasOpen) {
106
+ wasOpen = false;
107
+ closeDrawer();
108
+ }
109
+ });
110
+
111
+ function openDrawer(): void {
112
+ view = "list";
113
+ created = null;
114
+ error = "";
115
+ busy = false;
116
+ revokeId = null;
117
+ label = "";
118
+ windowPreset = "default";
119
+ copied = false;
120
+ // `sources` identity changes with every page refresh; untrack it so a
121
+ // background sync cannot reset the form while the drawer is open.
122
+ selectedSources = new Set(
123
+ untrack(() => sources).map((source) => source.key),
124
+ );
125
+ void load();
126
+ }
127
+
128
+ function closeDrawer(): void {
129
+ if (copyTimer) clearTimeout(copyTimer);
130
+ copied = false;
131
+ created = null;
132
+ error = "";
133
+ busy = false;
134
+ revokeId = null;
135
+ }
136
+
137
+ async function load(): Promise<void> {
138
+ loading = true;
139
+ error = "";
140
+ try {
141
+ const context = await requireActiveContext();
142
+ feeds = await listCalendarFeeds(context.logId);
143
+ } catch (cause) {
144
+ error = messageOf(cause);
145
+ } finally {
146
+ loading = false;
147
+ }
148
+ }
149
+
150
+ async function create(): Promise<void> {
151
+ if (busy || !canCreate) return;
152
+ busy = true;
153
+ error = "";
154
+ try {
155
+ const context = await requireActiveContext();
156
+ const allKeys = sources.map((source) => source.key);
157
+ const chosen = allKeys.filter((key) => selectedSources.has(key));
158
+ const preset =
159
+ WINDOW_PRESETS.find((item) => item.value === windowPreset) ??
160
+ WINDOW_PRESETS[0];
161
+ created = await createCalendarFeed(context.logId, {
162
+ label: label.trim() || "Calendar feed",
163
+ sources: chosen.length === allKeys.length ? null : chosen,
164
+ windowDays: preset.windowDays,
165
+ });
166
+ feeds = [created.feed, ...feeds];
167
+ view = "created";
168
+ notify.success("Calendar feed created");
169
+ } catch (cause) {
170
+ error = messageOf(cause);
171
+ notify.error(error);
172
+ } finally {
173
+ busy = false;
174
+ }
175
+ }
176
+
177
+ async function revoke(): Promise<void> {
178
+ const feedId = revokeId;
179
+ if (!feedId || busy) return;
180
+ revokeId = null;
181
+ busy = true;
182
+ try {
183
+ const context = await requireActiveContext();
184
+ await revokeCalendarFeed(context.logId, feedId);
185
+ feeds = feeds.filter((feed) => feed.feedId !== feedId);
186
+ notify.success("Calendar feed revoked");
187
+ } catch (cause) {
188
+ notify.error(messageOf(cause));
189
+ } finally {
190
+ busy = false;
191
+ }
192
+ }
193
+
194
+ async function copyUrl(url: string): Promise<void> {
195
+ try {
196
+ await navigator.clipboard.writeText(url);
197
+ copied = true;
198
+ if (copyTimer) clearTimeout(copyTimer);
199
+ copyTimer = setTimeout(() => (copied = false), 2000);
200
+ } catch {
201
+ notify.error("Could not copy the URL");
202
+ }
203
+ }
204
+
205
+ function entrySelected(entry: CalendarMenuEntry): boolean {
206
+ return entry.sourceKeys.every((key) => selectedSources.has(key));
207
+ }
208
+
209
+ function toggleSource(entry: CalendarMenuEntry, checked: boolean): void {
210
+ const next = new Set(selectedSources);
211
+ for (const key of entry.sourceKeys) {
212
+ if (checked) next.add(key);
213
+ else next.delete(key);
214
+ }
215
+ selectedSources = next;
216
+ }
217
+
218
+ function selectAllSources(): void {
219
+ selectedSources = new Set(sources.map((source) => source.key));
220
+ }
221
+
222
+ function clearSources(): void {
223
+ selectedSources = new Set();
224
+ }
225
+
226
+ function feedScope(feed: CalendarFeedSummary): string {
227
+ if (!feed.sources) return "All sources";
228
+ const count = feed.sources.length;
229
+ return `${count} ${count === 1 ? "source" : "sources"}`;
230
+ }
231
+
232
+ function feedUsage(feed: CalendarFeedSummary): string {
233
+ const createdLabel = `created ${formatDistanceToNow(new Date(feed.createdAt), { addSuffix: true })}`;
234
+ const usedLabel = feed.lastAccessedAt
235
+ ? `used ${formatDistanceToNow(new Date(feed.lastAccessedAt), { addSuffix: true })}`
236
+ : "not used yet";
237
+ return `${createdLabel} · ${usedLabel}`;
238
+ }
239
+
240
+ function messageOf(cause: unknown): string {
241
+ return cause instanceof Error ? cause.message : "The request failed";
242
+ }
243
+ </script>
244
+
245
+ <Drawer
246
+ show={show}
247
+ {title}
248
+ {description}
249
+ onClose={() => (show = false)}
250
+ >
251
+ {#if view === "list"}
252
+ <div class="space-y-4">
253
+ {#if error}
254
+ <div class="flex flex-wrap items-center gap-3 rounded-xl bg-[var(--stor-negative-soft)] px-3 py-2">
255
+ <p class="min-w-0 flex-1 text-sm text-[var(--stor-negative)]" role="alert">{error}</p>
256
+ <Button variant="secondary" size="sm" onclick={() => void load()}>Retry</Button>
257
+ </div>
258
+ {/if}
259
+
260
+ {#if loading && feeds.length === 0}
261
+ <DataPlaceholder rows={3} label="Loading calendar feeds" />
262
+ {:else if feeds.length === 0}
263
+ <EmptyState
264
+ surface={false}
265
+ icon={IconRss}
266
+ title="No calendar feeds"
267
+ description="Subscribe to this log from your calendar app."
268
+ >
269
+ <Button onclick={() => (view = "create")}><IconPlus class="h-4 w-4" /> New feed</Button>
270
+ </EmptyState>
271
+ {:else}
272
+ <ul class="divide-y divide-[var(--stor-border)]">
273
+ {#each feeds as feed (feed.feedId)}
274
+ <li class="flex items-center gap-3 py-3">
275
+ <div class="min-w-0 flex-1">
276
+ <p class="truncate text-sm font-semibold text-[var(--stor-ink)]">{feed.label}</p>
277
+ <p class="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--stor-muted-text)]">
278
+ <Badge>{feedScope(feed)}</Badge>
279
+ <span>{feedUsage(feed)}</span>
280
+ </p>
281
+ </div>
282
+ <Button
283
+ variant="ghost"
284
+ size="icon"
285
+ aria-label={`Revoke ${feed.label}`}
286
+ disabled={busy}
287
+ onclick={() => (revokeId = feed.feedId)}
288
+ ><IconLinkOff class="h-4 w-4" /></Button>
289
+ </li>
290
+ {/each}
291
+ </ul>
292
+ {/if}
293
+ </div>
294
+ {:else if view === "create"}
295
+ <form
296
+ id={formId}
297
+ class="space-y-5"
298
+ onsubmit={(event) => {
299
+ event.preventDefault();
300
+ void create();
301
+ }}
302
+ >
303
+ <button
304
+ type="button"
305
+ class="stor-section-link"
306
+ onclick={() => (view = "list")}
307
+ ><IconArrowLeft class="h-4 w-4" /> Back to feeds</button>
308
+
309
+ {#if error}
310
+ <p class="rounded-xl bg-[var(--stor-negative-soft)] px-3 py-2 text-sm text-[var(--stor-negative)]" role="alert">
311
+ {error}
312
+ </p>
313
+ {/if}
314
+
315
+ <Field label="Label" htmlFor="calendar-feed-label">
316
+ <Input id="calendar-feed-label" bind:value={label} placeholder="Calendar feed" />
317
+ </Field>
318
+
319
+ <Field label="Publish window" htmlFor="calendar-feed-window">
320
+ <Select
321
+ id="calendar-feed-window"
322
+ value={windowPreset}
323
+ options={WINDOW_PRESETS.map((preset) => ({ value: preset.value, label: preset.label }))}
324
+ onValueChange={(value) => (windowPreset = value)}
325
+ />
326
+ </Field>
327
+
328
+ <section class="space-y-2" aria-labelledby="calendar-feed-sources-heading">
329
+ <div class="flex items-center justify-between">
330
+ <h3 id="calendar-feed-sources-heading" class="text-xs font-semibold uppercase tracking-wide text-[var(--stor-muted-text)]">Sources</h3>
331
+ <button
332
+ type="button"
333
+ class="text-xs font-semibold text-[var(--stor-accent-text)] hover:underline"
334
+ onclick={allSelected ? clearSources : selectAllSources}
335
+ >{allSelected ? "Clear all" : "Select all"}</button>
336
+ </div>
337
+ <div class="grid gap-1 sm:grid-cols-2">
338
+ {#each sourceEntries as entry (entry.id)}
339
+ {@const Icon = calendarSourceIcon(entry.icon)}
340
+ <label class="flex min-h-11 items-center gap-2.5 rounded-xl px-2 text-sm text-[var(--stor-ink)] transition-colors hover:bg-[var(--stor-surface-muted)] sm:min-h-9">
341
+ <Checkbox
342
+ checked={entrySelected(entry)}
343
+ onCheckedChange={(checked) => toggleSource(entry, Boolean(checked))}
344
+ aria-label={entry.label}
345
+ />
346
+ <Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
347
+ <span class="truncate">{entry.label}</span>
348
+ </label>
349
+ {/each}
350
+ </div>
351
+ {#if !canCreate}
352
+ <p class="text-xs text-[var(--stor-negative)]" role="alert">Choose at least one source.</p>
353
+ {/if}
354
+ </section>
355
+ </form>
356
+ {:else if created}
357
+ {@const feedUrl = created.url}
358
+ <div class="space-y-5">
359
+ <div class="flex items-center gap-3" role="status">
360
+ <span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--stor-positive-soft)] text-[var(--stor-positive)]">
361
+ <IconCheckCircle class="h-5 w-5" />
362
+ </span>
363
+ <div class="min-w-0">
364
+ <h3 class="text-base font-semibold text-[var(--stor-ink)]">Feed created</h3>
365
+ <p class="mt-0.5 text-sm text-[var(--stor-muted-text)]">Copy the URL now — it is not shown again.</p>
366
+ </div>
367
+ </div>
368
+
369
+ <div class="flex gap-2">
370
+ <Input
371
+ readonly
372
+ aria-label="Feed URL"
373
+ value={feedUrl}
374
+ class="font-mono text-xs"
375
+ />
376
+ <Button
377
+ variant={copied ? "secondary" : "default"}
378
+ aria-label={copied ? "Copied" : "Copy feed URL"}
379
+ onclick={() => copyUrl(feedUrl)}
380
+ >
381
+ {#if copied}<IconCheck class="h-4 w-4" /> Copied{:else}<IconContentCopy class="h-4 w-4" /> Copy{/if}
382
+ </Button>
383
+ </div>
384
+
385
+ <a class="stor-section-link" href={webcalUrl(feedUrl)}>Add to Apple Calendar</a>
386
+ </div>
387
+ {/if}
388
+
389
+ {#snippet footer()}
390
+ {#if view === "list"}
391
+ <Button variant="secondary" onclick={() => (show = false)}>Close</Button>
392
+ {#if feeds.length > 0}
393
+ <Button onclick={() => (view = "create")}><IconPlus class="h-4 w-4" /> New feed</Button>
394
+ {/if}
395
+ {:else if view === "create"}
396
+ <Button variant="secondary" onclick={() => (view = "list")}>Cancel</Button>
397
+ <Button type="submit" form={formId} disabled={busy || !canCreate}>
398
+ {busy ? "Creating…" : "Create feed"}
399
+ </Button>
400
+ {:else}
401
+ <Button onclick={() => (view = "list")}>Done</Button>
402
+ {/if}
403
+ {/snippet}
404
+ </Drawer>
405
+
406
+ <ConfirmDialog
407
+ show={revokeId !== null}
408
+ title="Revoke feed?"
409
+ message="Calendar clients using this URL stop updating immediately."
410
+ confirmLabel="Revoke"
411
+ tone="danger"
412
+ onConfirm={revoke}
413
+ onCancel={() => (revokeId = null)}
414
+ />
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import { format as formatDay } from "date-fns";
3
+ import DataPlaceholder from "@hearthlog/ui/components/data/DataPlaceholder.svelte";
4
+ import { calendarSourcePresentation } from "@hearthlog/contracts/calendar/sources";
5
+ import { summarizeWorkingMonth } from "@hearthlog/czech";
6
+ import type { CalendarSourceCounts } from "@hearthlog/contracts/calendar/types";
7
+ import { calendarSourceIcon } from "./source-icons.js";
8
+ import { formatNumber } from "@hearthlog/ui/utils/number-format.js";
9
+ import { cn } from "@hearthlog/ui/utils/cn.js";
10
+
11
+ let {
12
+ year,
13
+ monthIndex,
14
+ today,
15
+ counts,
16
+ sourceLabel,
17
+ loading = false,
18
+ class: className = "",
19
+ }: {
20
+ year: number;
21
+ /** 0-indexed, like `Date#getMonth`. */
22
+ monthIndex: number;
23
+ today: string;
24
+ counts: CalendarSourceCounts;
25
+ sourceLabel: (sourceKey: string) => string;
26
+ loading?: boolean;
27
+ class?: string;
28
+ } = $props();
29
+
30
+ const summary = $derived(summarizeWorkingMonth(year, monthIndex, today));
31
+ const previousMonthName = $derived(formatDay(new Date(year, monthIndex - 1, 1), "MMMM"));
32
+ const comparison = $derived.by(() => {
33
+ const difference = summary.workingDays - summary.previousWorkingDays;
34
+ if (difference === 0) return `same as ${previousMonthName}`;
35
+ return `${Math.abs(difference)} ${difference > 0 ? "more" : "fewer"} than ${previousMonthName}`;
36
+ });
37
+ const progressPercent = $derived(
38
+ summary.progress && summary.workingDays > 0
39
+ ? (summary.progress.passed / summary.workingDays) * 100
40
+ : 0,
41
+ );
42
+
43
+ function holidayDate(date: string): string {
44
+ return formatDay(new Date(`${date}T12:00:00`), "EEE d MMM");
45
+ }
46
+ </script>
47
+
48
+ <section class={cn("stor-panel p-[var(--stor-panel-padding)]", className)} aria-label="Month summary">
49
+ <div class="stor-metric">
50
+ <p class="stor-metric-label">Working days</p>
51
+ <p class="stor-metric-value tabular-nums" data-testid="calendar-working-days">{formatNumber(summary.workingDays)}</p>
52
+ <p class="stor-metric-support">{formatNumber(summary.workingHours)} hours · {comparison}</p>
53
+ </div>
54
+
55
+ {#if summary.progress}
56
+ <div class="mt-4">
57
+ <div
58
+ class="h-1.5 overflow-hidden rounded-full bg-[var(--stor-surface-muted)]"
59
+ role="progressbar"
60
+ aria-label="Working days passed"
61
+ aria-valuemin={0}
62
+ aria-valuemax={summary.workingDays}
63
+ aria-valuenow={summary.progress.passed}
64
+ aria-valuetext={`${summary.progress.passed} of ${summary.workingDays} passed, ${summary.progress.left} left`}
65
+ >
66
+ <div class="h-full rounded-full bg-[var(--stor-action)]" style="width: {progressPercent}%"></div>
67
+ </div>
68
+ <p class="mt-1.5 flex justify-between gap-3 text-xs tabular-nums text-[var(--stor-muted-text)]" aria-hidden="true">
69
+ <span>{formatNumber(summary.progress.passed)} passed</span>
70
+ <span class="font-semibold text-[var(--stor-ink)]">{formatNumber(summary.progress.left)} left</span>
71
+ </p>
72
+ </div>
73
+ {/if}
74
+
75
+ <dl class="stor-inset-rows mt-3 text-sm">
76
+ <div class="flex items-baseline justify-between gap-3 py-2">
77
+ <dt class="text-[var(--stor-muted-text)]">Weekends and holidays</dt>
78
+ <dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{formatNumber(summary.daysOff)}</dd>
79
+ </div>
80
+ <div class="flex items-baseline justify-between gap-3 py-2">
81
+ <dt class="text-[var(--stor-muted-text)]">Working days in Q{summary.quarter.number}</dt>
82
+ <dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{formatNumber(summary.quarter.workingDays)}</dd>
83
+ </div>
84
+ <div class="flex items-baseline justify-between gap-3 py-2">
85
+ <dt class="text-[var(--stor-muted-text)]">Working days in {year}</dt>
86
+ <dd class="font-semibold tabular-nums text-[var(--stor-ink)]">{formatNumber(summary.yearWorkingDays)}</dd>
87
+ </div>
88
+ </dl>
89
+
90
+ {#if summary.holidays.length > 0}
91
+ <h2 class="mt-4 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Holidays</h2>
92
+ <ul class="stor-inset-rows mt-1 text-sm">
93
+ {#each summary.holidays as holiday (holiday.date)}
94
+ <li class="flex items-baseline justify-between gap-3 py-2 {holiday.isWeekend ? 'text-[var(--stor-muted-text)]' : 'text-[var(--stor-ink)]'}">
95
+ <span class="min-w-0">{holiday.name}</span>
96
+ <span class="shrink-0 tabular-nums text-[var(--stor-muted-text)]">{holidayDate(holiday.date)}{#if holiday.isWeekend}<span class="sr-only">, on a weekend</span>{/if}</span>
97
+ </li>
98
+ {/each}
99
+ </ul>
100
+ {/if}
101
+
102
+ <div class="mt-4 flex items-baseline justify-between gap-3">
103
+ <h2 class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Scheduled</h2>
104
+ {#if !loading && counts.total > 0}<span class="text-sm font-semibold tabular-nums text-[var(--stor-ink)]">{formatNumber(counts.total)}</span>{/if}
105
+ </div>
106
+ {#if loading}
107
+ <DataPlaceholder rows={2} label="Loading scheduled items" />
108
+ {:else if counts.total === 0}
109
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">Nothing scheduled</p>
110
+ {:else}
111
+ <ul class="stor-inset-rows mt-1 text-sm" style="--stor-row-inset: 1.75rem">
112
+ {#each counts.sources as entry (entry.sourceKey)}
113
+ {@const Icon = calendarSourceIcon(calendarSourcePresentation(entry.sourceKey).icon)}
114
+ <li class="flex items-center gap-3 py-2">
115
+ <Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
116
+ <span class="min-w-0 flex-1 truncate text-[var(--stor-ink)]">{sourceLabel(entry.sourceKey)}</span>
117
+ <span class="font-semibold tabular-nums text-[var(--stor-ink)]">{formatNumber(entry.count)}</span>
118
+ </li>
119
+ {/each}
120
+ </ul>
121
+ {/if}
122
+ </section>
@@ -0,0 +1,27 @@
1
+ import { BucketStore } from '@hearthlog/ui/components/endless/bucket-store.svelte.js';
2
+ import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
3
+ import type { CalendarSourcePreference } from '@hearthlog/runtime/calendar/groups.js';
4
+ import type { CalendarWeek } from '@hearthlog/contracts/calendar/types';
5
+ import { weekChunks } from '@hearthlog/runtime/calendar/weeks.js';
6
+
7
+ /**
8
+ * Loaded weeks of the endless calendar. Weeks are read in whole chunks, each
9
+ * batch one SQL session under the source preference in force when it starts.
10
+ */
11
+ export class CalendarWeeks extends BucketStore<CalendarWeek> {
12
+ constructor(preference: () => CalendarSourcePreference | null) {
13
+ super(async (keys) => {
14
+ const current = preference();
15
+ const weeks = await queryDatabase((repositories) => repositories.calendar.getCalendarWeeks(keys, current));
16
+ return new Map(weeks.map((week) => [week.start, week]));
17
+ }, '[Calendar] Could not load weeks');
18
+ }
19
+
20
+ ensure(keys: string[]): void {
21
+ super.ensure(weekChunks(keys));
22
+ }
23
+
24
+ refresh(keysInView: string[]): void {
25
+ super.refresh(weekChunks(keysInView));
26
+ }
27
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Maps the string icon names from the calendar presentation registry to
3
+ * `~icons` components. Keeping this mapping here lets `sources.ts` stay free
4
+ * of Svelte imports (the frontend Worker imports it for ICS links).
5
+ */
6
+
7
+ import type { Component } from 'svelte';
8
+ import IconCalendarBlank from '~icons/mdi/calendar-blank-outline';
9
+ import { MODULE_CALENDAR_ICONS } from '#generated/module-icons';
10
+
11
+ const ICONS: Record<string, Component> = {
12
+ 'calendar-blank-outline': IconCalendarBlank,
13
+ // Icons modules use on their calendar sources (their manifests list them).
14
+ ...MODULE_CALENDAR_ICONS,
15
+ };
16
+
17
+ export function calendarSourceIcon(name: string): Component {
18
+ return ICONS[name] ?? IconCalendarBlank;
19
+ }
@@ -0,0 +1,14 @@
1
+ <script lang="ts">
2
+ import type { RawSqliteValuePresentation } from "@hearthlog/runtime/db/raw-browser.js";
3
+
4
+ let { presented }: { presented: RawSqliteValuePresentation } = $props();
5
+ </script>
6
+
7
+ <span
8
+ class:italic={presented.kind === "null"}
9
+ class:text-[var(--stor-muted-text)]={presented.kind === "null"}
10
+ class:text-violet-700={presented.kind === "blob"}
11
+ class="whitespace-pre-wrap break-words"
12
+ >
13
+ {presented.text}
14
+ </span>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import CommandOnly from "@hearthlog/ui/components/commands/CommandOnly.svelte";
3
+ import IconPencil from "~icons/mdi/pencil-outline";
4
+ import IconTrash from "~icons/mdi/trash-can-outline";
5
+
6
+ let {
7
+ onEdit,
8
+ onDelete,
9
+ }: {
10
+ onEdit: () => void;
11
+ onDelete: () => void;
12
+ } = $props();
13
+ </script>
14
+
15
+ <div class="flex items-center justify-end gap-1">
16
+ <CommandOnly>
17
+ <button
18
+ type="button"
19
+ onclick={onEdit}
20
+ class="rounded-lg p-2 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
21
+ title="Edit"
22
+ aria-label="Edit"
23
+ >
24
+ <IconPencil class="h-4 w-4" />
25
+ </button>
26
+ <button
27
+ type="button"
28
+ onclick={onDelete}
29
+ class="rounded-lg p-2 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-negative-soft)] hover:text-[var(--stor-negative)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
30
+ title="Delete"
31
+ aria-label="Delete"
32
+ >
33
+ <IconTrash class="h-4 w-4" />
34
+ </button>
35
+ </CommandOnly>
36
+ </div>