@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,89 @@
1
+ import { AccessSessionExpiredError, fetchAccessProtected } from '../access/session.js';
2
+ import { requireActiveContext } from '../db/index.js';
3
+ import { isAiCapabilityEnabled } from '@hearthlog/contracts/tenant/config';
4
+ import { withTimeout } from '../utils/async.js';
5
+ import {
6
+ MAX_DECISION_ITEMS,
7
+ type AiCapability,
8
+ type Decision,
9
+ type DecisionResponse,
10
+ } from '@hearthlog/contracts/ai/capabilities';
11
+
12
+ /**
13
+ * Client for the same-origin decision route. It never throws: offline, a site
14
+ * without the capability, a 503, a timeout or an unusable answer all return no
15
+ * decisions (a partial batch keeps what was answered), so callers show no
16
+ * suggestion and the manual path is unchanged.
17
+ */
18
+
19
+ const TIMEOUT_MS = 30_000;
20
+
21
+ /** What the client got back. `answered` is false when the model could not be asked or failed. */
22
+ export interface DecisionResult extends DecisionResponse {
23
+ answered: boolean;
24
+ }
25
+
26
+ const NONE: DecisionResult = { model: '', decisions: [], answered: false };
27
+
28
+ export async function requestDecisions(
29
+ capability: AiCapability,
30
+ items: ReadonlyArray<{ id: string }>,
31
+ ): Promise<DecisionResult> {
32
+ if (items.length === 0 || !isAiCapabilityEnabled(capability)) return NONE;
33
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) return NONE;
34
+ let logId: string;
35
+ try {
36
+ logId = (await requireActiveContext()).logId;
37
+ } catch {
38
+ return NONE;
39
+ }
40
+
41
+ let model = '';
42
+ let answered = false;
43
+ const decisions: Decision[] = [];
44
+ for (let start = 0; start < items.length; start += MAX_DECISION_ITEMS) {
45
+ const chunk = items.slice(start, start + MAX_DECISION_ITEMS);
46
+ const reply = await requestChunk(logId, capability, chunk);
47
+ // A failed chunk ends the batch: later ones would most likely fail too.
48
+ if (!reply) break;
49
+ model = reply.model;
50
+ decisions.push(...reply.decisions);
51
+ answered = true;
52
+ }
53
+ return { model, decisions, answered };
54
+ }
55
+
56
+ async function requestChunk(logId: string, capability: AiCapability, items: unknown[]): Promise<DecisionResponse | null> {
57
+ try {
58
+ const response = await withTimeout(
59
+ fetchAccessProtected(`/api/ai/v1/logs/${logId}/decisions/${capability}`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ items }),
63
+ }),
64
+ TIMEOUT_MS,
65
+ 'AI decision request timed out',
66
+ );
67
+ if (!response.ok) return null;
68
+ return parseResponse(await response.json(), items.length);
69
+ } catch (error) {
70
+ if (!(error instanceof AccessSessionExpiredError)) console.warn('[AI] Decision request failed');
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function parseResponse(payload: unknown, expected: number): DecisionResponse | null {
76
+ const record = payload as Partial<DecisionResponse> | null;
77
+ if (!record || !Array.isArray(record.decisions) || record.decisions.length !== expected) return null;
78
+ const decisions: Decision[] = [];
79
+ for (const entry of record.decisions) {
80
+ if (
81
+ !entry || typeof entry.id !== 'string' || typeof entry.option !== 'string' ||
82
+ typeof entry.probability !== 'number' || !Number.isFinite(entry.probability)
83
+ ) {
84
+ return null;
85
+ }
86
+ decisions.push({ id: entry.id, option: entry.option, probability: entry.probability });
87
+ }
88
+ return { model: typeof record.model === 'string' ? record.model : '', decisions };
89
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Draft helpers for the dynamic entity editor's calendar section.
3
+ *
4
+ * The editor edits one calendar date per dynamic entity (the stored config is
5
+ * still an array, so hand-written events may declare several). These helpers
6
+ * convert between the stored sources and the flat form state, and validate
7
+ * against the entity's field list.
8
+ */
9
+
10
+ import {
11
+ validateCalendarConfig,
12
+ type CalendarFieldLike,
13
+ type EntityCalendarSource,
14
+ } from '@hearthlog/contracts/calendar/config';
15
+
16
+ export interface CalendarSourceDraft {
17
+ enabled: boolean;
18
+ dateField: string;
19
+ endDateField: string;
20
+ timeField: string;
21
+ titleField: string;
22
+ label: string;
23
+ }
24
+
25
+ export function emptyCalendarDraft(): CalendarSourceDraft {
26
+ return {
27
+ enabled: false,
28
+ dateField: '',
29
+ endDateField: '',
30
+ timeField: '',
31
+ titleField: '',
32
+ label: '',
33
+ };
34
+ }
35
+
36
+ export function draftFromCalendar(
37
+ calendar: readonly EntityCalendarSource[] | null | undefined,
38
+ ): CalendarSourceDraft {
39
+ const source = calendar?.[0];
40
+ if (!source) return emptyCalendarDraft();
41
+ return {
42
+ enabled: true,
43
+ dateField: source.dateField,
44
+ endDateField: source.endDateField ?? '',
45
+ timeField: source.timeField ?? '',
46
+ titleField: source.titleField ?? '',
47
+ label: source.label,
48
+ };
49
+ }
50
+
51
+ export function calendarFromDraft(
52
+ draft: CalendarSourceDraft,
53
+ ): EntityCalendarSource[] {
54
+ if (!draft.enabled || !draft.dateField) return [];
55
+ return [
56
+ {
57
+ dateField: draft.dateField,
58
+ label: draft.label.trim() || 'Event',
59
+ ...(draft.endDateField ? { endDateField: draft.endDateField } : {}),
60
+ ...(draft.timeField ? { timeField: draft.timeField } : {}),
61
+ ...(draft.titleField ? { titleField: draft.titleField } : {}),
62
+ },
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * First validation error for the draft, or null when it is valid. Disabled or
68
+ * empty drafts are always valid (the entity simply has no calendar date).
69
+ */
70
+ export function calendarDraftError(
71
+ draft: CalendarSourceDraft,
72
+ fields: readonly CalendarFieldLike[],
73
+ ): string | null {
74
+ if (!draft.enabled) return null;
75
+ if (!draft.dateField) return 'Choose a date field';
76
+ if (!draft.label.trim()) return 'Add a calendar label';
77
+ const errors = validateCalendarConfig(
78
+ 'draft',
79
+ calendarFromDraft(draft),
80
+ fields,
81
+ { parentTables: new Set<string>() },
82
+ );
83
+ return errors[0] ?? null;
84
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Client for the Worker calendar-feed registry.
3
+ *
4
+ * Every call is same-origin and Access-authenticated. The feed token is only
5
+ * ever returned by `createCalendarFeed`; the listing never includes it.
6
+ */
7
+
8
+ export interface CalendarFeedWindow {
9
+ pastDays: number;
10
+ futureDays: number;
11
+ }
12
+
13
+ export interface CalendarFeedSummary {
14
+ feedId: string;
15
+ appId: string;
16
+ logId: string;
17
+ label: string;
18
+ /** Source keys, or null for "all sources". */
19
+ sources: string[] | null;
20
+ windowDays: CalendarFeedWindow;
21
+ createdBy: string;
22
+ createdAt: string;
23
+ lastAccessedAt: string | null;
24
+ expiresAt: string | null;
25
+ }
26
+
27
+ export interface CreatedCalendarFeed {
28
+ feed: CalendarFeedSummary;
29
+ token: string;
30
+ url: string;
31
+ }
32
+
33
+ export interface CalendarFeedDraft {
34
+ label: string;
35
+ sources: string[] | null;
36
+ windowDays: CalendarFeedWindow;
37
+ }
38
+
39
+ const ENDPOINT = '/api/calendar-feeds/v1';
40
+
41
+ export function createCalendarFeed(
42
+ logId: string,
43
+ draft: CalendarFeedDraft,
44
+ ): Promise<CreatedCalendarFeed> {
45
+ return request<CreatedCalendarFeed>(ENDPOINT, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ logId, ...draft }),
49
+ });
50
+ }
51
+
52
+ export async function listCalendarFeeds(logId: string): Promise<CalendarFeedSummary[]> {
53
+ const body = await request<{ feeds: CalendarFeedSummary[] }>(
54
+ `${ENDPOINT}?logId=${encodeURIComponent(logId)}`,
55
+ );
56
+ return body.feeds;
57
+ }
58
+
59
+ export function revokeCalendarFeed(logId: string, feedId: string): Promise<void> {
60
+ return request<void>(
61
+ `${ENDPOINT}/${encodeURIComponent(feedId)}?logId=${encodeURIComponent(logId)}`,
62
+ { method: 'DELETE' },
63
+ );
64
+ }
65
+
66
+ /** Convert the https feed URL into a `webcal://` URL for one-click subscribe. */
67
+ export function webcalUrl(url: string): string {
68
+ return url.replace(/^https?:/, 'webcal:');
69
+ }
70
+
71
+ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
72
+ const response = await fetch(path, { credentials: 'same-origin', ...init });
73
+ if (!response.ok) {
74
+ let message = `Calendar feed request failed (${response.status})`;
75
+ try {
76
+ const body: unknown = await response.json();
77
+ if (
78
+ body &&
79
+ typeof body === 'object' &&
80
+ typeof (body as { error?: unknown }).error === 'string'
81
+ ) {
82
+ message = (body as { error: string }).error;
83
+ }
84
+ } catch {
85
+ // Keep the status-based message.
86
+ }
87
+ throw new Error(message);
88
+ }
89
+ if (response.status === 204) return undefined as T;
90
+ return (await response.json()) as T;
91
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Calendar source groups for the page filter and the feed picker.
3
+ *
4
+ * Dates that static definitions group share a menu row. A source that is not in a built-in group
5
+ * (a runtime-defined entity, or a future static date) is its own row.
6
+ * Preferences remember what the user turned off, so a new source stays visible.
7
+ */
8
+
9
+ import { ENTITY_DEFINITIONS } from '@hearthlog/contracts/event/entity-definitions';
10
+ import { calendarSourceKey, normalizeCalendarSources } from '@hearthlog/contracts/calendar/config';
11
+
12
+ export interface CalendarSourceRef {
13
+ key: string;
14
+ label: string;
15
+ dynamic: boolean;
16
+ }
17
+
18
+ /** What the user turned off. Absence, or both lists empty, means show everything. */
19
+ export interface CalendarSourcePreference {
20
+ hiddenGroups: string[];
21
+ hiddenSources: string[];
22
+ }
23
+
24
+ const EMPTY_CALENDAR_SOURCE_PREFERENCE: CalendarSourcePreference = {
25
+ hiddenGroups: [],
26
+ hiddenSources: [],
27
+ };
28
+
29
+ export interface CalendarMenuEntry {
30
+ id: string;
31
+ label: string;
32
+ kind: 'group' | 'source';
33
+ /** Source keys this row controls. Only keys present in the current log. */
34
+ sourceKeys: string[];
35
+ icon: string;
36
+ }
37
+
38
+ /**
39
+ * The menu rows static definitions declare (`group` on a calendar source), in
40
+ * the order of their first source's rank (Events, Transactions, Invoices).
41
+ */
42
+ const GROUPS: ReadonlyArray<{
43
+ id: string;
44
+ label: string;
45
+ keys: readonly string[];
46
+ icon: string;
47
+ }> = (() => {
48
+ const groups = new Map<string, { id: string; label: string; keys: string[]; icon: string; rank: number }>();
49
+ for (const definition of ENTITY_DEFINITIONS) {
50
+ for (const source of normalizeCalendarSources(definition.calendar)) {
51
+ if (!source.group) continue;
52
+ const entry = groups.get(source.group.id) ?? { ...source.group, keys: [], rank: Infinity };
53
+ entry.keys.push(calendarSourceKey(definition.name, source));
54
+ entry.rank = Math.min(entry.rank, source.presentation?.rank ?? Infinity);
55
+ groups.set(source.group.id, entry);
56
+ }
57
+ }
58
+ return [...groups.values()].sort((a, b) => a.rank - b.rank);
59
+ })();
60
+
61
+ const GROUP_BY_KEY = new Map<string, (typeof GROUPS)[number]>();
62
+ for (const group of GROUPS) {
63
+ for (const key of group.keys) GROUP_BY_KEY.set(key, group);
64
+ }
65
+
66
+ export function sanitizeCalendarSourcePreference(value: unknown): CalendarSourcePreference {
67
+ if (!value || typeof value !== 'object') return EMPTY_CALENDAR_SOURCE_PREFERENCE;
68
+ const record = value as Record<string, unknown>;
69
+ return {
70
+ hiddenGroups: stringList(record.hiddenGroups),
71
+ hiddenSources: stringList(record.hiddenSources),
72
+ };
73
+ }
74
+
75
+ function stringList(value: unknown): string[] {
76
+ if (!Array.isArray(value)) return [];
77
+ const seen = new Set<string>();
78
+ for (const item of value) {
79
+ if (typeof item === 'string' && item.length > 0) seen.add(item);
80
+ }
81
+ return [...seen];
82
+ }
83
+
84
+ export function calendarMenuEntries(
85
+ sources: readonly CalendarSourceRef[],
86
+ ): CalendarMenuEntry[] {
87
+ const present = new Set(sources.map((source) => source.key));
88
+ const entries: CalendarMenuEntry[] = [];
89
+ for (const group of GROUPS) {
90
+ const sourceKeys = group.keys.filter((key) => present.has(key));
91
+ if (sourceKeys.length === 0) continue;
92
+ entries.push({
93
+ id: group.id,
94
+ label: group.label,
95
+ kind: 'group',
96
+ sourceKeys,
97
+ icon: group.icon,
98
+ });
99
+ }
100
+ for (const source of sources) {
101
+ if (GROUP_BY_KEY.has(source.key)) continue;
102
+ entries.push({
103
+ id: source.key,
104
+ label: source.label,
105
+ kind: 'source',
106
+ sourceKeys: [source.key],
107
+ icon: 'calendar-blank-outline',
108
+ });
109
+ }
110
+ return entries;
111
+ }
112
+
113
+ function isEntryHidden(
114
+ entry: CalendarMenuEntry,
115
+ preference: CalendarSourcePreference,
116
+ ): boolean {
117
+ return entry.kind === 'group'
118
+ ? preference.hiddenGroups.includes(entry.id)
119
+ : preference.hiddenSources.includes(entry.id);
120
+ }
121
+
122
+ export function isCalendarMenuEntryVisible(
123
+ entry: CalendarMenuEntry,
124
+ preference: CalendarSourcePreference,
125
+ ): boolean {
126
+ return !isEntryHidden(entry, preference);
127
+ }
128
+
129
+ /**
130
+ * Preference the menu and the query should agree on. A saved filter that hides
131
+ * every source currently available is treated as "show all" so the month is
132
+ * never blank because a feature was turned off.
133
+ */
134
+ export function effectiveCalendarSourcePreference(
135
+ sources: readonly CalendarSourceRef[],
136
+ preference: CalendarSourcePreference | null,
137
+ ): CalendarSourcePreference {
138
+ const stored = sanitizeCalendarSourcePreference(preference);
139
+ const entries = calendarMenuEntries(sources);
140
+ if (entries.length === 0) return EMPTY_CALENDAR_SOURCE_PREFERENCE;
141
+ const visible = entries.filter((entry) => !isEntryHidden(entry, stored));
142
+ if (visible.length === 0) return EMPTY_CALENDAR_SOURCE_PREFERENCE;
143
+ return stored;
144
+ }
145
+
146
+ /**
147
+ * Source keys to query. `null` means every source — the repository then omits
148
+ * the filter instead of enumerating keys.
149
+ */
150
+ export function visibleCalendarSourceKeys(
151
+ sources: readonly CalendarSourceRef[],
152
+ preference: CalendarSourcePreference | null,
153
+ ): Set<string> | null {
154
+ const effective = effectiveCalendarSourcePreference(sources, preference);
155
+ if (effective.hiddenGroups.length === 0 && effective.hiddenSources.length === 0) {
156
+ return null;
157
+ }
158
+ const keys = new Set<string>();
159
+ for (const entry of calendarMenuEntries(sources)) {
160
+ if (isEntryHidden(entry, effective)) continue;
161
+ for (const key of entry.sourceKeys) keys.add(key);
162
+ }
163
+ if (keys.size === 0 || keys.size === sources.length) return null;
164
+ return keys;
165
+ }
166
+
167
+ /**
168
+ * Toggle one menu row. Returns null when the click would hide the last visible
169
+ * row or the id is unknown. Operates on the preference the menu is showing.
170
+ */
171
+ export function toggleCalendarMenuEntry(
172
+ sources: readonly CalendarSourceRef[],
173
+ preference: CalendarSourcePreference | null,
174
+ entryId: string,
175
+ ): CalendarSourcePreference | null {
176
+ const current = effectiveCalendarSourcePreference(sources, preference);
177
+ const entries = calendarMenuEntries(sources);
178
+ const entry = entries.find((item) => item.id === entryId);
179
+ if (!entry) return null;
180
+ const visibleCount = entries.filter((item) => !isEntryHidden(item, current)).length;
181
+ const hidden = isEntryHidden(entry, current);
182
+ if (!hidden && visibleCount <= 1) return null;
183
+
184
+ if (entry.kind === 'group') {
185
+ return {
186
+ hiddenGroups: hidden
187
+ ? current.hiddenGroups.filter((id) => id !== entry.id)
188
+ : [...new Set([...current.hiddenGroups, entry.id])],
189
+ hiddenSources: [...current.hiddenSources],
190
+ };
191
+ }
192
+ return {
193
+ hiddenGroups: [...current.hiddenGroups],
194
+ hiddenSources: hidden
195
+ ? current.hiddenSources.filter((id) => id !== entry.id)
196
+ : [...new Set([...current.hiddenSources, entry.id])],
197
+ };
198
+ }
@@ -0,0 +1,141 @@
1
+ import { differenceInCalendarDays } from "date-fns";
2
+
3
+ export interface CalendarEventDraft {
4
+ title: string;
5
+ eventDate: string;
6
+ startTime: string;
7
+ notes: string;
8
+ pinned: boolean;
9
+ }
10
+
11
+ export interface CalendarMonth {
12
+ year: number;
13
+ monthIndex: number;
14
+ key: string;
15
+ }
16
+
17
+ const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
18
+ const MONTH_PATTERN = /^(\d{4})-(\d{2})$/;
19
+ const TIME_PATTERN = /^(\d{2}):(\d{2})$/;
20
+
21
+ function pad(value: number): string {
22
+ return String(value).padStart(2, "0");
23
+ }
24
+
25
+ export function toLocalDateString(date = new Date()): string {
26
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
27
+ }
28
+
29
+ export function isValidLocalDate(value: string): boolean {
30
+ const match = value.match(DATE_PATTERN);
31
+ if (!match) return false;
32
+ const year = Number(match[1]);
33
+ const month = Number(match[2]);
34
+ const day = Number(match[3]);
35
+ const parsed = new Date(year, month - 1, day, 12);
36
+ return parsed.getFullYear() === year &&
37
+ parsed.getMonth() === month - 1 &&
38
+ parsed.getDate() === day;
39
+ }
40
+
41
+ export function isValidStartTime(value: string): boolean {
42
+ if (value === "") return true;
43
+ const match = value.match(TIME_PATTERN);
44
+ return Boolean(match && Number(match[1]) <= 23 && Number(match[2]) <= 59);
45
+ }
46
+
47
+ export function validateCalendarEventDraft(
48
+ draft: CalendarEventDraft,
49
+ ): Record<string, string> {
50
+ const errors: Record<string, string> = {};
51
+ if (!draft.title.trim()) errors.title = "Enter an event title.";
52
+ if (!isValidLocalDate(draft.eventDate)) {
53
+ errors.eventDate = "Choose a valid event date.";
54
+ }
55
+ if (!isValidStartTime(draft.startTime)) {
56
+ errors.startTime = "Enter a valid time.";
57
+ }
58
+ return errors;
59
+ }
60
+
61
+ export function parseCalendarMonth(
62
+ value: string | null | undefined,
63
+ fallback = new Date(),
64
+ ): CalendarMonth {
65
+ const match = value?.match(MONTH_PATTERN);
66
+ const year = match ? Number(match[1]) : fallback.getFullYear();
67
+ const month = match ? Number(match[2]) : fallback.getMonth() + 1;
68
+ const valid = Boolean(match && year >= 1 && month >= 1 && month <= 12);
69
+ const resolvedYear = valid ? year : fallback.getFullYear();
70
+ const resolvedMonth = valid ? month : fallback.getMonth() + 1;
71
+ return {
72
+ year: resolvedYear,
73
+ monthIndex: resolvedMonth - 1,
74
+ key: `${pad(resolvedYear)}-${pad(resolvedMonth)}`,
75
+ };
76
+ }
77
+
78
+ export function calendarMonthBounds(month: CalendarMonth): {
79
+ startDate: string;
80
+ endDateExclusive: string;
81
+ } {
82
+ const next = month.monthIndex === 11
83
+ ? { year: month.year + 1, month: 1 }
84
+ : { year: month.year, month: month.monthIndex + 2 };
85
+ return {
86
+ startDate: `${pad(month.year)}-${pad(month.monthIndex + 1)}-01`,
87
+ endDateExclusive: `${pad(next.year)}-${pad(next.month)}-01`,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Bounds of the visible month grid, Monday-aligned and padded to whole weeks.
93
+ * The calendar renders leading and trailing days, so items must be fetched for
94
+ * the grid span rather than the month.
95
+ */
96
+ export function calendarGridBounds(month: CalendarMonth): {
97
+ startDate: string;
98
+ endDateExclusive: string;
99
+ } {
100
+ const firstDay = new Date(month.year, month.monthIndex, 1, 12);
101
+ const offset = (firstDay.getDay() + 6) % 7;
102
+ const daysInMonth = new Date(month.year, month.monthIndex + 1, 0).getDate();
103
+ const weeks = Math.ceil((offset + daysInMonth) / 7);
104
+ const start = new Date(month.year, month.monthIndex, 1 - offset, 12);
105
+ const end = new Date(
106
+ start.getFullYear(),
107
+ start.getMonth(),
108
+ start.getDate() + weeks * 7,
109
+ 12,
110
+ );
111
+ return {
112
+ startDate: toLocalDateString(start),
113
+ endDateExclusive: toLocalDateString(end),
114
+ };
115
+ }
116
+
117
+ function parseLocalDate(value: string): Date {
118
+ const [year, month, day] = value.split("-").map(Number);
119
+ return new Date(year, month - 1, day, 12);
120
+ }
121
+
122
+ export function daysUntilCalendarEvent(
123
+ eventDate: string,
124
+ today = new Date(),
125
+ ): number {
126
+ const localToday = new Date(
127
+ today.getFullYear(),
128
+ today.getMonth(),
129
+ today.getDate(),
130
+ 12,
131
+ );
132
+ return differenceInCalendarDays(parseLocalDate(eventDate), localToday);
133
+ }
134
+
135
+ export function describeEventCountdown(daysUntil: number): string {
136
+ if (daysUntil === 0) return "Today";
137
+ if (daysUntil === 1) return "Tomorrow";
138
+ if (daysUntil > 1) return `${daysUntil} days left`;
139
+ if (daysUntil === -1) return "1 day ago";
140
+ return `${Math.abs(daysUntil)} days ago`;
141
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Per-log calendar source filter, stored in the existing IndexedDB key-value
3
+ * store. This is a view preference, not log data, so it is not an event and
4
+ * does not sync.
5
+ */
6
+
7
+ import { del, get, set } from '../db/kv.js';
8
+ import { tenant } from '@hearthlog/contracts/tenant/config';
9
+ import {
10
+ sanitizeCalendarSourcePreference,
11
+ type CalendarSourcePreference,
12
+ } from './groups.js';
13
+
14
+ export function calendarSourcePreferenceKey(tenantId: string, logId: string): string {
15
+ return `calendar-sources:${tenantId}:${logId}`;
16
+ }
17
+
18
+ export async function getCalendarSourcePreference(
19
+ logId: string,
20
+ ): Promise<CalendarSourcePreference | null> {
21
+ if (!logId) return null;
22
+ const stored = await get<unknown>(calendarSourcePreferenceKey(tenant.id, logId));
23
+ if (stored == null) return null;
24
+ return sanitizeCalendarSourcePreference(stored);
25
+ }
26
+
27
+ export async function setCalendarSourcePreference(
28
+ logId: string,
29
+ preference: CalendarSourcePreference,
30
+ ): Promise<void> {
31
+ const key = calendarSourcePreferenceKey(tenant.id, logId);
32
+ if (preference.hiddenGroups.length === 0 && preference.hiddenSources.length === 0) {
33
+ await del(key);
34
+ return;
35
+ }
36
+ await set(key, {
37
+ hiddenGroups: preference.hiddenGroups,
38
+ hiddenSources: preference.hiddenSources,
39
+ } satisfies CalendarSourcePreference);
40
+ }
@@ -0,0 +1,56 @@
1
+ import { monthKeyOf, shiftDay, shiftWeek, weekOffset, weekStartOf } from '@hearthlog/ui/utils/endless-axis.js';
2
+
3
+ /** Weeks read together, so scrolling reads a block at a time instead of a week. */
4
+ export const CHUNK_WEEKS = 6;
5
+
6
+ /** Weeks listed either side of today's week; a hundred years each way feels endless. */
7
+ export const WEEK_SPAN = 5218;
8
+
9
+ /** A Monday that chunks count from, so every visit reads the same blocks. */
10
+ const CHUNK_EPOCH = '2000-01-03';
11
+
12
+ /** The given weeks widened to whole chunks, sorted. */
13
+ export function weekChunks(weekStarts: readonly string[]): string[] {
14
+ const weeks = new Set<string>();
15
+ for (const week of weekStarts) {
16
+ const first = shiftWeek(CHUNK_EPOCH, Math.floor(weekOffset(week, CHUNK_EPOCH) / CHUNK_WEEKS) * CHUNK_WEEKS);
17
+ for (let index = 0; index < CHUNK_WEEKS; index += 1) weeks.add(shiftWeek(first, index));
18
+ }
19
+ return [...weeks].sort();
20
+ }
21
+
22
+ /** The month with the most days in the given weeks; the earlier month wins a tie. */
23
+ export function monthInView(weekStarts: readonly string[]): string {
24
+ const days = new Map<string, number>();
25
+ for (const week of weekStarts) {
26
+ for (let offset = 0; offset < 7; offset += 1) {
27
+ const month = monthKeyOf(shiftDay(week, offset));
28
+ days.set(month, (days.get(month) ?? 0) + 1);
29
+ }
30
+ }
31
+ let best = '';
32
+ let most = 0;
33
+ for (const [month, count] of days) {
34
+ if (count > most) {
35
+ best = month;
36
+ most = count;
37
+ }
38
+ }
39
+ return best;
40
+ }
41
+
42
+ /**
43
+ * Where a month boundary runs along a day's cell: above each of a month's
44
+ * first seven days, whose cell above belongs to the month before, and
45
+ * before its first day unless that is a Monday. Together they draw one
46
+ * stepped line between months.
47
+ */
48
+ export function monthEdges(day: string): { top: boolean; start: boolean } {
49
+ const date = Number(day.slice(8, 10));
50
+ return { top: date <= 7, start: date === 1 && dayColumn(day) > 0 };
51
+ }
52
+
53
+ /** A day's column in its week, 0 for Monday. */
54
+ export function dayColumn(day: string): number {
55
+ return Math.round((Date.parse(day) - Date.parse(weekStartOf(day))) / 86_400_000);
56
+ }