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.
- package/README.md +13 -4
- package/dist/bin.js +27 -8
- package/dist/bin.js.map +1 -1
- package/dist/cli.js +24 -0
- package/dist/cli.js.map +1 -1
- package/dist/db/migrations/0018_project_archived_at.sql +8 -0
- package/dist/db/migrations/0019_project_status.sql +20 -0
- package/dist/db/migrations/0020_project_archived_status.sql +20 -0
- package/dist/db/migrations/0021_workspace_settings.sql +18 -0
- package/dist/db/migrations/0022_recurrence_split.sql +13 -0
- package/dist/db/schema.d.ts +112 -0
- package/dist/db/schema.js +14 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/mcp/http-client.d.ts +18 -1
- package/dist/mcp/http-client.js +25 -2
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.js +75 -15
- package/dist/mcp/server.js.map +1 -1
- package/dist/rest/routes.js +120 -17
- package/dist/rest/routes.js.map +1 -1
- package/dist/service/dates.d.ts +11 -0
- package/dist/service/dates.js +31 -0
- package/dist/service/dates.js.map +1 -1
- package/dist/service/entities.js +52 -3
- package/dist/service/entities.js.map +1 -1
- package/dist/service/projects.d.ts +2 -2
- package/dist/service/projects.js +15 -4
- package/dist/service/projects.js.map +1 -1
- package/dist/service/recurrence.d.ts +64 -1
- package/dist/service/recurrence.js +382 -22
- package/dist/service/recurrence.js.map +1 -1
- package/dist/service/search.js +9 -4
- package/dist/service/search.js.map +1 -1
- package/dist/service/tasks.d.ts +15 -0
- package/dist/service/tasks.js +179 -3
- package/dist/service/tasks.js.map +1 -1
- package/dist/service/types.d.ts +133 -7
- package/dist/service/types.js +47 -7
- package/dist/service/types.js.map +1 -1
- package/dist/service/workspace_settings.d.ts +8 -0
- package/dist/service/workspace_settings.js +53 -0
- package/dist/service/workspace_settings.js.map +1 -0
- package/package.json +1 -1
- package/web/dist/assets/index-Bt8xPxDL.css +1 -0
- package/web/dist/assets/{index-D-Ojc9qs.js → index-DyioCifb.js} +139 -134
- package/web/dist/index.html +2 -2
- 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;
|
|
1
|
+
{"version":3,"file":"projects.js","sourceRoot":"","sources":["../../src/service/projects.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAE1C,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,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,UAAU,EAAE,KAAK,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI;KAC5D,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,IAAI,KAAK,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QAChC,GAAG,CAAC,UAAU,GAAG,GAAG,CAAA,YAAY,QAAQ,CAAC,UAAU,UAAU,CAAC;IAChE,CAAC;SAAM,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QACtC,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,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,CAChC,EAAkB,EAClB,QAA2B,EAAE,eAAe,EAAE,KAAK,EAAE;IAErD,IAAI,KAAK,CAAC,eAAe,EAAE,CAAC;QAC1B,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;IAC1D,CAAC;IACD,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;AACjG,CAAC"}
|
|
@@ -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 ?
|
|
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
|
-
|
|
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
|
|
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
|
|
197
|
+
timezone,
|
|
155
198
|
allDay: input.allDay,
|
|
156
199
|
anchor: input.anchor,
|
|
157
200
|
maxInstancesAhead: 1,
|
|
158
|
-
nextOccurrenceAt
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
239
|
-
allDay:
|
|
287
|
+
timezone: resolvedTimezone,
|
|
288
|
+
allDay: resolvedAllDay,
|
|
240
289
|
anchor: input.anchor ?? current.anchor,
|
|
241
|
-
nextOccurrenceAt
|
|
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
|
-
|
|
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
|
|
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
|