@hearthlog/shell 0.0.0-stage → 0.1.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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/app.template.html +184 -0
- package/src/components/ai/DecisionReviewSheet.svelte +226 -0
- package/src/components/ai/SuggestButton.svelte +23 -0
- package/src/components/ai/SuggestionChip.svelte +13 -0
- package/src/components/ai/SuggestionMark.svelte +15 -0
- package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
- package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
- package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
- package/src/components/calendar/source-icons.ts +19 -0
- package/src/components/data-table/DataTableValue.svelte +14 -0
- package/src/components/data-table/EntityActionsCell.svelte +36 -0
- package/src/components/data-table/EntityRefsCell.svelte +58 -0
- package/src/components/data-table/EntityValue.svelte +42 -0
- package/src/components/data-table/entity-columns.ts +87 -0
- package/src/components/data-table/sqlite-columns.ts +42 -0
- package/src/components/entities/CalendarSourceFields.svelte +149 -0
- package/src/components/history/ChangeRecord.svelte +30 -0
- package/src/components/history/ForkComposer.svelte +174 -0
- package/src/components/history/HistoryScrubber.svelte +245 -0
- package/src/components/history/LogHistory.svelte +334 -0
- package/src/components/history/LogMap.svelte +293 -0
- package/src/components/history/LogTree.svelte +124 -0
- package/src/components/layout/AccountMenu.svelte +111 -0
- package/src/components/layout/AppearanceOptions.svelte +78 -0
- package/src/components/layout/BrandWordmark.svelte +12 -0
- package/src/components/layout/ErrorToast.svelte +34 -0
- package/src/components/layout/LogCreateDrawer.svelte +111 -0
- package/src/components/layout/LogPanel.svelte +225 -0
- package/src/components/layout/LogStatusChip.svelte +137 -0
- package/src/components/layout/MobilePageAction.svelte +44 -0
- package/src/components/layout/MovedBanner.svelte +51 -0
- package/src/components/layout/NavBar.svelte +603 -0
- package/src/components/layout/NavFlyout.svelte +132 -0
- package/src/components/layout/NavItem.svelte +36 -0
- package/src/components/layout/NavMoreDialog.svelte +179 -0
- package/src/components/layout/PageDataStatus.svelte +36 -0
- package/src/components/layout/PhoneGlance.svelte +96 -0
- package/src/components/layout/SidebarResizer.svelte +122 -0
- package/src/components/layout/SyncIndicator.svelte +24 -0
- package/src/components/layout/feature-icons.ts +24 -0
- package/src/components/layout/nav-indicator.ts +128 -0
- package/src/components/layout/sidebar-size.ts +31 -0
- package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
- package/src/components/sync/SyncStateBadge.svelte +17 -0
- package/src/pages/calendar/Page.svelte +1341 -0
- package/src/pages/calendar/load.ts +77 -0
- package/src/pages/developer/DatabaseBrowser.svelte +325 -0
- package/src/pages/developer/Event.svelte +51 -0
- package/src/pages/developer/EventLogManager.svelte +268 -0
- package/src/pages/developer/IndexedDbStats.svelte +93 -0
- package/src/pages/developer/LedgerAdmin.svelte +877 -0
- package/src/pages/developer/LocalSqliteStats.svelte +56 -0
- package/src/pages/developer/Page.svelte +590 -0
- package/src/pages/developer/PendingWorkImport.svelte +49 -0
- package/src/pages/developer/PerformanceDashboard.svelte +262 -0
- package/src/pages/developer/StackInfo.svelte +117 -0
- package/src/pages/developer/SyncLog.svelte +188 -0
- package/src/pages/developer/load.ts +28 -0
- package/src/pages/entities/Page.svelte +1069 -0
- package/src/pages/entities/load.ts +18 -0
- package/src/pages/entity/Page.svelte +655 -0
- package/src/pages/entity/load.ts +79 -0
- package/src/pages/error/ErrorPage.svelte +147 -0
- package/src/pages/layout/Layout.svelte +597 -0
- package/src/pages/layout/load.ts +28 -0
- package/src/pages/redirects/history.ts +20 -0
- package/src/pages/redirects/start.ts +11 -0
- package/src/pages/redirects/today.ts +9 -0
- package/src/service-worker/index.ts +108 -0
- package/src/service-worker/offline-bundle.ts +116 -0
- package/src/service-worker/virtual.d.ts +3 -0
- package/src/styles/index.css +11 -0
- package/src/styles/shell.css +296 -0
- package/static/_headers +4 -0
- package/static/favicon.ico +0 -0
- package/static/offline.html +44 -0
- package/static/replay/sw.js +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatHistoryDateTime } from "@hearthlog/runtime/history/history.js";
|
|
3
|
+
import {
|
|
4
|
+
nearestBucket,
|
|
5
|
+
type HistoryActivity,
|
|
6
|
+
type HistoryGhost,
|
|
7
|
+
type HistoryMarker,
|
|
8
|
+
} from "@hearthlog/runtime/history/timeline.js";
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
version,
|
|
12
|
+
activity = null,
|
|
13
|
+
markers = [],
|
|
14
|
+
ghosts = [],
|
|
15
|
+
loading = false,
|
|
16
|
+
pointLabel = "",
|
|
17
|
+
onSeek,
|
|
18
|
+
onStep,
|
|
19
|
+
}: {
|
|
20
|
+
version: number;
|
|
21
|
+
activity?: HistoryActivity | null;
|
|
22
|
+
markers?: HistoryMarker[];
|
|
23
|
+
ghosts?: HistoryGhost[];
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
pointLabel?: string;
|
|
26
|
+
onSeek: (version: number) => void | Promise<void>;
|
|
27
|
+
onStep: (direction: "previous" | "next") => void | Promise<void>;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
const TRACK_HEIGHT = 46;
|
|
31
|
+
const MAX_BAR_HEIGHT = 32;
|
|
32
|
+
|
|
33
|
+
let track = $state<HTMLElement | null>(null);
|
|
34
|
+
let width = $state(0);
|
|
35
|
+
let hover = $state<{ x: number; version: number } | null>(null);
|
|
36
|
+
let dragVersion = $state<number | null>(null);
|
|
37
|
+
|
|
38
|
+
const minVersion = $derived(activity?.minVersion ?? 1);
|
|
39
|
+
const maxVersion = $derived(activity?.maxVersion ?? Math.max(version, 1));
|
|
40
|
+
const range = $derived(Math.max(1, maxVersion - minVersion + 1));
|
|
41
|
+
const activeVersion = $derived(dragVersion ?? version);
|
|
42
|
+
const playheadX = $derived(width > 0 ? ((activeVersion - minVersion) / range) * width : 0);
|
|
43
|
+
const hoverBucket = $derived(
|
|
44
|
+
activity && hover ? nearestBucket(activity, hover.version) : null,
|
|
45
|
+
);
|
|
46
|
+
const hoverMarker = $derived(
|
|
47
|
+
hover
|
|
48
|
+
? markers.find(
|
|
49
|
+
(marker) =>
|
|
50
|
+
marker.version !== null &&
|
|
51
|
+
Math.abs(xFor(marker.version) - hover!.x) < 7,
|
|
52
|
+
) ?? null
|
|
53
|
+
: null,
|
|
54
|
+
);
|
|
55
|
+
const hoverLabel = $derived.by(() => {
|
|
56
|
+
if (hoverMarker) return hoverMarker.label;
|
|
57
|
+
if (!hoverBucket) return "";
|
|
58
|
+
const date = formatHistoryDateTime(hoverBucket.endTimestamp);
|
|
59
|
+
return `${date} · ${hoverBucket.count} change${hoverBucket.count === 1 ? "" : "s"}`;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function xFor(value: number): number {
|
|
63
|
+
return ((value - minVersion) / range) * width;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function versionAt(clientX: number): number {
|
|
67
|
+
if (!track) return version;
|
|
68
|
+
const rect = track.getBoundingClientRect();
|
|
69
|
+
if (rect.width <= 0) return version;
|
|
70
|
+
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
71
|
+
return Math.round(minVersion + ratio * (range - 1));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function barX(startVersion: number): number {
|
|
75
|
+
return xFor(startVersion);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function barWidth(startVersion: number, endVersion: number): number {
|
|
79
|
+
return Math.max(1.5, xFor(endVersion + 1) - xFor(startVersion) - 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function barHeight(count: number): number {
|
|
83
|
+
if (!activity) return 2;
|
|
84
|
+
return Math.max(2, (count / activity.maxCount) * MAX_BAR_HEIGHT);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handlePointerDown(event: PointerEvent): void {
|
|
88
|
+
if (!activity || loading) return;
|
|
89
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
90
|
+
dragVersion = versionAt(event.clientX);
|
|
91
|
+
hover = { x: xFor(dragVersion), version: dragVersion };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handlePointerMove(event: PointerEvent): void {
|
|
95
|
+
if (!activity) return;
|
|
96
|
+
const value = versionAt(event.clientX);
|
|
97
|
+
hover = { x: xFor(value), version: value };
|
|
98
|
+
if (dragVersion !== null) dragVersion = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handlePointerUp(): void {
|
|
102
|
+
if (dragVersion === null) return;
|
|
103
|
+
const target = dragVersion;
|
|
104
|
+
dragVersion = null;
|
|
105
|
+
void onSeek(target);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
109
|
+
if (event.key === "ArrowLeft") {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
void onStep("previous");
|
|
112
|
+
} else if (event.key === "ArrowRight") {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
void onStep("next");
|
|
115
|
+
} else if (event.key === "Home") {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
void onSeek(minVersion);
|
|
118
|
+
} else if (event.key === "End") {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
void onSeek(maxVersion);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatDay(timestamp: number | undefined): string {
|
|
125
|
+
if (!timestamp) return "";
|
|
126
|
+
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(
|
|
127
|
+
new Date(timestamp),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<div class="space-y-1.5">
|
|
133
|
+
<div
|
|
134
|
+
bind:this={track}
|
|
135
|
+
bind:clientWidth={width}
|
|
136
|
+
class="relative w-full touch-none select-none overflow-hidden rounded-xl bg-[var(--stor-surface-muted)] {activity && !loading
|
|
137
|
+
? 'cursor-pointer'
|
|
138
|
+
: 'cursor-default'} {loading ? 'motion-safe:animate-pulse' : ''}"
|
|
139
|
+
style="height: {TRACK_HEIGHT}px"
|
|
140
|
+
role="slider"
|
|
141
|
+
tabindex="0"
|
|
142
|
+
aria-label="History timeline"
|
|
143
|
+
aria-valuemin={minVersion}
|
|
144
|
+
aria-valuemax={maxVersion}
|
|
145
|
+
aria-valuenow={version}
|
|
146
|
+
aria-valuetext={pointLabel || `Version ${version}`}
|
|
147
|
+
aria-busy={loading}
|
|
148
|
+
onpointerdown={handlePointerDown}
|
|
149
|
+
onpointermove={handlePointerMove}
|
|
150
|
+
onpointerup={handlePointerUp}
|
|
151
|
+
onpointercancel={() => (dragVersion = null)}
|
|
152
|
+
onpointerleave={() => (hover = null)}
|
|
153
|
+
onkeydown={handleKeydown}
|
|
154
|
+
>
|
|
155
|
+
{#if activity && !loading}
|
|
156
|
+
{#each activity.buckets as bucket (bucket.bucket)}
|
|
157
|
+
<div
|
|
158
|
+
class="absolute bottom-0 rounded-t-[3px] bg-[var(--stor-info-border)]"
|
|
159
|
+
style="left: {barX(bucket.startVersion)}px; width: {barWidth(bucket.startVersion, bucket.endVersion)}px; height: {barHeight(bucket.count)}px"
|
|
160
|
+
title={`${formatHistoryDateTime(bucket.endTimestamp)} · ${bucket.count} change${bucket.count === 1 ? "" : "s"}`}
|
|
161
|
+
></div>
|
|
162
|
+
{/each}
|
|
163
|
+
|
|
164
|
+
{#each ghosts as ghost (ghost.id)}
|
|
165
|
+
<div
|
|
166
|
+
class="absolute bottom-0 w-[3px] rounded-t-[2px] border border-dashed border-[var(--stor-muted-text)]/70 bg-[var(--stor-surface)]"
|
|
167
|
+
style="left: {xFor(ghost.version)}px; height: {MAX_BAR_HEIGHT}px"
|
|
168
|
+
title={`Removed · ${ghost.label}`}
|
|
169
|
+
></div>
|
|
170
|
+
{/each}
|
|
171
|
+
|
|
172
|
+
{#each markers.filter((marker) => marker.version !== null) as marker (marker.id)}
|
|
173
|
+
<div
|
|
174
|
+
class="absolute top-0 w-[2px] {marker.type === 'restore'
|
|
175
|
+
? 'bg-[var(--stor-warning)]'
|
|
176
|
+
: marker.active
|
|
177
|
+
? 'bg-[var(--stor-accent-strong)]'
|
|
178
|
+
: 'bg-[var(--stor-muted-text)]/50'}"
|
|
179
|
+
style="left: {xFor(marker.version!)}px; height: 9px"
|
|
180
|
+
title={marker.label}
|
|
181
|
+
></div>
|
|
182
|
+
{/each}
|
|
183
|
+
|
|
184
|
+
{#if hover}
|
|
185
|
+
<div
|
|
186
|
+
class="pointer-events-none absolute inset-y-0 w-px bg-[var(--stor-ink)]/20"
|
|
187
|
+
style="left: {hover.x}px"
|
|
188
|
+
></div>
|
|
189
|
+
{/if}
|
|
190
|
+
|
|
191
|
+
<div
|
|
192
|
+
class="pointer-events-none absolute inset-y-0 w-[2px] bg-[var(--stor-info)]"
|
|
193
|
+
style="left: {playheadX}px"
|
|
194
|
+
></div>
|
|
195
|
+
<div
|
|
196
|
+
class="pointer-events-none absolute -top-[1px] h-2.5 w-2.5 -translate-x-1/2 rounded-full border-2 border-[var(--stor-surface-muted)] bg-[var(--stor-info)]"
|
|
197
|
+
style="left: {playheadX}px"
|
|
198
|
+
></div>
|
|
199
|
+
|
|
200
|
+
{#if hover && hoverLabel}
|
|
201
|
+
<div
|
|
202
|
+
class="pointer-events-none absolute top-1 z-10 max-w-[min(16rem,80%)] -translate-x-1/2 whitespace-nowrap rounded-lg border border-[var(--stor-border)] bg-[var(--stor-surface-raised)] px-2 py-1 text-xs font-medium text-[var(--stor-ink)] shadow-[var(--stor-shadow-sm)]"
|
|
203
|
+
style="left: {Math.min(Math.max(hover.x, 70), Math.max(70, width - 70))}px"
|
|
204
|
+
>
|
|
205
|
+
{hoverLabel}
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
{:else if loading}
|
|
209
|
+
<div class="absolute inset-0 flex items-center justify-center text-xs font-medium text-[var(--stor-muted-text)]" role="status">
|
|
210
|
+
Building timeline…
|
|
211
|
+
</div>
|
|
212
|
+
{:else}
|
|
213
|
+
<div class="absolute inset-0 flex items-center justify-center text-xs font-medium text-[var(--stor-muted-text)]">
|
|
214
|
+
No synced history yet
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="flex items-center justify-between gap-3 text-[10.5px] font-medium uppercase tracking-wide text-[var(--stor-muted-text)]">
|
|
220
|
+
<span>{formatDay(activity?.firstTimestamp)}</span>
|
|
221
|
+
{#if ghosts.length > 0 || markers.length > 0}
|
|
222
|
+
<span class="flex items-center gap-2 normal-case tracking-normal">
|
|
223
|
+
{#if markers.some((marker) => marker.type === "restore")}
|
|
224
|
+
<span class="inline-flex items-center gap-1">
|
|
225
|
+
<span class="h-2 w-[2px] bg-[var(--stor-warning)]"></span>
|
|
226
|
+
restore
|
|
227
|
+
</span>
|
|
228
|
+
{/if}
|
|
229
|
+
{#if markers.some((marker) => marker.type === "excision")}
|
|
230
|
+
<span class="inline-flex items-center gap-1">
|
|
231
|
+
<span class="h-2 w-[2px] bg-[var(--stor-accent-strong)]"></span>
|
|
232
|
+
removed
|
|
233
|
+
</span>
|
|
234
|
+
{/if}
|
|
235
|
+
{#if ghosts.length > 0}
|
|
236
|
+
<span class="inline-flex items-center gap-1">
|
|
237
|
+
<span class="h-2 w-[3px] rounded-sm border border-dashed border-[var(--stor-muted-text)]/70"></span>
|
|
238
|
+
ghost
|
|
239
|
+
</span>
|
|
240
|
+
{/if}
|
|
241
|
+
</span>
|
|
242
|
+
{/if}
|
|
243
|
+
<span>{formatDay(activity?.lastTimestamp)}</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick, untrack } from 'svelte';
|
|
3
|
+
import { cachedCurrentUserEmail } from '@hearthlog/runtime/access/user-session.js';
|
|
4
|
+
import { historyRouteUrl, navigateHistory } from '@hearthlog/runtime/history/navigation.js';
|
|
5
|
+
import Button from '@hearthlog/ui/components/ui/Button.svelte';
|
|
6
|
+
import ChangeRecord from './ChangeRecord.svelte';
|
|
7
|
+
import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
|
|
8
|
+
import { historyUrlState } from '@hearthlog/runtime/history/url.js';
|
|
9
|
+
import type { HistoryEntry, HistoryItem, HistoryRecord } from '@hearthlog/runtime/history/contracts.js';
|
|
10
|
+
import { formatHistoryDateTime } from '@hearthlog/runtime/history/history.js';
|
|
11
|
+
import { FEATURE_ICONS } from '../layout/feature-icons.js';
|
|
12
|
+
import { events } from '@hearthlog/runtime/store/events.svelte.js';
|
|
13
|
+
import type { LogLineageIndex, LogSummary } from '@hearthlog/runtime/ledger/index.js';
|
|
14
|
+
import { forkMarkers, ownChanges, type ForkMarker } from '@hearthlog/runtime/history/lineage.js';
|
|
15
|
+
import { formatForkPoint, formatLogDay } from '@hearthlog/runtime/history/log-format.js';
|
|
16
|
+
import { formatNumber } from '@hearthlog/ui/utils/number-format.js';
|
|
17
|
+
import IconHistory from '~icons/mdi/history';
|
|
18
|
+
import IconChevronRight from '~icons/mdi/chevron-right';
|
|
19
|
+
import IconChevronDown from '~icons/mdi/chevron-down';
|
|
20
|
+
import IconSourceBranch from '~icons/mdi/source-branch';
|
|
21
|
+
import IconPlus from '~icons/mdi/plus';
|
|
22
|
+
|
|
23
|
+
const PAGE = 60;
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
currentLogId,
|
|
27
|
+
currentLogName,
|
|
28
|
+
logs,
|
|
29
|
+
lineage,
|
|
30
|
+
showHidden,
|
|
31
|
+
switchingLogId = null,
|
|
32
|
+
reveal = null,
|
|
33
|
+
locked = false,
|
|
34
|
+
onOpenLog,
|
|
35
|
+
onOpenSyncIssues,
|
|
36
|
+
onRange,
|
|
37
|
+
}: {
|
|
38
|
+
currentLogId: string | null;
|
|
39
|
+
currentLogName: string | null;
|
|
40
|
+
logs: LogSummary[];
|
|
41
|
+
lineage: LogLineageIndex | null;
|
|
42
|
+
showHidden: boolean;
|
|
43
|
+
switchingLogId?: string | null;
|
|
44
|
+
/** An action to scroll to without opening it, from the map. */
|
|
45
|
+
reveal?: { id: string; nonce: number } | null;
|
|
46
|
+
/** A new log is being created; the list stays still. */
|
|
47
|
+
locked?: boolean;
|
|
48
|
+
onOpenLog: (logId: string) => void;
|
|
49
|
+
onOpenSyncIssues: () => void;
|
|
50
|
+
/** The time span of the actions on screen. */
|
|
51
|
+
onRange: (range: { from: number; to: number } | null) => void;
|
|
52
|
+
} = $props();
|
|
53
|
+
|
|
54
|
+
const view = $derived(historyUrlState(historyRouteUrl()));
|
|
55
|
+
const current = $derived(logs.find(log => log.id === currentLogId) ?? null);
|
|
56
|
+
const markers = $derived(forkMarkers(logs, lineage, { currentLogId, showHidden }));
|
|
57
|
+
const markersByKey = $derived(new Map(markers.map(marker => [marker.key, marker])));
|
|
58
|
+
const markerPositions = $derived(markers.map(marker => ({ key: marker.key, version: marker.version })));
|
|
59
|
+
const sourceVersion = $derived(current?.sourceLogId ? current.sourceVersion ?? 0 : null);
|
|
60
|
+
|
|
61
|
+
let items = $state<HistoryItem[]>([]);
|
|
62
|
+
let limit = $state(PAGE);
|
|
63
|
+
let loading = $state(true);
|
|
64
|
+
let error = $state('');
|
|
65
|
+
let retry = $state(0);
|
|
66
|
+
let entry = $state<HistoryEntry | null>(null);
|
|
67
|
+
let changes = $state<HistoryRecord[]>([]);
|
|
68
|
+
let detailLoading = $state(false);
|
|
69
|
+
let detailError = $state('');
|
|
70
|
+
let me = $state('');
|
|
71
|
+
onMount(() => {
|
|
72
|
+
// Authors are named only when someone else wrote the action.
|
|
73
|
+
void cachedCurrentUserEmail().then((email) => { me = email; }).catch(() => undefined);
|
|
74
|
+
});
|
|
75
|
+
let root = $state<HTMLElement | null>(null);
|
|
76
|
+
let sentinel = $state<HTMLElement | null>(null);
|
|
77
|
+
let scrolledTo = '';
|
|
78
|
+
/** An older action the map asked for, so the list loads until it. */
|
|
79
|
+
let revealThrough = $state<string | null>(null);
|
|
80
|
+
let revealPending = $state<string | null>(null);
|
|
81
|
+
|
|
82
|
+
const total = $derived(items[0]?.total ?? 0);
|
|
83
|
+
|
|
84
|
+
function navigate(values: Record<string, string | null>, replace = false) {
|
|
85
|
+
const url = new URL(window.location.href);
|
|
86
|
+
for (const [key, value] of Object.entries(values)) {
|
|
87
|
+
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
|
88
|
+
}
|
|
89
|
+
navigateHistory(url, replace);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A new search or filter starts from the top again.
|
|
93
|
+
$effect(() => {
|
|
94
|
+
void view.search; void view.feature; void currentLogId;
|
|
95
|
+
limit = PAGE;
|
|
96
|
+
revealThrough = null;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
$effect(() => {
|
|
100
|
+
const scope = currentLogId;
|
|
101
|
+
const options = { search: view.search, feature: view.feature, limit, through: revealThrough ?? view.action,
|
|
102
|
+
sourceVersion, markers: markerPositions };
|
|
103
|
+
void events.updatedAt;
|
|
104
|
+
void retry;
|
|
105
|
+
if (!scope) return;
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
loading = true;
|
|
108
|
+
error = '';
|
|
109
|
+
void queryDatabase(r => r.history.list(options)).then(rows => {
|
|
110
|
+
if (!cancelled) items = rows;
|
|
111
|
+
}).catch(cause => { if (!cancelled) error = message(cause); })
|
|
112
|
+
.finally(() => { if (!cancelled) loading = false; });
|
|
113
|
+
return () => { cancelled = true; };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Old links name a version; open the action it belongs to.
|
|
117
|
+
$effect(() => {
|
|
118
|
+
const version = view.version;
|
|
119
|
+
if (!version || !currentLogId) return;
|
|
120
|
+
void queryDatabase(r => r.history.get({ version })).then(found => {
|
|
121
|
+
navigate({ historyVersion: null, historyAction: found?.id ?? null }, true);
|
|
122
|
+
}).catch(() => navigate({ historyVersion: null }, true));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
$effect(() => {
|
|
126
|
+
const action = view.action;
|
|
127
|
+
void events.updatedAt;
|
|
128
|
+
if (!action || !currentLogId) { entry = null; changes = []; return; }
|
|
129
|
+
let cancelled = false;
|
|
130
|
+
detailLoading = true;
|
|
131
|
+
detailError = '';
|
|
132
|
+
void queryDatabase(async r => {
|
|
133
|
+
const selected = await r.history.get({ id: action });
|
|
134
|
+
if (!selected) throw new Error('This action is not in this log. Choose another one.');
|
|
135
|
+
if (!cancelled) entry = selected;
|
|
136
|
+
return selected.status === 'ready' ? r.history.changes(selected) : r.history.savedValues(selected.id);
|
|
137
|
+
}).then(records => { if (!cancelled) changes = records; })
|
|
138
|
+
.catch(cause => { if (!cancelled) detailError = message(cause); })
|
|
139
|
+
.finally(() => { if (!cancelled) detailLoading = false; });
|
|
140
|
+
return () => { cancelled = true; };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Bring an action opened from a link into view once it has loaded.
|
|
144
|
+
$effect(() => {
|
|
145
|
+
const action = view.action;
|
|
146
|
+
if (!action || scrolledTo === action || !items.some(item => item.id === action)) return;
|
|
147
|
+
scrolledTo = action;
|
|
148
|
+
void tick().then(() => root?.querySelector(`[data-history-item="${CSS.escape(action)}"]`)?.scrollIntoView({ block: 'nearest' }));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// The map moves the list: scroll to the action, loading older ones first when needed.
|
|
152
|
+
$effect(() => {
|
|
153
|
+
const target = reveal;
|
|
154
|
+
if (!target) return;
|
|
155
|
+
const element = untrack(() => root)?.querySelector<HTMLElement>(`[data-history-item="${CSS.escape(target.id)}"]`);
|
|
156
|
+
if (element) { element.scrollIntoView({ block: 'start' }); return; }
|
|
157
|
+
revealPending = target.id;
|
|
158
|
+
if (untrack(() => revealThrough) !== target.id) revealThrough = target.id;
|
|
159
|
+
});
|
|
160
|
+
$effect(() => {
|
|
161
|
+
const id = revealPending;
|
|
162
|
+
if (!id || !items.some(item => item.id === id)) return;
|
|
163
|
+
revealPending = null;
|
|
164
|
+
void tick().then(() => root?.querySelector(`[data-history-item="${CSS.escape(id)}"]`)?.scrollIntoView({ block: 'start' }));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Load the next page when the end of the list comes into view.
|
|
168
|
+
$effect(() => {
|
|
169
|
+
const target = sentinel;
|
|
170
|
+
if (!target) return;
|
|
171
|
+
const observer = new IntersectionObserver(entries => {
|
|
172
|
+
if (entries.some(item => item.isIntersecting) && !loading && items.length < total) limit += PAGE;
|
|
173
|
+
}, { root: target.closest('.stor-drawer-body'), rootMargin: '400px 0px' });
|
|
174
|
+
observer.observe(target);
|
|
175
|
+
return () => observer.disconnect();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Report which part of the log is on screen, for the map's bracket.
|
|
179
|
+
$effect(() => {
|
|
180
|
+
const list = root;
|
|
181
|
+
void items;
|
|
182
|
+
if (!list) return;
|
|
183
|
+
const visible = new Set<Element>();
|
|
184
|
+
let frame = 0;
|
|
185
|
+
const report = () => {
|
|
186
|
+
frame = 0;
|
|
187
|
+
const times = [...visible].map(element => Number((element as HTMLElement).dataset.time)).filter(Number.isFinite);
|
|
188
|
+
onRange(times.length ? { from: Math.min(...times), to: Math.max(...times) } : null);
|
|
189
|
+
};
|
|
190
|
+
const observer = new IntersectionObserver(entries => {
|
|
191
|
+
for (const item of entries) {
|
|
192
|
+
if (item.isIntersecting) visible.add(item.target); else visible.delete(item.target);
|
|
193
|
+
}
|
|
194
|
+
if (!frame) frame = requestAnimationFrame(report);
|
|
195
|
+
}, { root: list.closest('.stor-drawer-body') });
|
|
196
|
+
list.querySelectorAll('[data-time]').forEach(element => observer.observe(element));
|
|
197
|
+
return () => { observer.disconnect(); if (frame) cancelAnimationFrame(frame); };
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function toggle(item: HistoryEntry) {
|
|
201
|
+
if (locked) return;
|
|
202
|
+
navigate(view.action === item.id
|
|
203
|
+
? { historyAction: null, historyStep: null }
|
|
204
|
+
: { historyAction: item.id, historyStep: null });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function message(cause: unknown) { return cause instanceof Error ? cause.message : 'Could not load history. Try again.'; }
|
|
208
|
+
function day(timestamp: number) { return new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(timestamp); }
|
|
209
|
+
function time(timestamp: number) { return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp); }
|
|
210
|
+
function marker(item: HistoryItem): ForkMarker | undefined {
|
|
211
|
+
return item.kind === 'marker' ? markersByKey.get(item.markerKey) : undefined;
|
|
212
|
+
}
|
|
213
|
+
function authorText(item: HistoryEntry): string {
|
|
214
|
+
return item.actor && me && item.actor.toLowerCase() !== me.toLowerCase() ? ` · ${item.actor}` : '';
|
|
215
|
+
}
|
|
216
|
+
function statusText(item: HistoryEntry): string {
|
|
217
|
+
return item.status === 'ready' ? '' : item.status === 'attention' ? ' · Needs attention' : ' · Waiting to sync';
|
|
218
|
+
}
|
|
219
|
+
</script>
|
|
220
|
+
|
|
221
|
+
{#snippet ghost()}
|
|
222
|
+
<li class="py-1">
|
|
223
|
+
<div class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm outline-dashed outline-[1.5px] -outline-offset-[1.5px] outline-[var(--stor-ink)]">
|
|
224
|
+
<IconPlus class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
225
|
+
<span class="min-w-0"><span class="block font-semibold text-[var(--stor-ink)]">New log starts here</span>
|
|
226
|
+
<span class="block text-xs text-[var(--stor-muted-text)]">It keeps everything below this line.</span></span>
|
|
227
|
+
</div>
|
|
228
|
+
</li>
|
|
229
|
+
{/snippet}
|
|
230
|
+
|
|
231
|
+
<div bind:this={root}>
|
|
232
|
+
{#if loading && items.length === 0}
|
|
233
|
+
<p role="status" class="py-2 text-sm text-[var(--stor-muted-text)] motion-safe:animate-pulse">Loading history…</p>
|
|
234
|
+
{:else if !error && !items.some(item => item.kind === 'action')}
|
|
235
|
+
<p class="py-4 text-sm text-[var(--stor-muted-text)]">{view.search || view.feature ? 'No actions match your search.' : 'No activity in this log yet. Your next saved action will show here.'}</p>
|
|
236
|
+
{/if}
|
|
237
|
+
{#if items.length > 0}
|
|
238
|
+
<ol aria-label="Activity in this log" aria-busy={loading}>
|
|
239
|
+
{#if view.step === 'copy' && !view.search && !view.feature}{@render ghost()}{/if}
|
|
240
|
+
{#each items as item (item.id)}
|
|
241
|
+
{#if item.kind === 'marker'}
|
|
242
|
+
{@const found = marker(item)}
|
|
243
|
+
{#if found?.kind === 'start'}
|
|
244
|
+
<li class="py-2" data-time={Date.parse(found.log.sourceTimestamp ?? '') || undefined}>
|
|
245
|
+
<div class="rounded-2xl bg-[var(--stor-surface-muted)] px-3 py-2.5">
|
|
246
|
+
<p class="text-sm font-semibold text-[var(--stor-ink)]">{currentLogName ?? 'This log'} starts here</p>
|
|
247
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">
|
|
248
|
+
{#if found.log.sourceLogId && logs.some(log => log.id === found.log.sourceLogId)}
|
|
249
|
+
{found.version === 0 ? 'Started empty from' : 'Copied from'}
|
|
250
|
+
<button type="button" class="font-semibold text-[var(--stor-ink)] underline decoration-[var(--stor-border)] underline-offset-2 hover:decoration-[var(--stor-ink)] disabled:no-underline"
|
|
251
|
+
disabled={switchingLogId !== null || locked} onclick={() => onOpenLog(found.log.sourceLogId!)}>{found.log.sourceLogName ?? 'its source'}</button>{found.version === 0 ? '.' : ` up to ${formatForkPoint(found.log.sourceTimestamp)}.`}
|
|
252
|
+
{found.log.sourceLogName ?? 'The source'} is unchanged.
|
|
253
|
+
{:else}
|
|
254
|
+
Copied from a log that is no longer available{found.version === 0 ? '' : `, up to ${formatForkPoint(found.log.sourceTimestamp)}`}.
|
|
255
|
+
{/if}
|
|
256
|
+
</p>
|
|
257
|
+
</div>
|
|
258
|
+
</li>
|
|
259
|
+
{:else if found}
|
|
260
|
+
{@const log = found.log}
|
|
261
|
+
<li class="py-1">
|
|
262
|
+
<button type="button"
|
|
263
|
+
class="flex min-h-11 w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left shadow-[inset_0_0_0_1px_var(--stor-border)] hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:cursor-default disabled:hover:bg-transparent"
|
|
264
|
+
aria-label={log.status === 'preparing' ? `${log.name} started here, preparing` : `Open ${log.name}, which started here`}
|
|
265
|
+
disabled={log.status === 'preparing' || switchingLogId !== null || locked}
|
|
266
|
+
onclick={() => onOpenLog(log.id)}>
|
|
267
|
+
<IconSourceBranch class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
268
|
+
<span class="min-w-0 flex-1">
|
|
269
|
+
<span class="block break-words text-sm font-semibold text-[var(--stor-ink)]">{log.name}</span>
|
|
270
|
+
<span class="block text-xs text-[var(--stor-muted-text)]">{log.status === 'preparing' ? 'Started here · preparing'
|
|
271
|
+
: `Started here · ${formatNumber(ownChanges(log))} own ${ownChanges(log) === 1 ? 'change' : 'changes'}${ownChanges(log) > 0 && log.lastUpdatedAt ? ` · ${formatLogDay(log.lastUpdatedAt)}` : ''}`}</span>
|
|
272
|
+
</span>
|
|
273
|
+
{#if switchingLogId === log.id}<span class="shrink-0 text-xs text-[var(--stor-muted-text)]" role="status">Opening…</span>
|
|
274
|
+
{:else if log.status !== 'preparing'}<IconChevronRight class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />{/if}
|
|
275
|
+
</button>
|
|
276
|
+
</li>
|
|
277
|
+
{/if}
|
|
278
|
+
{:else}
|
|
279
|
+
{@const Icon = item.feature ? FEATURE_ICONS[item.feature] : IconHistory}
|
|
280
|
+
{@const open = view.action === item.id}
|
|
281
|
+
{#if view.step === 'fork' && open}{@render ghost()}{/if}
|
|
282
|
+
<li data-history-item={item.id} data-time={item.timestamp}>
|
|
283
|
+
{#if item.startsDay}<p class="px-2 pb-1.5 pt-4 text-xs font-semibold text-[var(--stor-muted-text)]">{day(item.timestamp)}</p>{/if}
|
|
284
|
+
<div class={open ? 'my-1 rounded-2xl bg-[var(--stor-surface-muted)] shadow-[inset_0_0_0_1px_var(--stor-border)]' : ''}>
|
|
285
|
+
<button type="button"
|
|
286
|
+
class="flex min-h-12 w-full items-center gap-3 rounded-xl px-2 py-2.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {open ? '' : 'hover:bg-[var(--stor-surface-hover)]'}"
|
|
287
|
+
aria-expanded={open} aria-controls={open ? `history-detail-${item.id}` : undefined}
|
|
288
|
+
disabled={locked}
|
|
289
|
+
onclick={() => toggle(item)}>
|
|
290
|
+
<Icon class="h-5 w-5 shrink-0 text-[var(--stor-muted-text)] {item.copied ? 'opacity-60' : ''}" />
|
|
291
|
+
<span class="min-w-0 flex-1">
|
|
292
|
+
<span class="block break-words text-sm text-[var(--stor-ink)] {item.copied ? 'font-medium' : 'font-semibold'}">{item.label}</span>
|
|
293
|
+
<span class="mt-0.5 block text-xs text-[var(--stor-muted-text)]">{time(item.timestamp)}{item.eventCount > 1 ? ` · ${formatNumber(item.eventCount)} changes` : ''}{authorText(item)}{statusText(item)}</span>
|
|
294
|
+
</span>
|
|
295
|
+
<IconChevronDown class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)] transition-transform motion-reduce:transition-none {open ? 'rotate-180' : ''}" />
|
|
296
|
+
</button>
|
|
297
|
+
{#if open}
|
|
298
|
+
<div id={`history-detail-${item.id}`} class="space-y-3 px-3 pb-3 sm:pl-10">
|
|
299
|
+
{#if entry && entry.id === item.id && entry.status !== 'ready'}
|
|
300
|
+
<p class="text-sm text-[var(--stor-muted-text)]">{entry.status === 'attention' ? 'Needs attention' : 'Waiting to sync'} · {entry.reason}</p>
|
|
301
|
+
{#if entry.status === 'attention'}<Button variant="secondary" size="sm" onclick={onOpenSyncIssues}>Open sync issues</Button>{/if}
|
|
302
|
+
<p class="text-xs text-[var(--stor-muted-text)]">Saved values on this device; these are not a confirmed before and after.</p>
|
|
303
|
+
{/if}
|
|
304
|
+
{#if detailLoading}<p role="status" class="text-sm text-[var(--stor-muted-text)] motion-safe:animate-pulse">Loading this action…</p>
|
|
305
|
+
{:else if detailError}<p role="alert" class="text-sm text-[var(--stor-negative)]">{detailError}</p><Button variant="secondary" size="sm" onclick={() => retry++}>Retry</Button>
|
|
306
|
+
{:else}
|
|
307
|
+
{#each changes as change (change.key)}<ChangeRecord {change} />{/each}
|
|
308
|
+
{#if changes.length === 0}<p class="text-sm text-[var(--stor-muted-text)]">This action has no field details to show.</p>{/if}
|
|
309
|
+
{/if}
|
|
310
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
311
|
+
<Button variant={view.step === 'fork' ? 'default' : 'secondary'} size="sm" aria-pressed={view.step === 'fork'}
|
|
312
|
+
disabled={locked || item.status !== 'ready'}
|
|
313
|
+
title={item.status === 'ready' ? undefined : 'Only synced actions can start a log.'}
|
|
314
|
+
onclick={() => navigate({ historyStep: view.step === 'fork' ? null : 'fork' })}>
|
|
315
|
+
<IconSourceBranch class="h-4 w-4" />New log after this
|
|
316
|
+
</Button>
|
|
317
|
+
</div>
|
|
318
|
+
{#if entry && entry.id === item.id}
|
|
319
|
+
<details class="text-xs text-[var(--stor-muted-text)]"><summary class="min-h-11 cursor-pointer py-3 sm:min-h-0 sm:py-1">Technical details</summary>
|
|
320
|
+
<p class="break-all">{formatHistoryDateTime(entry.timestamp)} · {entry.version === null ? 'Not synced' : `Version ${entry.version}`}{entry.actor ? ` · ${entry.actor}` : ''} · {entry.id}</p></details>
|
|
321
|
+
{/if}
|
|
322
|
+
</div>
|
|
323
|
+
{/if}
|
|
324
|
+
</div>
|
|
325
|
+
</li>
|
|
326
|
+
{/if}
|
|
327
|
+
{/each}
|
|
328
|
+
</ol>
|
|
329
|
+
{#if items.length < total}
|
|
330
|
+
<p bind:this={sentinel} role="status" class="py-3 text-center text-xs text-[var(--stor-muted-text)]">Loading older actions…</p>
|
|
331
|
+
{/if}
|
|
332
|
+
{/if}
|
|
333
|
+
{#if error}<p role="alert" class="text-sm text-[var(--stor-negative)]">{error}</p><Button variant="secondary" onclick={() => retry++}>Retry</Button>{/if}
|
|
334
|
+
</div>
|