botnote 0.1.28 → 0.1.30

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 (47) hide show
  1. package/README.md +13 -4
  2. package/dist/bin.js +27 -8
  3. package/dist/bin.js.map +1 -1
  4. package/dist/cli.js +24 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/db/migrations/0018_project_archived_at.sql +8 -0
  7. package/dist/db/migrations/0019_project_status.sql +20 -0
  8. package/dist/db/migrations/0020_project_archived_status.sql +20 -0
  9. package/dist/db/migrations/0021_workspace_settings.sql +18 -0
  10. package/dist/db/migrations/0022_recurrence_split.sql +13 -0
  11. package/dist/db/schema.d.ts +112 -0
  12. package/dist/db/schema.js +14 -1
  13. package/dist/db/schema.js.map +1 -1
  14. package/dist/mcp/http-client.d.ts +18 -1
  15. package/dist/mcp/http-client.js +25 -2
  16. package/dist/mcp/http-client.js.map +1 -1
  17. package/dist/mcp/server.js +75 -15
  18. package/dist/mcp/server.js.map +1 -1
  19. package/dist/rest/routes.js +120 -17
  20. package/dist/rest/routes.js.map +1 -1
  21. package/dist/service/dates.d.ts +11 -0
  22. package/dist/service/dates.js +31 -0
  23. package/dist/service/dates.js.map +1 -1
  24. package/dist/service/entities.js +52 -3
  25. package/dist/service/entities.js.map +1 -1
  26. package/dist/service/projects.d.ts +2 -2
  27. package/dist/service/projects.js +15 -4
  28. package/dist/service/projects.js.map +1 -1
  29. package/dist/service/recurrence.d.ts +64 -1
  30. package/dist/service/recurrence.js +382 -22
  31. package/dist/service/recurrence.js.map +1 -1
  32. package/dist/service/search.js +9 -4
  33. package/dist/service/search.js.map +1 -1
  34. package/dist/service/tasks.d.ts +15 -0
  35. package/dist/service/tasks.js +179 -3
  36. package/dist/service/tasks.js.map +1 -1
  37. package/dist/service/types.d.ts +133 -7
  38. package/dist/service/types.js +47 -7
  39. package/dist/service/types.js.map +1 -1
  40. package/dist/service/workspace_settings.d.ts +8 -0
  41. package/dist/service/workspace_settings.js +53 -0
  42. package/dist/service/workspace_settings.js.map +1 -0
  43. package/package.json +1 -1
  44. package/web/dist/assets/index-Bt8xPxDL.css +1 -0
  45. package/web/dist/assets/{index-D-Ojc9qs.js → index-DyioCifb.js} +139 -134
  46. package/web/dist/index.html +2 -2
  47. package/web/dist/assets/index-BLQefDfj.css +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"projects.js","sourceRoot":"","sources":["../../src/service/projects.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,QAAQ,EAAgB,MAAM,iBAAiB,CAAC;AAGzD,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,EAAkB,EAClB,KAAyB;IAEzB,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE;SACnB,MAAM,CAAC,QAAQ,CAAC;SAChB,MAAM,CAAC;QACN,GAAG,EAAE,KAAK,CAAC,GAAG;QACd,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,QAAQ,EAAE,KAAK,CAAC,QAAQ;KACzB,CAAC;SACD,SAAS,EAAE,CAAC;IACf,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IAC5D,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,EAAkB,EAClB,EAAU,EACV,KAAyB;IAEzB,MAAM,GAAG,GAA4B,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC;IACzE,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;IACxF,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;IACrD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,EAAkB,EAClB,GAAW;IAEX,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACpF,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACzB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,EAAkB,EAAE,EAAU;IAC7D,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACzB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,EAAkB;IACnD,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;AAC1D,CAAC"}
1
+ {"version":3,"file":"projects.js","sourceRoot":"","sources":["../../src/service/projects.ts"],"names":[],"mappings":"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"}
@@ -1,13 +1,51 @@
1
+ import rrulePkg from "rrule";
1
2
  import type { Database } from "../db/client.js";
2
3
  import { type Entity, type RecurrenceRule } from "../db/schema.js";
3
- import type { RecurrenceInput, StopRecurrenceInput, UpdateRecurrenceInput } from "./types.js";
4
+ import type { RecurrenceInput, SplitRecurrenceInput, StopRecurrenceInput, UpdateRecurrenceInput } from "./types.js";
5
+ type BaselineFields = {
6
+ title?: string | null;
7
+ body?: string;
8
+ tags?: string[];
9
+ priority?: string;
10
+ };
11
+ export type ContentFieldKey = "title" | "body" | "tags" | "priority";
12
+ interface RecurrenceMarker {
13
+ ruleId: string;
14
+ seriesId: string;
15
+ role: "occurrence";
16
+ occurrenceAt: string;
17
+ occurrenceIndex: number;
18
+ }
4
19
  export interface RecurrenceDetails {
5
20
  rule: RecurrenceRule;
6
21
  currentOccurrence: Entity | null;
7
22
  }
23
+ export declare function recurrenceMarker(e: Entity): Partial<RecurrenceMarker> | null;
24
+ export declare function buildRRule(rule: Pick<RecurrenceRule, "rrule" | "dtstart">, dtstart?: Date): rrulePkg.RRule;
25
+ /**
26
+ * Normalize a computed occurrence date for storage.
27
+ * - All-day rules: resolve the calendar day as seen in the rule's timezone,
28
+ * then snap to noon UTC (so the intended local calendar day is preserved
29
+ * across all UTC-offset clients).
30
+ * - Timed rules: use normalizeDueAt (exact-midnight-UTC -> noon-UTC heuristic).
31
+ */
32
+ export declare function normalizeOccurrenceDueAt(rule: Pick<RecurrenceRule, "allDay" | "timezone">, date: Date): Date;
33
+ export declare function findRuleForTask(db: Database["db"], task: Entity, includeDisabled?: boolean): Promise<RecurrenceRule | null>;
8
34
  export declare function getRecurrenceForTask(db: Database["db"], taskId: string): Promise<RecurrenceDetails | null>;
9
35
  export declare function createRecurrenceRule(db: Database["db"], taskId: string, input: RecurrenceInput): Promise<RecurrenceRule>;
10
36
  export declare function updateRecurrenceRule(db: Database["db"], ruleId: string, input: UpdateRecurrenceInput): Promise<RecurrenceRule>;
37
+ /**
38
+ * Upsert a 'modified' exception row recording the pre-edit (baseline) values
39
+ * of content fields for this occurrence. Uses earliest-wins merge: only
40
+ * adds a field to baseline if not already present.
41
+ */
42
+ export declare function upsertModifiedExceptionBaseline(db: Database["db"], rule: RecurrenceRule, occurrence: Entity, preEditValues: BaselineFields, changedFields: ContentFieldKey[]): Promise<void>;
43
+ /**
44
+ * Remove the given fields from an existing 'modified' exception's baseline.
45
+ * If baseline becomes empty after removal, delete the row.
46
+ */
47
+ export declare function clearModifiedExceptionFields(db: Database["db"], _rule: RecurrenceRule, occurrence: Entity, changedFields: ContentFieldKey[]): Promise<void>;
48
+ export declare function materializeScheduledRecurrences(db: Database["db"], upTo: Date, limit?: number): Promise<Entity[]>;
11
49
  export declare function advanceRecurrenceOnCompletion(db: Database["db"], occurrence: Entity, completedAt?: Date): Promise<Entity | null>;
12
50
  export declare function skipOccurrence(db: Database["db"], taskId: string, input?: {
13
51
  reason?: string;
@@ -18,3 +56,28 @@ export declare function skipOccurrence(db: Database["db"], taskId: string, input
18
56
  rule: RecurrenceRule;
19
57
  }>;
20
58
  export declare function stopRecurrence(db: Database["db"], ruleId: string, _input?: StopRecurrenceInput): Promise<RecurrenceRule>;
59
+ /**
60
+ * Fork a recurrence series at its current occurrence: freeze the old rule and
61
+ * apply a new cadence going forward, keeping the same series so the UI still
62
+ * shows one continuous timeline (history + future).
63
+ *
64
+ * Mechanics:
65
+ * - The old rule is bounded with UNTIL at the fork point and disabled. It stays
66
+ * as frozen history (its already-materialized occurrences are untouched).
67
+ * - A new enabled rule is created sharing the SAME series_id (UI continuity)
68
+ * with the new RRULE. The live occurrence is kept as its template/display
69
+ * source.
70
+ * - The new rule has a distinct id, so its occurrences live in a disjoint
71
+ * idempotency-key space (`recurrence:<ruleId>:<dueAt>`): no duplicate or
72
+ * orphan occurrence can appear at the fork even if a date coincides.
73
+ * - Scheduled forks let the materializer grow the new rule from the first
74
+ * occurrence after the fork; the live occurrence stays on the old rule as the
75
+ * tail of history. Completion forks re-home the live occurrence onto the new
76
+ * rule so completing it advances the new cadence.
77
+ *
78
+ * The whole operation runs in one transaction: the old rule is disabled before
79
+ * the new one is inserted, so the partial-unique "one enabled rule per series"
80
+ * index holds at every step.
81
+ */
82
+ export declare function splitRecurrence(db: Database["db"], ruleId: string, input: SplitRecurrenceInput): Promise<RecurrenceRule>;
83
+ export {};
@@ -1,7 +1,8 @@
1
- import { and, eq, or } from "drizzle-orm";
1
+ import { and, asc, desc, eq, lte, or } from "drizzle-orm";
2
2
  import rrulePkg from "rrule";
3
3
  import { entities, recurrenceExceptions, recurrenceRules } from "../db/schema.js";
4
- import { normalizeDueAt } from "./dates.js";
4
+ import { allDayDueAtInZone, normalizeDueAt } from "./dates.js";
5
+ import { getWorkspaceSettings } from "./workspace_settings.js";
5
6
  const { RRule } = rrulePkg;
6
7
  const WEEKDAYS = {
7
8
  MO: RRule.MO,
@@ -24,7 +25,7 @@ function metadataObject(value) {
24
25
  return {};
25
26
  return { ...value };
26
27
  }
27
- function recurrenceMarker(e) {
28
+ export function recurrenceMarker(e) {
28
29
  const metadata = metadataObject(e.metadata);
29
30
  if (!metadata.recurrence || typeof metadata.recurrence !== "object")
30
31
  return null;
@@ -80,13 +81,36 @@ function buildRRuleFromInput(input, dtstart) {
80
81
  options.count = input.count;
81
82
  return rruleLine(new RRule(options));
82
83
  }
83
- function buildRRule(rule, dtstart = rule.dtstart) {
84
+ export function buildRRule(rule, dtstart = rule.dtstart) {
84
85
  const parsed = RRule.parseString(rule.rrule);
85
86
  return new RRule({ ...parsed, dtstart });
86
87
  }
88
+ /**
89
+ * Bound an existing RRULE with UNTIL at the fork point (used when freezing the
90
+ * old rule during a split). UNTIL supersedes any COUNT the rule carried.
91
+ */
92
+ function applyUntilToRrule(rruleText, dtstart, until) {
93
+ const parsed = RRule.parseString(rruleText);
94
+ delete parsed.count;
95
+ parsed.until = until;
96
+ return rruleLine(new RRule({ ...parsed, dtstart }));
97
+ }
87
98
  function maxDate(a, b) {
88
99
  return a.getTime() >= b.getTime() ? a : b;
89
100
  }
101
+ /**
102
+ * Normalize a computed occurrence date for storage.
103
+ * - All-day rules: resolve the calendar day as seen in the rule's timezone,
104
+ * then snap to noon UTC (so the intended local calendar day is preserved
105
+ * across all UTC-offset clients).
106
+ * - Timed rules: use normalizeDueAt (exact-midnight-UTC -> noon-UTC heuristic).
107
+ */
108
+ export function normalizeOccurrenceDueAt(rule, date) {
109
+ if (rule.allDay) {
110
+ return allDayDueAtInZone(date, rule.timezone) ?? normalizeDueAt(date) ?? date;
111
+ }
112
+ return normalizeDueAt(date) ?? date;
113
+ }
90
114
  function countLimit(rule) {
91
115
  return buildRRule(rule).options.count ?? null;
92
116
  }
@@ -100,7 +124,11 @@ function computeNextOccurrenceAt(rule, occurrence, completedAt) {
100
124
  const scheduledBase = occurrence.dueAt ?? rule.dtstart;
101
125
  const cursor = anchor === "completion" ? completedAt : maxDate(scheduledBase, completedAt);
102
126
  const next = rrule.after(cursor, false);
103
- return next ? normalizeDueAt(next) : null;
127
+ return next ? normalizeOccurrenceDueAt(rule, next) : null;
128
+ }
129
+ function computeNextScheduledAfter(rule, after) {
130
+ const next = buildRRule(rule, rule.dtstart).after(after, false);
131
+ return next ? normalizeOccurrenceDueAt(rule, next) : null;
104
132
  }
105
133
  async function fetchEntity(db, id) {
106
134
  const rows = await db.select().from(entities).where(eq(entities.id, id)).limit(1);
@@ -109,13 +137,21 @@ async function fetchEntity(db, id) {
109
137
  throw new Error(`entity ${id} not found`);
110
138
  return row;
111
139
  }
112
- async function findRuleForTask(db, task, includeDisabled = true) {
140
+ export async function findRuleForTask(db, task, includeDisabled = true) {
113
141
  const marker = recurrenceMarker(task);
114
142
  const cond = marker?.ruleId
115
143
  ? eq(recurrenceRules.id, marker.ruleId)
116
144
  : or(eq(recurrenceRules.currentOccurrenceId, task.id), eq(recurrenceRules.seriesId, task.id));
117
145
  const where = includeDisabled ? cond : and(cond, eq(recurrenceRules.enabled, true));
118
- const rows = await db.select().from(recurrenceRules).where(where).limit(1);
146
+ // A split leaves multiple rules per series (disabled history + one active),
147
+ // so the seriesId/currentOccurrenceId fallback can match more than one. Prefer
148
+ // the active rule, then the most recent. (The marker.ruleId branch is exact.)
149
+ const rows = await db
150
+ .select()
151
+ .from(recurrenceRules)
152
+ .where(where)
153
+ .orderBy(desc(recurrenceRules.enabled), desc(recurrenceRules.createdAt))
154
+ .limit(1);
119
155
  return rows[0] ?? null;
120
156
  }
121
157
  export async function getRecurrenceForTask(db, taskId) {
@@ -137,11 +173,18 @@ export async function createRecurrenceRule(db, taskId, input) {
137
173
  if (task.status === "done" || task.status === "rejected") {
138
174
  throw new Error("recurrence cannot be attached to a terminal task");
139
175
  }
176
+ // Resolve timezone: explicit input wins; otherwise fall back to workspace tz.
177
+ const timezone = input.timezone ?? (await getWorkspaceSettings(db)).timezone;
140
178
  const existingRule = await findRuleForTask(db, task);
141
179
  const seriesId = existingRule?.seriesId ?? recurrenceMarker(task)?.seriesId ?? task.id;
142
180
  const dtstart = input.dtstart ? normalizeDueAt(input.dtstart) : task.dueAt;
143
181
  const rrule = buildRRuleFromInput(input, dtstart);
144
- const nextOccurrenceAt = buildRRule({ rrule, dtstart }).after(dtstart, false);
182
+ const nextOccurrenceRaw = buildRRule({ rrule, dtstart }).after(dtstart, false);
183
+ // Build a partial rule shape for normalizeOccurrenceDueAt before the DB row exists.
184
+ const ruleShape = { allDay: input.allDay ?? true, timezone };
185
+ const nextOccurrenceAt = nextOccurrenceRaw
186
+ ? normalizeOccurrenceDueAt(ruleShape, nextOccurrenceRaw)
187
+ : null;
145
188
  let rule;
146
189
  if (existingRule) {
147
190
  const [updated] = await db
@@ -151,11 +194,11 @@ export async function createRecurrenceRule(db, taskId, input) {
151
194
  enabled: true,
152
195
  rrule,
153
196
  dtstart,
154
- timezone: input.timezone,
197
+ timezone,
155
198
  allDay: input.allDay,
156
199
  anchor: input.anchor,
157
200
  maxInstancesAhead: 1,
158
- nextOccurrenceAt: nextOccurrenceAt ? normalizeDueAt(nextOccurrenceAt) : null,
201
+ nextOccurrenceAt,
159
202
  endedAt: null
160
203
  })
161
204
  .where(eq(recurrenceRules.id, existingRule.id))
@@ -173,13 +216,13 @@ export async function createRecurrenceRule(db, taskId, input) {
173
216
  enabled: true,
174
217
  rrule,
175
218
  dtstart,
176
- timezone: input.timezone,
219
+ timezone,
177
220
  allDay: input.allDay,
178
221
  anchor: input.anchor,
179
222
  maxInstancesAhead: 1,
180
223
  generatedCount: 1,
181
224
  lastOccurrenceAt: null,
182
- nextOccurrenceAt: nextOccurrenceAt ? normalizeDueAt(nextOccurrenceAt) : null,
225
+ nextOccurrenceAt,
183
226
  endedAt: null
184
227
  })
185
228
  .returning();
@@ -228,17 +271,23 @@ export async function updateRecurrenceRule(db, ruleId, input) {
228
271
  anchor: input.anchor ?? (current.anchor === "completion" ? "completion" : "scheduled")
229
272
  };
230
273
  const rrule = input.rrule || input.preset ? buildRRuleFromInput(nextRRuleInput, dtstart) : current.rrule;
231
- const nextOccurrenceAt = buildRRule({ rrule, dtstart }).after(dtstart, false);
274
+ const nextOccurrenceRaw = buildRRule({ rrule, dtstart }).after(dtstart, false);
275
+ const resolvedAllDay = input.allDay ?? current.allDay;
276
+ const resolvedTimezone = input.timezone ?? current.timezone;
277
+ const ruleShape = { allDay: resolvedAllDay, timezone: resolvedTimezone };
278
+ const nextOccurrenceAt = nextOccurrenceRaw
279
+ ? normalizeOccurrenceDueAt(ruleShape, nextOccurrenceRaw)
280
+ : null;
232
281
  const [updated] = await db
233
282
  .update(recurrenceRules)
234
283
  .set({
235
284
  enabled: input.enabled ?? current.enabled,
236
285
  rrule,
237
286
  dtstart,
238
- timezone: input.timezone ?? current.timezone,
239
- allDay: input.allDay ?? current.allDay,
287
+ timezone: resolvedTimezone,
288
+ allDay: resolvedAllDay,
240
289
  anchor: input.anchor ?? current.anchor,
241
- nextOccurrenceAt: nextOccurrenceAt ? normalizeDueAt(nextOccurrenceAt) : null,
290
+ nextOccurrenceAt,
242
291
  endedAt: input.enabled === true ? null : current.endedAt
243
292
  })
244
293
  .where(eq(recurrenceRules.id, ruleId))
@@ -247,7 +296,135 @@ export async function updateRecurrenceRule(db, ruleId, input) {
247
296
  throw new Error(`recurrence rule ${ruleId} not found`);
248
297
  return updated;
249
298
  }
250
- async function insertNextOccurrence(db, rule, occurrence, nextDueAt) {
299
+ // ---------------------------------------------------------------------------
300
+ // Modified-exception helpers (scope='this' baseline management)
301
+ // ---------------------------------------------------------------------------
302
+ /**
303
+ * Find the existing 'modified' exception row for a given entity.
304
+ * Keyed by entityId (stable), not occurrenceAt.
305
+ */
306
+ async function findModifiedExceptionForEntity(db, entityId) {
307
+ const rows = await db
308
+ .select({ id: recurrenceExceptions.id, metadata: recurrenceExceptions.metadata })
309
+ .from(recurrenceExceptions)
310
+ .where(and(eq(recurrenceExceptions.entityId, entityId), eq(recurrenceExceptions.action, "modified")))
311
+ .limit(1);
312
+ if (!rows[0])
313
+ return null;
314
+ const meta = rows[0].metadata;
315
+ return {
316
+ id: rows[0].id,
317
+ metadata: (meta && typeof meta === "object" && !Array.isArray(meta))
318
+ ? meta
319
+ : {}
320
+ };
321
+ }
322
+ /**
323
+ * Upsert a 'modified' exception row recording the pre-edit (baseline) values
324
+ * of content fields for this occurrence. Uses earliest-wins merge: only
325
+ * adds a field to baseline if not already present.
326
+ */
327
+ export async function upsertModifiedExceptionBaseline(db, rule, occurrence, preEditValues, changedFields) {
328
+ const existing = await findModifiedExceptionForEntity(db, occurrence.id);
329
+ if (existing) {
330
+ const existingMeta = existing.metadata;
331
+ const existingBaseline = (existingMeta.baseline && typeof existingMeta.baseline === "object" && !Array.isArray(existingMeta.baseline)
332
+ ? existingMeta.baseline
333
+ : {});
334
+ const existingChangedFields = Array.isArray(existingMeta.changedFields)
335
+ ? existingMeta.changedFields
336
+ : [];
337
+ // Merge: only add fields not already in baseline (earliest value wins)
338
+ const mergedBaseline = { ...existingBaseline };
339
+ const mergedChangedFields = new Set(existingChangedFields);
340
+ for (const field of changedFields) {
341
+ if (!(field in mergedBaseline)) {
342
+ mergedBaseline[field] = preEditValues[field] ?? null;
343
+ }
344
+ mergedChangedFields.add(field);
345
+ }
346
+ await db
347
+ .update(recurrenceExceptions)
348
+ .set({
349
+ metadata: {
350
+ scope: "this",
351
+ baseline: mergedBaseline,
352
+ changedFields: Array.from(mergedChangedFields),
353
+ actorKind: "human"
354
+ }
355
+ })
356
+ .where(eq(recurrenceExceptions.id, existing.id));
357
+ }
358
+ else {
359
+ const baseline = {};
360
+ for (const field of changedFields) {
361
+ baseline[field] = preEditValues[field] ?? null;
362
+ }
363
+ await db.insert(recurrenceExceptions).values({
364
+ ruleId: rule.id,
365
+ occurrenceAt: occurrence.dueAt ?? rule.dtstart,
366
+ action: "modified",
367
+ entityId: occurrence.id,
368
+ metadata: {
369
+ scope: "this",
370
+ baseline,
371
+ changedFields,
372
+ actorKind: "human"
373
+ }
374
+ });
375
+ }
376
+ }
377
+ /**
378
+ * Remove the given fields from an existing 'modified' exception's baseline.
379
+ * If baseline becomes empty after removal, delete the row.
380
+ */
381
+ export async function clearModifiedExceptionFields(db, _rule, occurrence, changedFields) {
382
+ const existing = await findModifiedExceptionForEntity(db, occurrence.id);
383
+ if (!existing)
384
+ return;
385
+ const existingMeta = existing.metadata;
386
+ const existingBaseline = (existingMeta.baseline && typeof existingMeta.baseline === "object" && !Array.isArray(existingMeta.baseline)
387
+ ? existingMeta.baseline
388
+ : {});
389
+ const existingChangedFields = Array.isArray(existingMeta.changedFields)
390
+ ? existingMeta.changedFields
391
+ : [];
392
+ const newBaseline = { ...existingBaseline };
393
+ const newChangedFields = existingChangedFields.filter((f) => !changedFields.includes(f));
394
+ for (const field of changedFields) {
395
+ delete newBaseline[field];
396
+ }
397
+ if (Object.keys(newBaseline).length === 0) {
398
+ await db.delete(recurrenceExceptions).where(eq(recurrenceExceptions.id, existing.id));
399
+ }
400
+ else {
401
+ await db
402
+ .update(recurrenceExceptions)
403
+ .set({
404
+ metadata: {
405
+ ...existingMeta,
406
+ baseline: newBaseline,
407
+ changedFields: newChangedFields
408
+ }
409
+ })
410
+ .where(eq(recurrenceExceptions.id, existing.id));
411
+ }
412
+ }
413
+ /**
414
+ * Fetch the baseline stored in the 'modified' exception for the given template
415
+ * occurrence. Returns null if no such exception exists.
416
+ */
417
+ async function fetchBaselineForTemplate(db, _rule, template) {
418
+ const existing = await findModifiedExceptionForEntity(db, template.id);
419
+ if (!existing)
420
+ return null;
421
+ const meta = existing.metadata;
422
+ if (!meta.baseline || typeof meta.baseline !== "object" || Array.isArray(meta.baseline)) {
423
+ return null;
424
+ }
425
+ return meta.baseline;
426
+ }
427
+ async function insertNextOccurrence(db, rule, occurrence, nextDueAt, baseline) {
251
428
  const occurrenceIndex = rule.generatedCount + 1;
252
429
  const idempotencyKey = `recurrence:${rule.id}:${nextDueAt.toISOString()}`;
253
430
  const metadata = {
@@ -263,15 +440,15 @@ async function insertNextOccurrence(db, rule, occurrence, nextDueAt) {
263
440
  const values = {
264
441
  kind: "task",
265
442
  projectId: occurrence.projectId,
266
- title: occurrence.title,
267
- body: occurrence.body,
268
- tags: occurrence.tags,
443
+ title: baseline && "title" in baseline ? baseline.title : occurrence.title,
444
+ body: baseline && "body" in baseline ? (baseline.body ?? "") : occurrence.body,
445
+ tags: baseline && "tags" in baseline ? (baseline.tags ?? []) : occurrence.tags,
269
446
  status: "open",
270
447
  parentId: occurrence.parentId,
271
448
  actorKind: "system",
272
449
  metadata,
273
450
  dueAt: nextDueAt,
274
- priority: occurrence.priority,
451
+ priority: (baseline && "priority" in baseline ? baseline.priority : null) ?? occurrence.priority,
275
452
  pinned: false,
276
453
  completedAt: null,
277
454
  idempotencyKey
@@ -306,7 +483,8 @@ async function advanceRuleFromOccurrence(db, rule, occurrence, completedAt) {
306
483
  .where(eq(recurrenceRules.id, rule.id));
307
484
  return null;
308
485
  }
309
- const next = await insertNextOccurrence(db, rule, occurrence, nextDueAt);
486
+ const baseline = await fetchBaselineForTemplate(db, rule, occurrence);
487
+ const next = await insertNextOccurrence(db, rule, occurrence, nextDueAt, baseline ?? undefined);
310
488
  await db
311
489
  .update(recurrenceRules)
312
490
  .set({
@@ -318,6 +496,55 @@ async function advanceRuleFromOccurrence(db, rule, occurrence, completedAt) {
318
496
  .where(eq(recurrenceRules.id, rule.id));
319
497
  return next;
320
498
  }
499
+ export async function materializeScheduledRecurrences(db, upTo, limit = 100) {
500
+ const rows = await db
501
+ .select()
502
+ .from(recurrenceRules)
503
+ .where(and(eq(recurrenceRules.enabled, true), eq(recurrenceRules.anchor, "scheduled"), lte(recurrenceRules.nextOccurrenceAt, upTo)))
504
+ .orderBy(asc(recurrenceRules.nextOccurrenceAt))
505
+ .limit(limit);
506
+ const created = [];
507
+ for (const originalRule of rows) {
508
+ let rule = originalRule;
509
+ let template = await fetchEntity(db, rule.currentOccurrenceId ?? rule.seriesId).catch(() => fetchEntity(db, rule.seriesId));
510
+ while (rule.enabled &&
511
+ rule.nextOccurrenceAt &&
512
+ rule.nextOccurrenceAt.getTime() <= upTo.getTime() &&
513
+ created.length < limit) {
514
+ const count = countLimit(rule);
515
+ if (count != null && rule.generatedCount >= count) {
516
+ await db
517
+ .update(recurrenceRules)
518
+ .set({ nextOccurrenceAt: null })
519
+ .where(eq(recurrenceRules.id, rule.id));
520
+ break;
521
+ }
522
+ const nextDueAt = normalizeOccurrenceDueAt(rule, rule.nextOccurrenceAt);
523
+ const baseline = await fetchBaselineForTemplate(db, rule, template);
524
+ const next = await insertNextOccurrence(db, rule, template, nextDueAt, baseline ?? undefined);
525
+ created.push(next);
526
+ const generatedCount = rule.generatedCount + 1;
527
+ const following = count != null && generatedCount >= count
528
+ ? null
529
+ : computeNextScheduledAfter(rule, nextDueAt);
530
+ const [updated] = await db
531
+ .update(recurrenceRules)
532
+ .set({
533
+ currentOccurrenceId: next.id,
534
+ generatedCount,
535
+ lastOccurrenceAt: template.dueAt ?? rule.dtstart,
536
+ nextOccurrenceAt: following
537
+ })
538
+ .where(eq(recurrenceRules.id, rule.id))
539
+ .returning();
540
+ if (!updated)
541
+ break;
542
+ rule = updated;
543
+ template = next;
544
+ }
545
+ }
546
+ return created;
547
+ }
321
548
  export async function advanceRecurrenceOnCompletion(db, occurrence, completedAt = occurrence.completedAt ?? new Date()) {
322
549
  if (occurrence.kind !== "task")
323
550
  return null;
@@ -378,4 +605,137 @@ export async function stopRecurrence(db, ruleId, _input = {}) {
378
605
  throw new Error(`recurrence rule ${ruleId} not found`);
379
606
  return updated;
380
607
  }
608
+ /**
609
+ * Fork a recurrence series at its current occurrence: freeze the old rule and
610
+ * apply a new cadence going forward, keeping the same series so the UI still
611
+ * shows one continuous timeline (history + future).
612
+ *
613
+ * Mechanics:
614
+ * - The old rule is bounded with UNTIL at the fork point and disabled. It stays
615
+ * as frozen history (its already-materialized occurrences are untouched).
616
+ * - A new enabled rule is created sharing the SAME series_id (UI continuity)
617
+ * with the new RRULE. The live occurrence is kept as its template/display
618
+ * source.
619
+ * - The new rule has a distinct id, so its occurrences live in a disjoint
620
+ * idempotency-key space (`recurrence:<ruleId>:<dueAt>`): no duplicate or
621
+ * orphan occurrence can appear at the fork even if a date coincides.
622
+ * - Scheduled forks let the materializer grow the new rule from the first
623
+ * occurrence after the fork; the live occurrence stays on the old rule as the
624
+ * tail of history. Completion forks re-home the live occurrence onto the new
625
+ * rule so completing it advances the new cadence.
626
+ *
627
+ * The whole operation runs in one transaction: the old rule is disabled before
628
+ * the new one is inserted, so the partial-unique "one enabled rule per series"
629
+ * index holds at every step.
630
+ */
631
+ export async function splitRecurrence(db, ruleId, input) {
632
+ return db.transaction(async (tx) => {
633
+ const ruleRows = await tx
634
+ .select()
635
+ .from(recurrenceRules)
636
+ .where(eq(recurrenceRules.id, ruleId))
637
+ .limit(1);
638
+ const oldRule = ruleRows[0];
639
+ if (!oldRule)
640
+ throw new Error(`recurrence rule ${ruleId} not found`);
641
+ if (!oldRule.enabled)
642
+ throw new Error("only an active recurrence can be split");
643
+ if (!oldRule.currentOccurrenceId) {
644
+ throw new Error("recurrence has no current occurrence to fork from");
645
+ }
646
+ const occRows = await tx
647
+ .select()
648
+ .from(entities)
649
+ .where(eq(entities.id, oldRule.currentOccurrenceId))
650
+ .limit(1);
651
+ const current = occRows[0];
652
+ if (!current)
653
+ throw new Error("recurrence has no current occurrence to fork from");
654
+ const forkAfter = current.dueAt ?? oldRule.dtstart;
655
+ const allDay = input.allDay ?? oldRule.allDay;
656
+ const timezone = input.timezone ?? oldRule.timezone;
657
+ const anchor = input.anchor ?? (oldRule.anchor === "completion" ? "completion" : "scheduled");
658
+ const ruleShape = { allDay, timezone };
659
+ // The forward cadence.
660
+ const newRrule = buildRRuleFromInput(input, forkAfter);
661
+ // Resolve the new rule's anchoring before mutating anything, so a bounded
662
+ // new rule with no occurrences after the fork fails cleanly (no half-split).
663
+ let dtstart;
664
+ let nextOccurrenceAt;
665
+ let generatedCount;
666
+ if (anchor === "completion") {
667
+ // Completion series advance on completion, not on a schedule; the re-homed
668
+ // live occurrence (below) carries the chain.
669
+ dtstart = forkAfter;
670
+ nextOccurrenceAt = null;
671
+ generatedCount = oldRule.generatedCount;
672
+ }
673
+ else {
674
+ const firstRaw = buildRRule({ rrule: newRrule, dtstart: forkAfter }).after(forkAfter, false);
675
+ if (!firstRaw) {
676
+ throw new Error("new recurrence has no occurrences after the fork point");
677
+ }
678
+ dtstart = normalizeOccurrenceDueAt(ruleShape, firstRaw);
679
+ nextOccurrenceAt = dtstart;
680
+ // The new rule has generated nothing yet — the scheduler materializes its
681
+ // first occurrence (index 1) at dtstart. Starting at 0 keeps COUNT on the
682
+ // new rule consistent with rrule expansion. UI continuity comes from the
683
+ // shared series_id, not from this counter.
684
+ generatedCount = 0;
685
+ }
686
+ // 1) Freeze the old rule. Must run before the insert so the partial-unique
687
+ // (one enabled rule per series) index is satisfied at every step.
688
+ await tx
689
+ .update(recurrenceRules)
690
+ .set({
691
+ enabled: false,
692
+ rrule: applyUntilToRrule(oldRule.rrule, oldRule.dtstart, forkAfter),
693
+ currentOccurrenceId: anchor === "completion" ? null : oldRule.currentOccurrenceId,
694
+ lastOccurrenceAt: current.dueAt ?? oldRule.dtstart,
695
+ nextOccurrenceAt: null,
696
+ endedAt: new Date()
697
+ })
698
+ .where(eq(recurrenceRules.id, oldRule.id));
699
+ // 2) Insert the new enabled rule, same series_id.
700
+ const [created] = await tx
701
+ .insert(recurrenceRules)
702
+ .values({
703
+ seriesId: oldRule.seriesId,
704
+ currentOccurrenceId: current.id,
705
+ enabled: true,
706
+ rrule: newRrule,
707
+ dtstart,
708
+ timezone,
709
+ allDay,
710
+ anchor,
711
+ maxInstancesAhead: 1,
712
+ generatedCount,
713
+ lastOccurrenceAt: anchor === "completion" ? oldRule.lastOccurrenceAt : null,
714
+ nextOccurrenceAt,
715
+ endedAt: null
716
+ })
717
+ .returning();
718
+ if (!created)
719
+ throw new Error("recurrence rule insert returned no row");
720
+ // 3) Completion fork: re-home the live occurrence onto the new rule so its
721
+ // completion advances the new cadence (findRuleForTask routes by the
722
+ // marker's ruleId). Scheduled forks leave it on the old rule as history.
723
+ if (anchor === "completion") {
724
+ const marker = recurrenceMarker(current);
725
+ await tx
726
+ .update(entities)
727
+ .set({
728
+ metadata: withRecurrenceMetadata(current, {
729
+ ruleId: created.id,
730
+ seriesId: created.seriesId,
731
+ role: "occurrence",
732
+ occurrenceAt: marker?.occurrenceAt ?? (current.dueAt ?? created.dtstart).toISOString(),
733
+ occurrenceIndex: marker?.occurrenceIndex ?? created.generatedCount
734
+ })
735
+ })
736
+ .where(eq(entities.id, current.id));
737
+ }
738
+ return created;
739
+ });
740
+ }
381
741
  //# sourceMappingURL=recurrence.js.map