@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,49 @@
1
+ <script lang="ts">
2
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
3
+ import { importPendingWork } from "@hearthlog/runtime/store/pending-work-transfer.js";
4
+ import { parsePendingWorkFile, type ImportReport } from "@hearthlog/runtime/store/pending-work.js";
5
+
6
+ let busy = $state(false);
7
+ let error = $state<string | null>(null);
8
+ let report = $state<ImportReport | null>(null);
9
+ let input = $state<HTMLInputElement | null>(null);
10
+
11
+ async function run(): Promise<void> {
12
+ const file = input?.files?.[0];
13
+ if (!file) return;
14
+ busy = true;
15
+ error = null;
16
+ report = null;
17
+ try {
18
+ report = await importPendingWork(parsePendingWorkFile(await file.text()));
19
+ } catch (cause) {
20
+ error = cause instanceof Error ? cause.message : "Could not read the file";
21
+ } finally {
22
+ busy = false;
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <div class="mt-4 border-t border-[var(--stor-border)] pt-4">
28
+ <h3 class="text-sm font-semibold text-[var(--stor-ink)]">Pending changes from a moved workspace</h3>
29
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">Send the changes a device exported before the move. Ones already accepted are recognized.</p>
30
+ <div class="mt-3 flex flex-wrap items-center gap-2">
31
+ <input bind:this={input} type="file" accept="application/json,.json" class="text-sm" disabled={busy} data-testid="pending-work-file" />
32
+ <Button variant="secondary" onclick={run} disabled={busy}>{busy ? "Sending" : "Send changes"}</Button>
33
+ </div>
34
+ {#if error}
35
+ <p class="mt-3 text-sm text-[var(--stor-negative)]" role="alert">{error}</p>
36
+ {/if}
37
+ {#if report}
38
+ <div class="mt-3 text-sm" role="status">
39
+ <p class="text-[var(--stor-ink)]">{report.applied} applied, {report.notApplied} not applied.</p>
40
+ {#if report.notApplied > 0}
41
+ <ul class="mt-1 list-disc pl-5 text-[var(--stor-muted-text)]">
42
+ {#each report.results.filter((result) => result.outcome !== "applied") as result (result.requestId)}
43
+ <li>{result.message}</li>
44
+ {/each}
45
+ </ul>
46
+ {/if}
47
+ </div>
48
+ {/if}
49
+ </div>
@@ -0,0 +1,262 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { page } from "$app/state";
4
+ import { workspaceReadiness } from "@hearthlog/ui/state/readiness.js";
5
+ import { currentPageResource } from "@hearthlog/ui/state/page-context.js";
6
+ import {
7
+ clearPerfEntries,
8
+ formatDuration,
9
+ getBaselineMetricLabel,
10
+ getBaselineMetricDescription,
11
+ measurementMetadata,
12
+ getPerfEntries,
13
+ subscribeToPerfEntries,
14
+ summarizeBaselineMetrics,
15
+ type PerfEntry,
16
+ type PerfBaselineMetric,
17
+ } from "@hearthlog/runtime/utils/perf.js";
18
+ import IconDeleteOutline from "~icons/mdi/delete-outline";
19
+ import IconDownload from "~icons/mdi/download";
20
+ import IconRefresh from "~icons/mdi/refresh";
21
+
22
+ let entries = $state<PerfEntry[]>([]);
23
+ const documentId = measurementMetadata().documentId as string;
24
+ const resource = currentPageResource();
25
+ const launchMetrics: PerfBaselineMetric[] = ['firstContentfulPaint', 'shellInteractive', 'dataReady', 'firstSyncComplete'];
26
+ const buildEntries = $derived(entries.filter(entry => entry.metadata?.build === __BUILD_TIMESTAMP__));
27
+ const currentEntries = $derived(buildEntries.filter(entry => entry.metadata?.documentId === documentId));
28
+ const pageEntry = $derived(currentEntries.find(entry => entry.baselineMetric === 'pageContentReady' && entry.metadata?.loadId === resource?.().measurement?.id));
29
+ const pageFailed = $derived(pageEntry?.metadata?.outcome === 'failed' || resource?.().error);
30
+ let summaries = $derived(summarizeBaselineMetrics(buildEntries));
31
+ let activeSummaries = $derived(
32
+ summaries.filter((summary) => summary.sampleCount > 0),
33
+ );
34
+ let idleMetricCount = $derived(summaries.length - activeSummaries.length);
35
+ let recentEntries = $derived(
36
+ buildEntries
37
+ .filter((entry) => entry.baselineMetric)
38
+ .sort((left, right) => right.timestamp - left.timestamp)
39
+ .slice(0, 8),
40
+ );
41
+
42
+ function refreshEntries() {
43
+ entries = getPerfEntries();
44
+ }
45
+
46
+ function exportEntries() {
47
+ const payload = JSON.stringify(
48
+ {
49
+ exportedAt: new Date().toISOString(),
50
+ entries,
51
+ },
52
+ null,
53
+ 2,
54
+ );
55
+ const blob = new Blob([payload], { type: "application/json" });
56
+ const url = URL.createObjectURL(blob);
57
+ const link = document.createElement("a");
58
+ link.href = url;
59
+ link.download = "performance-baseline.json";
60
+ document.body.appendChild(link);
61
+ link.click();
62
+ document.body.removeChild(link);
63
+ URL.revokeObjectURL(url);
64
+ }
65
+
66
+ onMount(() => {
67
+ refreshEntries();
68
+ return subscribeToPerfEntries((nextEntries) => {
69
+ entries = nextEntries;
70
+ });
71
+ });
72
+ </script>
73
+
74
+ <div class="stor-panel min-w-0 p-[var(--stor-panel-padding)]">
75
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
76
+ <div>
77
+ <h2 class="text-base font-semibold text-stone-900">
78
+ Performance
79
+ </h2>
80
+ {#if idleMetricCount > 0}
81
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">
82
+ {idleMetricCount} idle metric{idleMetricCount === 1 ? "" : "s"}
83
+ </p>
84
+ {/if}
85
+ </div>
86
+
87
+ <div class="flex flex-wrap gap-2">
88
+ <button
89
+ type="button"
90
+ class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200"
91
+ onclick={refreshEntries}
92
+ >
93
+ <IconRefresh class="h-3.5 w-3.5" />
94
+ Refresh
95
+ </button>
96
+ <button
97
+ type="button"
98
+ class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200 disabled:opacity-50"
99
+ onclick={exportEntries}
100
+ disabled={entries.length === 0}
101
+ >
102
+ <IconDownload class="h-3.5 w-3.5" />
103
+ Export
104
+ </button>
105
+ <button
106
+ type="button"
107
+ class="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50"
108
+ onclick={() => {
109
+ clearPerfEntries();
110
+ refreshEntries();
111
+ }}
112
+ disabled={entries.length === 0}
113
+ >
114
+ <IconDeleteOutline class="h-3.5 w-3.5" />
115
+ Clear
116
+ </button>
117
+ </div>
118
+ </div>
119
+
120
+ <section class="mt-4 rounded-lg bg-[var(--stor-surface-muted)] p-3" aria-label="Current page performance">
121
+ <h3 class="text-sm font-semibold text-[var(--stor-ink)]">Current page</h3>
122
+ <p class="mt-1 break-all text-xs text-[var(--stor-muted-text)]">{page.url.pathname}{page.url.search}</p>
123
+ <dl class="mt-3">
124
+ <div class="flex justify-between gap-3 text-xs">
125
+ <dt class="font-medium text-[var(--stor-ink)]">{getBaselineMetricLabel('pageContentReady')}</dt>
126
+ <dd class="shrink-0 tabular-nums text-[var(--stor-muted-text)]">{pageFailed ? 'Failed' : pageEntry ? formatDuration(pageEntry.durationMs) : resource?.().loading ? 'Pending' : 'Not recorded'}</dd>
127
+ </div>
128
+ </dl>
129
+ <p class="mt-1 text-xs text-[var(--stor-muted-text)]">
130
+ {#if pageEntry?.metadata?.kind === 'startup'}
131
+ From opening the app, including local workspace setup.
132
+ {:else if pageEntry?.metadata?.kind === 'navigation'}
133
+ From navigating to this page until its required data has rendered.
134
+ {:else if pageEntry?.metadata?.kind === 'refresh'}
135
+ Background refresh; previously loaded content stays visible.
136
+ {:else if resource?.().loading}
137
+ Waiting for this page's required data to render.
138
+ {:else}
139
+ No retained sample for this page. Navigate or reload to record one.
140
+ {/if}
141
+ </p>
142
+ </section>
143
+
144
+ <section class="mt-4 rounded-lg bg-[var(--stor-surface-muted)] p-3" aria-label="App launch performance">
145
+ <h3 class="text-sm font-semibold text-[var(--stor-ink)]">App launch</h3>
146
+ <p class="mt-1 text-xs text-[var(--stor-muted-text)]">Elapsed since opening the app. These milestones overlap; do not add them together.</p>
147
+ <dl class="mt-3 space-y-3">
148
+ {#each launchMetrics as metric}
149
+ {@const entry = currentEntries.find(entry => entry.baselineMetric === metric)}
150
+ {@const failed = entry?.metadata?.outcome === 'failed' || (metric === 'dataReady' && $workspaceReadiness.error)}
151
+ <div>
152
+ <div class="flex justify-between gap-3 text-xs">
153
+ <dt class="font-medium text-stone-700">{getBaselineMetricLabel(metric)}</dt>
154
+ <dd class="shrink-0 tabular-nums text-stone-600">{failed ? 'Failed' : entry?.metadata?.outcome === 'offline' ? 'Offline' : entry?.metadata?.outcome === 'skipped' ? 'Skipped' : entry ? formatDuration(entry.durationMs) : 'Pending'}</dd>
155
+ </div>
156
+ <p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">{getBaselineMetricDescription(metric)}</p>
157
+ {#if entry}<p class="mt-0.5 break-all text-[11px] text-[var(--stor-muted-text)]">{entry.metadata?.kind} · {entry.metadata?.route}</p>{/if}
158
+ </div>
159
+ {/each}
160
+ </dl>
161
+ </section>
162
+
163
+ <h3 class="mt-4 text-sm font-semibold text-[var(--stor-ink)]">History for this build</h3>
164
+ {#if activeSummaries.length > 0}
165
+ <div class="mt-4 overflow-hidden rounded-lg bg-[var(--stor-surface-muted)]">
166
+ {#each activeSummaries as summary}
167
+ <section class="grid gap-3 border-b border-[var(--stor-border)] p-3 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto_auto_auto] sm:items-center">
168
+ <div class="min-w-0">
169
+ <h3 class="truncate text-sm font-semibold text-stone-900">
170
+ {summary.label}
171
+ </h3>
172
+ <p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">
173
+ {summary.group}
174
+ </p>
175
+ <p class="mt-1 text-xs text-[var(--stor-muted-text)]">{summary.description}</p>
176
+ <p class="mt-1 text-xs text-[var(--stor-muted-text)]">
177
+ {summary.sampleCount} sample{summary.sampleCount === 1
178
+ ? ""
179
+ : "s"}
180
+ </p>
181
+ </div>
182
+ <div>
183
+ <div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
184
+ Latest
185
+ </div>
186
+ <div class="text-sm font-semibold text-stone-900">
187
+ {summary.latestMs !== null
188
+ ? formatDuration(summary.latestMs)
189
+ : "—"}
190
+ </div>
191
+ </div>
192
+ <div>
193
+ <div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
194
+ Avg
195
+ </div>
196
+ <div class="text-sm font-semibold text-stone-900">
197
+ {summary.averageMs !== null
198
+ ? formatDuration(summary.averageMs)
199
+ : "—"}
200
+ </div>
201
+ </div>
202
+ <div>
203
+ <div class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">
204
+ Slow
205
+ </div>
206
+ <div class="text-sm font-semibold text-stone-900">
207
+ {summary.slowCount}
208
+ </div>
209
+ </div>
210
+ </section>
211
+ {/each}
212
+ </div>
213
+ {:else}
214
+ <div
215
+ class="mt-4 rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-6 text-center"
216
+ >
217
+ <p class="text-sm text-[var(--stor-muted-text)]">No performance samples yet.</p>
218
+ </div>
219
+ {/if}
220
+
221
+ <div class="mt-4">
222
+ <div class="mb-2 flex items-center justify-between gap-3">
223
+ <h3 class="text-sm font-semibold text-stone-900">Recent samples</h3>
224
+ <span class="text-xs text-[var(--stor-muted-text)]">{recentEntries.length}</span>
225
+ </div>
226
+
227
+ {#if recentEntries.length > 0}
228
+ <div class="overflow-hidden rounded-lg bg-[var(--stor-surface-muted)]">
229
+ {#each recentEntries as entry}
230
+ <div class="grid gap-1 border-b border-[var(--stor-border)] px-3 py-2 text-xs last:border-b-0 sm:grid-cols-[minmax(0,1fr)_7rem_10rem] sm:items-center">
231
+ <div class="min-w-0">
232
+ <div class="truncate font-medium text-stone-900">
233
+ {entry.baselineMetric
234
+ ? getBaselineMetricLabel(
235
+ entry.baselineMetric,
236
+ )
237
+ : entry.operation}
238
+ </div>
239
+ <div class="truncate text-[10.5px] text-[var(--stor-muted-text)]">
240
+ {entry.metadata?.route ?? 'Legacy sample'} · {entry.metadata?.kind ?? entry.category} · {entry.metadata?.outcome ?? 'success'}
241
+ </div>
242
+ </div>
243
+ <div class="font-semibold text-stone-900">
244
+ {formatDuration(entry.durationMs)}
245
+ </div>
246
+ <div class="text-[var(--stor-muted-text)]">
247
+ {new Date(entry.timestamp).toLocaleTimeString()}
248
+ </div>
249
+ </div>
250
+ {/each}
251
+ </div>
252
+ {:else}
253
+ <div
254
+ class="rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-8 text-center"
255
+ >
256
+ <p class="text-sm text-[var(--stor-muted-text)]">
257
+ No samples yet.
258
+ </p>
259
+ </div>
260
+ {/if}
261
+ </div>
262
+ </div>
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { tenant } from '@hearthlog/contracts/tenant/config';
4
+ import { formatSqliteBytes, type LocalSqliteStats } from '@hearthlog/runtime/db/local-statistics.js';
5
+ import IconLayersOutline from '~icons/mdi/layers-outline';
6
+
7
+ let { stats }: { stats: LocalSqliteStats | null } = $props();
8
+
9
+ const stack = __STACK_INFO__;
10
+ const mode = import.meta.env.VITE_E2E === '1' ? 'e2e' : import.meta.env.DEV ? 'development' : 'production';
11
+ const buildDate = new Date(__BUILD_TIMESTAMP__).toLocaleString('en-US');
12
+ const ledgerUrl = import.meta.env.VITE_LEDGER_SERVICE_URL || 'same origin';
13
+
14
+ let opfs = $state('checking');
15
+ let quota = $state('unknown');
16
+ let serviceWorker = $state('checking');
17
+ let online = $state(true);
18
+ const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
19
+
20
+ onMount(() => {
21
+ online = navigator.onLine;
22
+ const sync = () => (online = navigator.onLine);
23
+ window.addEventListener('online', sync);
24
+ window.addEventListener('offline', sync);
25
+
26
+ navigator.storage
27
+ ?.getDirectory?.()
28
+ .then(() => (opfs = 'available'))
29
+ .catch(() => (opfs = 'unavailable'));
30
+ if (!navigator.storage?.getDirectory) opfs = 'unavailable';
31
+ navigator.storage
32
+ ?.estimate?.()
33
+ .then(({ usage = 0, quota: limit = 0 }) => {
34
+ quota = `${formatSqliteBytes(usage)} of ${formatSqliteBytes(limit)}`;
35
+ })
36
+ .catch(() => {});
37
+ if ('serviceWorker' in navigator) {
38
+ navigator.serviceWorker
39
+ .getRegistration()
40
+ .then((registration) => {
41
+ serviceWorker = registration?.active ? 'active' : registration ? 'installing' : 'none';
42
+ })
43
+ .catch(() => (serviceWorker = 'unavailable'));
44
+ } else {
45
+ serviceWorker = 'unsupported';
46
+ }
47
+
48
+ return () => {
49
+ window.removeEventListener('online', sync);
50
+ window.removeEventListener('offline', sync);
51
+ };
52
+ });
53
+
54
+ const frameworks = $derived([
55
+ ['Svelte', stack.packages.svelte],
56
+ ['SvelteKit', stack.packages['@sveltejs/kit']],
57
+ ['Vite', stack.packages.vite],
58
+ ['TypeScript', stack.packages.typescript],
59
+ ['Tailwind CSS', stack.packages.tailwindcss],
60
+ ['Bits UI', stack.packages['bits-ui']],
61
+ ['TanStack Table', stack.packages['@tanstack/svelte-table']],
62
+ ['TanStack Virtual', stack.packages['@tanstack/svelte-virtual']],
63
+ ['Kysely', stack.packages.kysely],
64
+ ['SQLocal', stack.packages.sqlocal],
65
+ ['date-fns', stack.packages['date-fns']],
66
+ ['Node (build)', stack.node],
67
+ ]);
68
+ const runtime = $derived([
69
+ ['SQLite', stats?.sqliteVersion ?? '…'],
70
+ ['Schema version', stats ? String(stats.schemaVersion) : '…'],
71
+ ['OPFS', opfs],
72
+ ['Storage used', quota],
73
+ ['Service worker', serviceWorker],
74
+ ['Cross-origin isolated', isolated ? 'yes' : 'no'],
75
+ ['Network', online ? 'online' : 'offline'],
76
+ ]);
77
+ const build = $derived([
78
+ ['Commit', stack.commit ? `${stack.commit}${stack.dirty ? ' (dirty)' : ''}` : 'unknown'],
79
+ ['Branch', stack.branch || 'unknown'],
80
+ ['Built', buildDate],
81
+ ['Mode', mode],
82
+ ]);
83
+ const site = $derived([
84
+ ['Tenant', tenant.id],
85
+ ['Ledger app', tenant.ledgerAppName],
86
+ ['Features', String(tenant.features.size)],
87
+ ['Ledger URL', ledgerUrl],
88
+ ]);
89
+ const groups = $derived([
90
+ { title: 'Frameworks', rows: frameworks },
91
+ { title: 'Runtime', rows: runtime },
92
+ { title: 'Build', rows: build },
93
+ { title: 'Site', rows: site },
94
+ ]);
95
+ </script>
96
+
97
+ <section class="stor-panel min-w-0 p-[var(--stor-panel-padding)]" aria-labelledby="stack-info-heading" data-testid="stack-info">
98
+ <div class="flex items-start gap-3">
99
+ <IconLayersOutline class="mt-0.5 h-5 w-5 shrink-0 text-[var(--stor-accent-strong)]" />
100
+ <h2 id="stack-info-heading" class="text-base font-semibold text-[var(--stor-ink)]">Stack</h2>
101
+ </div>
102
+ <div class="mt-4 grid gap-x-8 gap-y-5 sm:grid-cols-2 xl:grid-cols-4">
103
+ {#each groups as group (group.title)}
104
+ <div class="min-w-0">
105
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--stor-muted-text)]">{group.title}</h3>
106
+ <dl class="mt-2 space-y-1.5 text-sm">
107
+ {#each group.rows as [label, value] (label)}
108
+ <div class="flex items-baseline justify-between gap-3">
109
+ <dt class="shrink-0 text-[var(--stor-muted-text)]">{label}</dt>
110
+ <dd class="min-w-0 truncate font-semibold tabular-nums text-[var(--stor-ink)]" title={value}>{value}</dd>
111
+ </div>
112
+ {/each}
113
+ </dl>
114
+ </div>
115
+ {/each}
116
+ </div>
117
+ </section>
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import {
4
+ syncLog,
5
+ clearSyncLog,
6
+ loadSyncLog,
7
+ loadMoreSyncLog,
8
+ type SyncLogType,
9
+ } from "@hearthlog/runtime/store/sync.svelte.js";
10
+ import IconDeleteOutline from "~icons/mdi/delete-outline";
11
+
12
+ const typeConfig: Record<
13
+ SyncLogType,
14
+ { label: string; bgColor: string; textColor: string }
15
+ > = {
16
+ upload: {
17
+ label: "UPLOAD",
18
+ bgColor: "bg-green-100",
19
+ textColor: "text-green-700",
20
+ },
21
+ download: {
22
+ label: "DOWNLOAD",
23
+ bgColor: "bg-blue-100",
24
+ textColor: "text-blue-700",
25
+ },
26
+ ws_receive: {
27
+ label: "WEBSOCKET",
28
+ bgColor: "bg-cyan-100",
29
+ textColor: "text-cyan-700",
30
+ },
31
+ ws_ready: {
32
+ label: "READY",
33
+ bgColor: "bg-emerald-100",
34
+ textColor: "text-emerald-700",
35
+ },
36
+ ws_close: {
37
+ label: "DISCONNECT",
38
+ bgColor: "bg-[var(--stor-surface-muted)]",
39
+ textColor: "text-stone-600",
40
+ },
41
+ ws_error: {
42
+ label: "WS ERROR",
43
+ bgColor: "bg-orange-100",
44
+ textColor: "text-orange-700",
45
+ },
46
+ ws_resync: {
47
+ label: "RESYNC",
48
+ bgColor: "bg-violet-100",
49
+ textColor: "text-violet-700",
50
+ },
51
+ drain: {
52
+ label: "DRAIN",
53
+ bgColor: "bg-amber-100",
54
+ textColor: "text-amber-700",
55
+ },
56
+ error: {
57
+ label: "ERROR",
58
+ bgColor: "bg-red-100",
59
+ textColor: "text-red-700",
60
+ },
61
+ };
62
+
63
+ let expandedIds = $state<Set<string>>(new Set());
64
+ let isLoadingMore = $state(false);
65
+
66
+ onMount(() => {
67
+ loadSyncLog();
68
+ });
69
+
70
+ function toggleExpanded(id: string) {
71
+ const newSet = new Set(expandedIds);
72
+ if (newSet.has(id)) {
73
+ newSet.delete(id);
74
+ } else {
75
+ newSet.add(id);
76
+ }
77
+ expandedIds = newSet;
78
+ }
79
+
80
+ function formatTime(date: Date): string {
81
+ return date.toLocaleTimeString("en-US", {
82
+ hour12: false,
83
+ hour: "2-digit",
84
+ minute: "2-digit",
85
+ second: "2-digit",
86
+ });
87
+ }
88
+
89
+ async function handleLoadMore() {
90
+ isLoadingMore = true;
91
+ try {
92
+ await loadMoreSyncLog();
93
+ } finally {
94
+ isLoadingMore = false;
95
+ }
96
+ }
97
+ </script>
98
+
99
+ <div class="stor-panel p-[var(--stor-panel-padding)]">
100
+ <div class="mb-3 flex items-center justify-between gap-3">
101
+ <h2 class="text-base font-semibold text-stone-900">
102
+ Recent sync
103
+ </h2>
104
+ <div class="flex gap-2 items-center">
105
+ <span class="text-xs text-[var(--stor-muted-text)]">
106
+ {syncLog.entries.length} entries{syncLog.hasMore ? "+" : ""}
107
+ </span>
108
+ {#if syncLog.entries.length > 0}
109
+ <button
110
+ type="button"
111
+ class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--stor-surface-muted)] px-3 py-1.5 text-xs font-semibold text-stone-700 hover:bg-stone-200"
112
+ onclick={clearSyncLog}
113
+ >
114
+ <IconDeleteOutline class="h-3.5 w-3.5" />
115
+ Clear
116
+ </button>
117
+ {/if}
118
+ </div>
119
+ </div>
120
+
121
+ {#if syncLog.entries.length > 0}
122
+ <div
123
+ class="max-h-80 overflow-y-auto rounded-lg bg-[var(--stor-surface-muted)]"
124
+ >
125
+ {#each syncLog.entries as entry (entry.id)}
126
+ {@const config = typeConfig[entry.type]}
127
+ <button
128
+ type="button"
129
+ class="w-full border-b border-[var(--stor-border)] px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-[var(--stor-surface-muted)] focus:outline-none focus:ring-2 focus:ring-inset focus:ring-stone-300"
130
+ onclick={() => entry.details && toggleExpanded(entry.id)}
131
+ aria-expanded={entry.details
132
+ ? expandedIds.has(entry.id)
133
+ : undefined}
134
+ >
135
+ <div class="flex items-center gap-2">
136
+ <span
137
+ class="w-14 shrink-0 text-[10.5px] text-[var(--stor-muted-text)]"
138
+ >
139
+ {formatTime(entry.timestamp)}
140
+ </span>
141
+ <span
142
+ class="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide {config.bgColor} {config.textColor}"
143
+ >
144
+ {config.label}
145
+ </span>
146
+ <span class="text-xs text-stone-700 truncate flex-1">
147
+ {entry.message}
148
+ </span>
149
+ {#if entry.details}
150
+ <span
151
+ class="text-stone-300 text-xs transition-transform"
152
+ class:rotate-90={expandedIds.has(entry.id)}
153
+ >
154
+ ▶
155
+ </span>
156
+ {/if}
157
+ </div>
158
+ {#if entry.details && expandedIds.has(entry.id)}
159
+ <div
160
+ class="mt-2 ml-[4rem] rounded bg-[var(--stor-surface-muted)] px-2 py-1 text-[11px] text-[var(--stor-muted-text)]"
161
+ >
162
+ {entry.details}
163
+ </div>
164
+ {/if}
165
+ </button>
166
+ {/each}
167
+
168
+ {#if syncLog.hasMore}
169
+ <div class="py-3 text-center">
170
+ <button
171
+ type="button"
172
+ class="text-xs text-stone-700 hover:text-stone-950 font-medium disabled:opacity-50"
173
+ onclick={handleLoadMore}
174
+ disabled={isLoadingMore}
175
+ >
176
+ {isLoadingMore ? "Loading..." : "Load More"}
177
+ </button>
178
+ </div>
179
+ {/if}
180
+ </div>
181
+ {:else}
182
+ <div
183
+ class="rounded-lg border border-dashed border-[var(--stor-border)] bg-[var(--stor-surface-muted)] py-6 text-center"
184
+ >
185
+ <p class="text-sm text-[var(--stor-muted-text)]">No sync events</p>
186
+ </div>
187
+ {/if}
188
+ </div>
@@ -0,0 +1,28 @@
1
+ import type { LoadEvent } from '@sveltejs/kit';
2
+ type PageLoad = (event: LoadEvent) => unknown;
3
+ import { deferPage } from '@hearthlog/runtime/runtime/page-data.svelte.js';
4
+ import { getAllEvents } from '@hearthlog/runtime/event/index.js';
5
+ import { getAllOutboxEvents } from '@hearthlog/runtime/store/outbox.js';
6
+ import { loadRawSqliteTablePage, parseRawSqlitePage, parseRawSqlitePageSize, parseRawSqliteSortDirection, parseRawSqliteSortField } from '@hearthlog/runtime/db/raw-browser.js';
7
+ import { loadLocalSqliteStats } from '@hearthlog/runtime/db/local-statistics.js';
8
+ import { tenant } from '@hearthlog/contracts/tenant/config';
9
+ export const load = ((event) => {
10
+ event.depends('app:developer');
11
+ const requestedTab = event.url.searchParams.get('tab');
12
+ const tab = requestedTab === 'events' || requestedTab === 'database' || requestedTab === 'performance'
13
+ || (requestedTab === 'ledger' && tenant.admin) ? requestedTab : 'overview';
14
+ const table = event.url.searchParams.get('table');
15
+ const page = parseRawSqlitePage(event.url.searchParams.get('page'));
16
+ const pageSize = parseRawSqlitePageSize(event.url.searchParams.get('pageSize'));
17
+ const sort = parseRawSqliteSortField(event.url.searchParams.get('sort'));
18
+ const direction = parseRawSqliteSortDirection(event.url.searchParams.get('direction'));
19
+ return deferPage(event, { events: [], outboxEvents: [], databasePage: null, sqliteStats: null }, async () => {
20
+ const [events, outboxEvents, databasePage, sqliteStats] = await Promise.all([
21
+ tab === 'events' ? getAllEvents() : Promise.resolve([]),
22
+ tab === 'overview' ? getAllOutboxEvents() : Promise.resolve([]),
23
+ tab === 'database' ? loadRawSqliteTablePage({ tableName: table, page, pageSize, sort, direction }) : Promise.resolve(null),
24
+ tab === 'overview' || tab === 'database' ? loadLocalSqliteStats() : Promise.resolve(null),
25
+ ]);
26
+ return { events, outboxEvents, databasePage, sqliteStats };
27
+ });
28
+ }) satisfies PageLoad;