@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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- 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
|
+
}
|