@hearthlog/runtime 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 (120) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/access/session.ts +223 -0
  4. package/src/access/user-session.ts +61 -0
  5. package/src/ai/decisions.ts +89 -0
  6. package/src/calendar/draft.ts +84 -0
  7. package/src/calendar/feeds.ts +91 -0
  8. package/src/calendar/groups.ts +198 -0
  9. package/src/calendar/logic.ts +141 -0
  10. package/src/calendar/preferences.ts +40 -0
  11. package/src/calendar/weeks.ts +56 -0
  12. package/src/commands/index.ts +184 -0
  13. package/src/commands/local-commit.ts +214 -0
  14. package/src/commands/standard.ts +8 -0
  15. package/src/commands/validation.ts +206 -0
  16. package/src/db/bootstrap.ts +72 -0
  17. package/src/db/calendar.ts +638 -0
  18. package/src/db/dynamic-ddl.ts +127 -0
  19. package/src/db/entities.ts +208 -0
  20. package/src/db/history.ts +194 -0
  21. package/src/db/index.ts +1240 -0
  22. package/src/db/kv.ts +33 -0
  23. package/src/db/local-statistics.ts +106 -0
  24. package/src/db/module-projections.ts +46 -0
  25. package/src/db/raw-browser.ts +361 -0
  26. package/src/db/read-repositories.ts +29 -0
  27. package/src/db/read-session.ts +47 -0
  28. package/src/db/schema-version.ts +76 -0
  29. package/src/db/session-context.ts +33 -0
  30. package/src/event/code-export.ts +65 -0
  31. package/src/event/dynamic-entities.ts +503 -0
  32. package/src/event/dynamic-materializer.ts +288 -0
  33. package/src/event/handlers.ts +490 -0
  34. package/src/event/index.ts +53 -0
  35. package/src/event/meta-entity-types.ts +165 -0
  36. package/src/event/projection-builder.ts +384 -0
  37. package/src/event/queries.ts +126 -0
  38. package/src/event/rebuild.ts +176 -0
  39. package/src/event/reference-lookup.ts +93 -0
  40. package/src/event/registry.ts +158 -0
  41. package/src/event/schema-effects.ts +45 -0
  42. package/src/event/types.ts +20 -0
  43. package/src/history/action-boundary.ts +18 -0
  44. package/src/history/action-detail.ts +23 -0
  45. package/src/history/change-comparison.ts +29 -0
  46. package/src/history/change-display.ts +41 -0
  47. package/src/history/contracts.ts +63 -0
  48. package/src/history/history.ts +372 -0
  49. package/src/history/legacy-replay.ts +69 -0
  50. package/src/history/legacy-url.ts +9 -0
  51. package/src/history/lineage.ts +123 -0
  52. package/src/history/log-format.ts +17 -0
  53. package/src/history/log-map.ts +110 -0
  54. package/src/history/navigation.ts +14 -0
  55. package/src/history/point-snapshot.ts +229 -0
  56. package/src/history/timeline.ts +215 -0
  57. package/src/history/url.ts +18 -0
  58. package/src/ledger/README.md +118 -0
  59. package/src/ledger/admin-client.ts +616 -0
  60. package/src/ledger/client.ts +10 -0
  61. package/src/ledger/config.ts +117 -0
  62. package/src/ledger/entity-id.ts +115 -0
  63. package/src/ledger/errors.ts +144 -0
  64. package/src/ledger/http-client.ts +590 -0
  65. package/src/ledger/index.ts +67 -0
  66. package/src/ledger/socket.ts +400 -0
  67. package/src/ledger/types.ts +202 -0
  68. package/src/ledger/version-tracker-singleton.ts +12 -0
  69. package/src/ledger/version-tracker.ts +259 -0
  70. package/src/log-management.ts +123 -0
  71. package/src/log-validation.ts +19 -0
  72. package/src/native/reminders.ts +2 -0
  73. package/src/native/shell.svelte.ts +136 -0
  74. package/src/native/shell.ts +102 -0
  75. package/src/pwa/registration.ts +169 -0
  76. package/src/recovery/local-recovery.ts +59 -0
  77. package/src/recovery/reset-sequence.ts +13 -0
  78. package/src/runtime/clock.ts +43 -0
  79. package/src/runtime/kept-page-data.svelte.ts +42 -0
  80. package/src/runtime/page-data.svelte.ts +160 -0
  81. package/src/runtime/phone-header.svelte.ts +17 -0
  82. package/src/runtime/read-coordinator.ts +103 -0
  83. package/src/runtime/refresh.ts +15 -0
  84. package/src/runtime/start-page.ts +56 -0
  85. package/src/runtime/view.ts +83 -0
  86. package/src/runtime/workspace.ts +62 -0
  87. package/src/storage/indexeddb-statistics.ts +153 -0
  88. package/src/store/attention.svelte.ts +51 -0
  89. package/src/store/conflict-merge.ts +169 -0
  90. package/src/store/entity-sync-state.svelte.ts +36 -0
  91. package/src/store/event-log.svelte.ts +109 -0
  92. package/src/store/events.svelte.ts +14 -0
  93. package/src/store/local-context.ts +93 -0
  94. package/src/store/local-store.svelte.ts +58 -0
  95. package/src/store/log-version.svelte.ts +35 -0
  96. package/src/store/logs.svelte.ts +60 -0
  97. package/src/store/moved.svelte.ts +43 -0
  98. package/src/store/offline-readiness.svelte.ts +188 -0
  99. package/src/store/outbox-request.ts +61 -0
  100. package/src/store/outbox.ts +426 -0
  101. package/src/store/pending-work-transfer.ts +49 -0
  102. package/src/store/pending-work.ts +143 -0
  103. package/src/store/sync-coordinator.ts +170 -0
  104. package/src/store/sync-invalidation.ts +10 -0
  105. package/src/store/sync-issues.ts +406 -0
  106. package/src/store/sync-progress.ts +54 -0
  107. package/src/store/sync-state.ts +127 -0
  108. package/src/store/sync.svelte.ts +1010 -0
  109. package/src/store/tab-coordination.ts +164 -0
  110. package/src/testing/e2e-seed.ts +40 -0
  111. package/src/testing/local-database.ts +120 -0
  112. package/src/testing/projection-database.ts +105 -0
  113. package/src/testing/test-entities.ts +28 -0
  114. package/src/theme/appearance.svelte.ts +111 -0
  115. package/src/utils/address.ts +5 -0
  116. package/src/utils/async.ts +30 -0
  117. package/src/utils/long-press.ts +70 -0
  118. package/src/utils/perf-dataset.ts +405 -0
  119. package/src/utils/perf.ts +627 -0
  120. package/README.md +0 -3
@@ -0,0 +1,638 @@
1
+ import { sql, type RawBuilder } from 'kysely';
2
+ import {
3
+ splitTitleRef,
4
+ type EntityCalendarFilter,
5
+ } from '@hearthlog/contracts/calendar/config';
6
+ import {
7
+ calendarSourceDefinitions,
8
+ type CalendarSourceDefinition,
9
+ } from '@hearthlog/contracts/calendar/registry';
10
+ import type {
11
+ CalendarBand,
12
+ CalendarDay,
13
+ CalendarPointItem,
14
+ CalendarRangeItem,
15
+ CalendarRangeResult,
16
+ CalendarSourceCounts,
17
+ CalendarSourceInfo,
18
+ CalendarWeek,
19
+ } from '@hearthlog/contracts/calendar/types';
20
+ import type { CalendarEvent } from '#generated/schema';
21
+ import type { ReadModelSession } from './read-session.js';
22
+ import {
23
+ visibleCalendarSourceKeys,
24
+ type CalendarSourcePreference,
25
+ } from '../calendar/groups.js';
26
+ import { ENTITY_DEFINITIONS } from '@hearthlog/contracts/event/entity-definitions';
27
+ import { quoteIdentifier } from '../event/projection-builder.js';
28
+ import { tenant } from '@hearthlog/contracts/tenant/config';
29
+ import { withTimeout } from '../utils/async.js';
30
+
31
+ export type { CalendarEvent };
32
+
33
+ const DB_TIMEOUT_MS = 5000;
34
+
35
+ /** One approved entity date, resolved to a queryable table and column set. */
36
+ type ResolvedSource = CalendarSourceDefinition;
37
+
38
+ function quoteColumn(alias: string, name: string): RawBuilder<unknown> {
39
+ return sql.raw(`${quoteIdentifier(alias)}.${quoteIdentifier(name)}`);
40
+ }
41
+
42
+ function valueOf(filter: EntityCalendarFilter): string | number {
43
+ return typeof filter.equals === 'boolean'
44
+ ? filter.equals
45
+ ? 1
46
+ : 0
47
+ : filter.equals;
48
+ }
49
+
50
+ function filterSql(
51
+ alias: string,
52
+ filters: readonly EntityCalendarFilter[] | undefined,
53
+ ): RawBuilder<unknown> {
54
+ if (!filters || filters.length === 0) return sql``;
55
+ const clauses = filters.map(
56
+ (filter) => sql`${quoteColumn(alias, filter.field)} = ${valueOf(filter)}`,
57
+ );
58
+ return sql` AND ${sql.join(clauses, sql` AND `)}`;
59
+ }
60
+
61
+ /**
62
+ * Title resolution: the configured ref, then configured fallbacks, then the
63
+ * first of title/name/label that exists, then the record id. A ref may read a
64
+ * JSON object key (`customer_snapshot#name`); malformed JSON is guarded so a
65
+ * bad snapshot cannot break the month query.
66
+ */
67
+ function titleSql(
68
+ source: ResolvedSource,
69
+ alias: string,
70
+ ): RawBuilder<unknown> {
71
+ const parts: RawBuilder<unknown>[] = [];
72
+ if (source.config.titleField) {
73
+ parts.push(titleRefSql(alias, source.config.titleField));
74
+ }
75
+ for (const ref of source.config.titleFallbacks ?? []) {
76
+ parts.push(titleRefSql(alias, ref));
77
+ }
78
+ for (const fallback of ['title', 'name', 'label']) {
79
+ if (source.fields.some((field) => field.name === fallback)) {
80
+ parts.push(sql`NULLIF(TRIM(${quoteColumn(alias, fallback)}), '')`);
81
+ }
82
+ }
83
+ parts.push(quoteColumn(alias, 'id'));
84
+ return sql`COALESCE(${sql.join(parts, sql`, `)})`;
85
+ }
86
+
87
+ function titleRefSql(alias: string, ref: string): RawBuilder<unknown> {
88
+ const { column, jsonKey } = splitTitleRef(ref);
89
+ const value = quoteColumn(alias, column);
90
+ if (!jsonKey) return sql`NULLIF(TRIM(${value}), '')`;
91
+ // json_extract throws on malformed JSON; guard it like the invoice lists.
92
+ return sql`CASE WHEN json_valid(${value}) THEN NULLIF(TRIM(json_extract(${value}, ${'$.' + jsonKey})), '') END`;
93
+ }
94
+
95
+ function subtitleSql(
96
+ source: ResolvedSource,
97
+ alias: string,
98
+ parentAlias: string,
99
+ ): RawBuilder<unknown> {
100
+ const parts: RawBuilder<unknown>[] = [];
101
+ if (source.config.subtitleField) {
102
+ parts.push(
103
+ sql`NULLIF(TRIM(${quoteColumn(alias, source.config.subtitleField)}), '')`,
104
+ );
105
+ }
106
+ if (source.config.parent) {
107
+ parts.push(
108
+ sql`NULLIF(TRIM(${quoteColumn(parentAlias, source.config.parent.titleField)}), '')`,
109
+ );
110
+ }
111
+ if (parts.length === 0) return sql`''`;
112
+ return sql`COALESCE(${sql.join(parts, sql`, `)}, '')`;
113
+ }
114
+
115
+ function timeSql(
116
+ alias: string,
117
+ field: string | undefined,
118
+ ): RawBuilder<unknown> {
119
+ if (!field) return sql`''`;
120
+ return sql`COALESCE(TRIM(${quoteColumn(alias, field)}), '')`;
121
+ }
122
+
123
+ function pinnedSql(
124
+ source: ResolvedSource,
125
+ alias: string,
126
+ ): RawBuilder<unknown> {
127
+ if (!source.config.pinnedField) return sql`0`;
128
+ return sql`CASE WHEN ${quoteColumn(alias, source.config.pinnedField)} THEN 1 ELSE 0 END`;
129
+ }
130
+
131
+ /** JSON object of configured href params, empty object by default. */
132
+ function paramsSql(
133
+ source: ResolvedSource,
134
+ alias: string,
135
+ ): RawBuilder<unknown> {
136
+ const entries = Object.entries(source.config.params ?? {});
137
+ if (entries.length === 0) return sql`'{}'`;
138
+ const pairs: RawBuilder<unknown>[] = [];
139
+ for (const [param, column] of entries) {
140
+ pairs.push(sql`${param}`);
141
+ pairs.push(
142
+ sql`COALESCE(CAST(${quoteColumn(alias, column)} AS TEXT), '')`,
143
+ );
144
+ }
145
+ return sql`json_object(${sql.join(pairs, sql`, `)})`;
146
+ }
147
+
148
+ function parentJoinSql(
149
+ session: ReadModelSession,
150
+ source: ResolvedSource,
151
+ alias: string,
152
+ ): RawBuilder<unknown> {
153
+ const parent = source.config.parent;
154
+ if (!parent) return sql``;
155
+ return sql`INNER JOIN ${session.table(parent.table)} AS p
156
+ ON ${quoteColumn('p', 'id')} = ${quoteColumn(alias, parent.localField)}
157
+ ${filterSql('p', parent.filters)}`;
158
+ }
159
+
160
+ function sourceArm(
161
+ session: ReadModelSession,
162
+ source: ResolvedSource,
163
+ startDate: string,
164
+ endDateExclusive: string,
165
+ lastDay: string,
166
+ ): RawBuilder<unknown> {
167
+ const alias = 'src';
168
+ const parentAlias = 'p';
169
+ const config = source.config;
170
+ const shared = {
171
+ sourceKey: sql`${source.key}`,
172
+ id: quoteColumn(alias, 'id'),
173
+ title: titleSql(source, alias),
174
+ subtitle: subtitleSql(source, alias, parentAlias),
175
+ params: paramsSql(source, alias),
176
+ };
177
+
178
+ const from = sql`FROM ${session.table(source.table)} AS ${sql.raw(quoteIdentifier(alias))}`;
179
+ const join = parentJoinSql(session, source, alias);
180
+
181
+ if (config.endDateField) {
182
+ // Multi-day source: clip the range to the requested window so the
183
+ // client can place week segments without further SQL.
184
+ const startField = quoteColumn(alias, config.dateField);
185
+ const endField = quoteColumn(alias, config.endDateField);
186
+ return sql`
187
+ SELECT
188
+ ${shared.sourceKey} AS source_key,
189
+ 'range' AS kind,
190
+ ${shared.id} AS source_id,
191
+ MAX(substr(TRIM(${startField}), 1, 10), ${startDate}) AS item_date,
192
+ MIN(substr(TRIM(${endField}), 1, 10), ${lastDay}) AS end_date,
193
+ ${timeSql(alias, config.timeField)} AS start_time,
194
+ ${timeSql(alias, config.endTimeField)} AS end_time,
195
+ ${shared.title} AS title,
196
+ ${shared.subtitle} AS subtitle,
197
+ ${shared.params} AS params,
198
+ 0 AS pinned
199
+ ${from}
200
+ ${join}
201
+ WHERE TRIM(${startField}) <> ''
202
+ AND TRIM(${endField}) <> ''
203
+ AND ${startField} < ${endDateExclusive}
204
+ AND ${endField} >= ${startDate}
205
+ ${filterSql(alias, config.filters)}
206
+ `;
207
+ }
208
+
209
+ const dateField = quoteColumn(alias, config.dateField);
210
+ return sql`
211
+ SELECT
212
+ ${shared.sourceKey} AS source_key,
213
+ 'point' AS kind,
214
+ ${shared.id} AS source_id,
215
+ substr(TRIM(${dateField}), 1, 10) AS item_date,
216
+ NULL AS end_date,
217
+ ${timeSql(alias, config.timeField)} AS start_time,
218
+ ${timeSql(alias, config.endTimeField)} AS end_time,
219
+ ${shared.title} AS title,
220
+ ${shared.subtitle} AS subtitle,
221
+ ${shared.params} AS params,
222
+ ${pinnedSql(source, alias)} AS pinned
223
+ ${from}
224
+ ${join}
225
+ WHERE ${dateField} >= ${startDate}
226
+ AND ${dateField} < ${endDateExclusive}
227
+ ${filterSql(alias, config.filters)}
228
+ `;
229
+ }
230
+
231
+ async function resolveSources(
232
+ session: ReadModelSession,
233
+ ): Promise<CalendarSourceDefinition[]> {
234
+ const dynamicDefinitions = tenant.dynamicEntities
235
+ ? await session
236
+ .selectFrom('entity_type_definitions')
237
+ .selectAll()
238
+ .where('is_archived', '=', 0)
239
+ .execute()
240
+ : [];
241
+ return calendarSourceDefinitions(
242
+ ENTITY_DEFINITIONS,
243
+ dynamicDefinitions,
244
+ {
245
+ approvedEntityNames: tenant.approvedEntities.entityNames,
246
+ approvedTables: tenant.approvedEntities.tables,
247
+ dynamicEnabled: tenant.dynamicEntities,
248
+ },
249
+ );
250
+ }
251
+
252
+ function previousDay(date: string): string {
253
+ return addDays(date, -1);
254
+ }
255
+
256
+ function addDays(date: string, amount: number): string {
257
+ const [year, month, day] = date.split('-').map(Number);
258
+ const value = new Date(Date.UTC(year, month - 1, day + amount));
259
+ return value.toISOString().slice(0, 10);
260
+ }
261
+
262
+ function parseItemParams(value: unknown): Record<string, string> {
263
+ if (typeof value !== 'string' || value.length === 0) return {};
264
+ try {
265
+ const parsed = JSON.parse(value);
266
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
267
+ return {};
268
+ }
269
+ const params: Record<string, string> = {};
270
+ for (const [key, entry] of Object.entries(parsed)) {
271
+ params[key] = String(entry);
272
+ }
273
+ return params;
274
+ } catch {
275
+ return {};
276
+ }
277
+ }
278
+
279
+ function parseDays(rows: ReadonlyArray<{ day: string; items: string }>): CalendarDay[] {
280
+ const days: CalendarDay[] = [];
281
+ for (const row of rows) {
282
+ let rawItems: unknown;
283
+ try {
284
+ rawItems = JSON.parse(row.items);
285
+ } catch {
286
+ rawItems = [];
287
+ }
288
+ if (!Array.isArray(rawItems)) continue;
289
+ days.push({ day: row.day, items: (rawItems as Array<Record<string, unknown>>).map(toPointItem) });
290
+ }
291
+ return days;
292
+ }
293
+
294
+ function toPointItem(raw: Record<string, unknown>): CalendarPointItem {
295
+ return {
296
+ sourceKey: String(raw.sourceKey ?? ''),
297
+ id: String(raw.id ?? ''),
298
+ startTime: String(raw.startTime ?? ''),
299
+ endTime: String(raw.endTime ?? ''),
300
+ title: String(raw.title ?? ''),
301
+ subtitle: String(raw.subtitle ?? ''),
302
+ pinned: Boolean(raw.pinned),
303
+ params: parseItemParams(
304
+ typeof raw.params === 'string'
305
+ ? raw.params
306
+ : JSON.stringify(raw.params ?? {}),
307
+ ),
308
+ };
309
+ }
310
+
311
+ function parseJsonArray(value: unknown): Array<Record<string, unknown>> {
312
+ if (typeof value !== 'string') return [];
313
+ try {
314
+ const parsed = JSON.parse(value);
315
+ return Array.isArray(parsed) ? parsed : [];
316
+ } catch {
317
+ return [];
318
+ }
319
+ }
320
+
321
+ function parseWeeks(rows: ReadonlyArray<{ week_start: string; days: string; bands: string }>): CalendarWeek[] {
322
+ return rows.map((row) => ({
323
+ start: row.week_start,
324
+ days: parseJsonArray(row.days).map((day) => ({
325
+ day: String(day.day ?? ''),
326
+ items: (Array.isArray(day.items) ? day.items as Array<Record<string, unknown>> : []).map(toPointItem),
327
+ lanes: Array.isArray(day.lanes) ? day.lanes.map(Number) : [],
328
+ })),
329
+ bands: parseJsonArray(row.bands).map((band): CalendarBand => ({
330
+ range: {
331
+ sourceKey: String(band.sourceKey ?? ''),
332
+ id: String(band.id ?? ''),
333
+ startDate: String(band.startDate ?? ''),
334
+ endDate: String(band.endDate ?? ''),
335
+ startTime: String(band.startTime ?? ''),
336
+ endTime: String(band.endTime ?? ''),
337
+ title: String(band.title ?? ''),
338
+ subtitle: String(band.subtitle ?? ''),
339
+ params: parseItemParams(JSON.stringify(band.params ?? {})),
340
+ },
341
+ startCol: Number(band.startCol),
342
+ endCol: Number(band.endCol),
343
+ lane: Number(band.lane),
344
+ })),
345
+ }));
346
+ }
347
+
348
+ function toSourceInfo(source: ResolvedSource): CalendarSourceInfo {
349
+ return {
350
+ key: source.key,
351
+ entityName: source.entityName,
352
+ label: source.label,
353
+ dynamic: source.dynamic,
354
+ };
355
+ }
356
+
357
+ export function createCalendarRepository(session: ReadModelSession) {
358
+ async function selectSources(
359
+ sourcePreference: CalendarSourcePreference | null,
360
+ ): Promise<{ resolved: ResolvedSource[]; selected: ResolvedSource[] }> {
361
+ const resolved = await resolveSources(session);
362
+ // The stored preference hides groups; a preference that would hide every
363
+ // available source falls back to showing all of them.
364
+ const visible = visibleCalendarSourceKeys(resolved, sourcePreference);
365
+ const selected = resolved.filter(
366
+ (source) => visible === null || visible.has(source.key),
367
+ );
368
+ return { resolved, selected };
369
+ }
370
+
371
+ function unionSql(
372
+ selected: readonly ResolvedSource[],
373
+ startDate: string,
374
+ endDateExclusive: string,
375
+ ): RawBuilder<unknown> {
376
+ const lastDay = previousDay(endDateExclusive);
377
+ const arms = selected.map((source) => sourceArm(session, source, startDate, endDateExclusive, lastDay));
378
+ return sql`${sql.join(arms, sql` UNION ALL `)}`;
379
+ }
380
+
381
+ async function getCalendarRange(
382
+ startDate: string,
383
+ endDateExclusive: string,
384
+ sourcePreference: CalendarSourcePreference | null,
385
+ ): Promise<CalendarRangeResult> {
386
+ const { resolved, selected } = await selectSources(sourcePreference);
387
+ const sources = resolved.map(toSourceInfo);
388
+ if (selected.length === 0) {
389
+ return { sources, days: [], ranges: [] };
390
+ }
391
+
392
+ const union = unionSql(selected, startDate, endDateExclusive);
393
+
394
+ const [pointRows, rangeRows] = await Promise.all([
395
+ withTimeout(
396
+ session.execute(sql<{ day: string; items: string }>`
397
+ SELECT item_date AS day,
398
+ json_group_array(json_object(
399
+ 'sourceKey', source_key,
400
+ 'id', source_id,
401
+ 'startTime', start_time,
402
+ 'endTime', end_time,
403
+ 'title', title,
404
+ 'subtitle', subtitle,
405
+ 'pinned', pinned,
406
+ 'params', json(params)
407
+ )) AS items
408
+ FROM (
409
+ SELECT * FROM (${union}) AS all_items
410
+ WHERE kind = 'point'
411
+ ORDER BY item_date ASC, start_time ASC, source_key ASC, source_id ASC
412
+ ) AS ordered
413
+ GROUP BY item_date
414
+ ORDER BY day ASC
415
+ `),
416
+ DB_TIMEOUT_MS,
417
+ 'Calendar point query timed out',
418
+ ),
419
+ withTimeout(
420
+ session.execute(sql<{
421
+ source_key: string;
422
+ source_id: string;
423
+ item_date: string;
424
+ end_date: string;
425
+ start_time: string;
426
+ end_time: string;
427
+ title: string;
428
+ subtitle: string;
429
+ params: string;
430
+ }>`
431
+ SELECT * FROM (${union}) AS all_items
432
+ WHERE kind = 'range'
433
+ ORDER BY item_date ASC, end_date ASC, start_time ASC,
434
+ source_key ASC, source_id ASC
435
+ `),
436
+ DB_TIMEOUT_MS,
437
+ 'Calendar range query timed out',
438
+ ),
439
+ ]);
440
+
441
+ const ranges: CalendarRangeItem[] = rangeRows.rows.map((row) => ({
442
+ sourceKey: row.source_key,
443
+ id: row.source_id,
444
+ startDate: row.item_date,
445
+ endDate: row.end_date,
446
+ startTime: row.start_time,
447
+ endTime: row.end_time,
448
+ title: row.title,
449
+ subtitle: row.subtitle,
450
+ params: parseItemParams(row.params),
451
+ }));
452
+
453
+ return { sources, days: parseDays(pointRows.rows), ranges };
454
+ }
455
+
456
+ /** Every enabled source, regardless of the active filter. */
457
+ async function getCalendarSources(): Promise<CalendarSourceInfo[]> {
458
+ return (await resolveSources(session)).map(toSourceInfo);
459
+ }
460
+
461
+ /**
462
+ * Whole Monday-first weeks for the endless calendar, one row per week
463
+ * start given. Each week holds its seven days with their point items,
464
+ * and its multi-day items as bands: the columns they cover and a lane
465
+ * each, in the order `getCalendarRange` lists ranges. Every day also
466
+ * lists the lanes covering it, so nothing is filtered on the client.
467
+ */
468
+ async function getCalendarWeeks(
469
+ weekStarts: readonly string[],
470
+ sourcePreference: CalendarSourcePreference | null,
471
+ ): Promise<CalendarWeek[]> {
472
+ if (weekStarts.length === 0) return [];
473
+ const sorted = [...weekStarts].sort();
474
+ const { selected } = await selectSources(sourcePreference);
475
+ const items = selected.length > 0
476
+ ? unionSql(selected, sorted[0], addDays(sorted[sorted.length - 1], 7))
477
+ : sql`SELECT '' AS source_key, '' AS kind, '' AS source_id, '' AS item_date, '' AS end_date,
478
+ '' AS start_time, '' AS end_time, '' AS title, '' AS subtitle, '{}' AS params, 0 AS pinned
479
+ WHERE 0`;
480
+ const weeks = sql.join(sorted.map((week) => sql`(${week})`), sql`, `);
481
+ const result = await withTimeout(
482
+ session.execute(sql<{ week_start: string; days: string; bands: string }>`
483
+ WITH RECURSIVE
484
+ weeks(week_start) AS (VALUES ${weeks}),
485
+ cols(col) AS (SELECT 0 UNION ALL SELECT col + 1 FROM cols WHERE col < 6),
486
+ all_items AS (${items}),
487
+ points AS (
488
+ SELECT item_date AS day,
489
+ json_group_array(json_object(
490
+ 'sourceKey', source_key,
491
+ 'id', source_id,
492
+ 'startTime', start_time,
493
+ 'endTime', end_time,
494
+ 'title', title,
495
+ 'subtitle', subtitle,
496
+ 'pinned', pinned,
497
+ 'params', json(params)
498
+ ) ORDER BY start_time, source_key, source_id) AS items
499
+ FROM all_items
500
+ WHERE kind = 'point'
501
+ GROUP BY item_date
502
+ ),
503
+ bands AS (
504
+ SELECT w.week_start, r.*,
505
+ MAX(0, CAST(julianday(r.item_date) - julianday(w.week_start) AS INTEGER)) AS start_col,
506
+ MIN(6, CAST(julianday(r.end_date) - julianday(w.week_start) AS INTEGER)) AS end_col,
507
+ ROW_NUMBER() OVER (
508
+ PARTITION BY w.week_start
509
+ ORDER BY r.item_date, r.end_date, r.start_time, r.source_key, r.source_id
510
+ ) - 1 AS lane
511
+ FROM weeks AS w
512
+ JOIN all_items AS r
513
+ ON r.kind = 'range'
514
+ AND r.item_date < date(w.week_start, '+7 days')
515
+ AND r.end_date >= w.week_start
516
+ ),
517
+ lanes AS (
518
+ SELECT b.week_start, c.col, json_group_array(b.lane ORDER BY b.lane) AS lanes
519
+ FROM bands AS b
520
+ JOIN cols AS c ON c.col BETWEEN b.start_col AND b.end_col
521
+ GROUP BY b.week_start, c.col
522
+ ),
523
+ week_days AS (
524
+ SELECT w.week_start,
525
+ json_group_array(json_object(
526
+ 'day', date(w.week_start, '+' || c.col || ' days'),
527
+ 'items', json(COALESCE(p.items, '[]')),
528
+ 'lanes', json(COALESCE(l.lanes, '[]'))
529
+ ) ORDER BY c.col) AS days
530
+ FROM weeks AS w
531
+ CROSS JOIN cols AS c
532
+ LEFT JOIN points AS p ON p.day = date(w.week_start, '+' || c.col || ' days')
533
+ LEFT JOIN lanes AS l ON l.week_start = w.week_start AND l.col = c.col
534
+ GROUP BY w.week_start
535
+ ),
536
+ week_bands AS (
537
+ SELECT week_start,
538
+ json_group_array(json_object(
539
+ 'sourceKey', source_key,
540
+ 'id', source_id,
541
+ 'startDate', item_date,
542
+ 'endDate', end_date,
543
+ 'startTime', start_time,
544
+ 'endTime', end_time,
545
+ 'title', title,
546
+ 'subtitle', subtitle,
547
+ 'params', json(params),
548
+ 'startCol', start_col,
549
+ 'endCol', end_col,
550
+ 'lane', lane
551
+ ) ORDER BY lane) AS bands
552
+ FROM bands
553
+ GROUP BY week_start
554
+ )
555
+ SELECT d.week_start, d.days, COALESCE(b.bands, '[]') AS bands
556
+ FROM week_days AS d
557
+ LEFT JOIN week_bands AS b ON b.week_start = d.week_start
558
+ ORDER BY d.week_start
559
+ `),
560
+ DB_TIMEOUT_MS,
561
+ 'Calendar week query timed out',
562
+ );
563
+ return parseWeeks(result.rows);
564
+ }
565
+
566
+ /**
567
+ * Count visible items per source in a window: points dated inside it and
568
+ * multi-day ranges that overlap it, with the same source preference as
569
+ * `getCalendarRange`.
570
+ */
571
+ async function getCalendarSourceCounts(
572
+ startDate: string,
573
+ endDateExclusive: string,
574
+ sourcePreference: CalendarSourcePreference | null,
575
+ ): Promise<CalendarSourceCounts> {
576
+ const { selected } = await selectSources(sourcePreference);
577
+ if (selected.length === 0) return { total: 0, sources: [] };
578
+ const result = await withTimeout(
579
+ session.execute(sql<{ source_key: string; item_count: number; total: number }>`
580
+ SELECT source_key,
581
+ COUNT(*) AS item_count,
582
+ SUM(COUNT(*)) OVER () AS total
583
+ FROM (${unionSql(selected, startDate, endDateExclusive)}) AS all_items
584
+ GROUP BY source_key
585
+ ORDER BY item_count DESC, source_key ASC
586
+ `),
587
+ DB_TIMEOUT_MS,
588
+ 'Calendar count query timed out',
589
+ );
590
+ return {
591
+ total: Number(result.rows[0]?.total ?? 0),
592
+ sources: result.rows.map((row) => ({
593
+ sourceKey: row.source_key,
594
+ count: Number(row.item_count),
595
+ })),
596
+ };
597
+ }
598
+
599
+ return {
600
+ getCalendarRange,
601
+ getCalendarSources,
602
+ getCalendarWeeks,
603
+ getCalendarSourceCounts,
604
+
605
+ getCalendarEvent(id: string): Promise<CalendarEvent | undefined> {
606
+ return withTimeout(
607
+ session
608
+ .selectFrom('calendar_events')
609
+ .selectAll()
610
+ .where('id', '=', id)
611
+ .executeTakeFirst(),
612
+ DB_TIMEOUT_MS,
613
+ 'Calendar event lookup timed out',
614
+ );
615
+ },
616
+
617
+ getUpcomingPinnedCalendarEvents(
618
+ today: string,
619
+ limit?: number,
620
+ ): Promise<CalendarEvent[]> {
621
+ let query = session
622
+ .selectFrom('calendar_events')
623
+ .selectAll()
624
+ .where('pinned', '=', true)
625
+ .where('event_date', '>=', today)
626
+ .orderBy('event_date', 'asc')
627
+ .orderBy('start_time', 'asc')
628
+ .orderBy('title', 'asc')
629
+ .orderBy('id', 'asc');
630
+ if (limit !== undefined) query = query.limit(limit);
631
+ return withTimeout(
632
+ query.execute(),
633
+ DB_TIMEOUT_MS,
634
+ 'Pinned calendar event query timed out',
635
+ );
636
+ },
637
+ };
638
+ }