forge-openclaw-plugin 0.2.15 → 0.2.19

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 (67) hide show
  1. package/README.md +39 -4
  2. package/dist/assets/{board-C_m78kvK.js → board-8L3uX7_O.js} +2 -2
  3. package/dist/assets/{board-C_m78kvK.js.map → board-8L3uX7_O.js.map} +1 -1
  4. package/dist/assets/index-Cj1IBH_w.js +36 -0
  5. package/dist/assets/index-Cj1IBH_w.js.map +1 -0
  6. package/dist/assets/index-DQT6EbuS.css +1 -0
  7. package/dist/assets/{motion-CpZvZumD.js → motion-1GAqqi8M.js} +2 -2
  8. package/dist/assets/{motion-CpZvZumD.js.map → motion-1GAqqi8M.js.map} +1 -1
  9. package/dist/assets/{table-DtyXTw03.js → table-DBGlgRjk.js} +2 -2
  10. package/dist/assets/{table-DtyXTw03.js.map → table-DBGlgRjk.js.map} +1 -1
  11. package/dist/assets/{ui-BXbpiKyS.js → ui-iTluWjC4.js} +2 -2
  12. package/dist/assets/{ui-BXbpiKyS.js.map → ui-iTluWjC4.js.map} +1 -1
  13. package/dist/assets/{vendor-QBH6qVEe.js → vendor-BvM2F9Dp.js} +151 -81
  14. package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
  15. package/dist/assets/{viz-w-IMeueL.js → viz-CNeunkfu.js} +2 -2
  16. package/dist/assets/{viz-w-IMeueL.js.map → viz-CNeunkfu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/local-runtime.js +142 -9
  19. package/dist/openclaw/parity.js +1 -0
  20. package/dist/openclaw/plugin-entry-shared.js +7 -1
  21. package/dist/openclaw/routes.js +7 -0
  22. package/dist/openclaw/tools.js +198 -16
  23. package/dist/server/app.js +2615 -251
  24. package/dist/server/managers/platform/secrets-manager.js +44 -1
  25. package/dist/server/managers/runtime.js +3 -1
  26. package/dist/server/openapi.js +2212 -170
  27. package/dist/server/repositories/calendar.js +1101 -0
  28. package/dist/server/repositories/deleted-entities.js +10 -2
  29. package/dist/server/repositories/habits.js +358 -0
  30. package/dist/server/repositories/notes.js +161 -28
  31. package/dist/server/repositories/projects.js +45 -13
  32. package/dist/server/repositories/rewards.js +176 -6
  33. package/dist/server/repositories/settings.js +47 -5
  34. package/dist/server/repositories/task-runs.js +46 -10
  35. package/dist/server/repositories/tasks.js +25 -9
  36. package/dist/server/repositories/weekly-reviews.js +109 -0
  37. package/dist/server/repositories/work-adjustments.js +105 -0
  38. package/dist/server/services/calendar-runtime.js +1301 -0
  39. package/dist/server/services/context.js +16 -6
  40. package/dist/server/services/dashboard.js +6 -3
  41. package/dist/server/services/entity-crud.js +116 -3
  42. package/dist/server/services/gamification.js +66 -18
  43. package/dist/server/services/insights.js +2 -1
  44. package/dist/server/services/projects.js +32 -8
  45. package/dist/server/services/reviews.js +17 -2
  46. package/dist/server/services/work-time.js +27 -0
  47. package/dist/server/types.js +1069 -45
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -1
  50. package/server/migrations/003_habits.sql +30 -0
  51. package/server/migrations/004_habit_links.sql +8 -0
  52. package/server/migrations/005_habit_psyche_links.sql +24 -0
  53. package/server/migrations/006_work_adjustments.sql +14 -0
  54. package/server/migrations/007_weekly_review_closures.sql +17 -0
  55. package/server/migrations/008_calendar_execution.sql +147 -0
  56. package/server/migrations/009_true_calendar_events.sql +195 -0
  57. package/server/migrations/010_calendar_selection_state.sql +6 -0
  58. package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
  59. package/server/migrations/012_work_block_ranges.sql +7 -0
  60. package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
  61. package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
  62. package/skills/forge-openclaw/SKILL.md +130 -10
  63. package/skills/forge-openclaw/cron_jobs.md +395 -0
  64. package/dist/assets/index-BWtLtXwb.js +0 -36
  65. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  66. package/dist/assets/index-Dp5GXY_z.css +0 -1
  67. package/dist/assets/vendor-QBH6qVEe.js.map +0 -1
@@ -18,6 +18,53 @@ function normalizeLinks(links) {
18
18
  return true;
19
19
  });
20
20
  }
21
+ function normalizeTags(tags) {
22
+ if (!tags) {
23
+ return [];
24
+ }
25
+ const seen = new Set();
26
+ return tags
27
+ .map((tag) => tag.trim())
28
+ .filter(Boolean)
29
+ .filter((tag) => {
30
+ const normalized = tag.toLowerCase();
31
+ if (seen.has(normalized)) {
32
+ return false;
33
+ }
34
+ seen.add(normalized);
35
+ return true;
36
+ });
37
+ }
38
+ function parseTagsJson(raw) {
39
+ try {
40
+ const parsed = JSON.parse(raw);
41
+ return Array.isArray(parsed)
42
+ ? normalizeTags(parsed.filter((value) => typeof value === "string"))
43
+ : [];
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ function noteMatchesTextTerm(note, term) {
50
+ const normalized = term.trim().toLowerCase();
51
+ if (!normalized) {
52
+ return false;
53
+ }
54
+ return note.tags.some((tag) => tag.toLowerCase().includes(normalized));
55
+ }
56
+ function cleanupExpiredNotes() {
57
+ const expiredRows = getDatabase()
58
+ .prepare(`SELECT id
59
+ FROM notes
60
+ WHERE destroy_at IS NOT NULL
61
+ AND destroy_at != ''
62
+ AND destroy_at <= ?`)
63
+ .all(new Date().toISOString());
64
+ for (const row of expiredRows) {
65
+ deleteNoteInternal(row.id, { source: "system", actor: null }, "Ephemeral note expired");
66
+ }
67
+ }
21
68
  function stripMarkdown(markdown) {
22
69
  return markdown
23
70
  .replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
@@ -55,7 +102,7 @@ function buildFtsQuery(query) {
55
102
  }
56
103
  function getNoteRow(noteId) {
57
104
  return getDatabase()
58
- .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
105
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
59
106
  FROM notes
60
107
  WHERE id = ?`)
61
108
  .get(noteId);
@@ -86,6 +133,8 @@ function mapNote(row, linkRows) {
86
133
  contentPlain: row.content_plain,
87
134
  author: row.author,
88
135
  source: row.source,
136
+ tags: parseTagsJson(row.tags_json),
137
+ destroyAt: row.destroy_at,
89
138
  createdAt: row.created_at,
90
139
  updatedAt: row.updated_at,
91
140
  links: mapLinks(linkRows)
@@ -102,7 +151,7 @@ function deleteSearchRow(noteId) {
102
151
  }
103
152
  function listAllNoteRows() {
104
153
  return getDatabase()
105
- .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
154
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
106
155
  FROM notes
107
156
  ORDER BY created_at DESC`)
108
157
  .all();
@@ -117,6 +166,19 @@ function findMatchingNoteIds(query) {
117
166
  .all(ftsQuery);
118
167
  return new Set(rows.map((row) => row.note_id));
119
168
  }
169
+ function findMatchingNoteIdsForTextTerms(terms) {
170
+ const normalizedTerms = terms.map((term) => term.trim()).filter(Boolean);
171
+ if (normalizedTerms.length === 0) {
172
+ return null;
173
+ }
174
+ const matches = new Set();
175
+ for (const term of normalizedTerms) {
176
+ for (const noteId of findMatchingNoteIds(term)) {
177
+ matches.add(noteId);
178
+ }
179
+ }
180
+ return matches;
181
+ }
120
182
  function insertLinks(noteId, links, createdAt) {
121
183
  const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
122
184
  VALUES (?, ?, ?, ?, ?)`);
@@ -164,7 +226,10 @@ function recordNoteActivity(note, eventType, title, context) {
164
226
  });
165
227
  }
166
228
  }
167
- export function getNoteById(noteId) {
229
+ export function getNoteById(noteId, options = {}) {
230
+ if (!options.skipCleanup) {
231
+ cleanupExpiredNotes();
232
+ }
168
233
  if (isEntityDeleted("note", noteId)) {
169
234
  return undefined;
170
235
  }
@@ -174,7 +239,10 @@ export function getNoteById(noteId) {
174
239
  }
175
240
  return mapNote(row, listNoteLinks(noteId));
176
241
  }
177
- export function getNoteByIdIncludingDeleted(noteId) {
242
+ export function getNoteByIdIncludingDeleted(noteId, options = {}) {
243
+ if (!options.skipCleanup) {
244
+ cleanupExpiredNotes();
245
+ }
178
246
  const row = getNoteRow(noteId);
179
247
  if (!row) {
180
248
  const deleted = getDeletedEntityRecord("note", noteId);
@@ -183,13 +251,26 @@ export function getNoteByIdIncludingDeleted(noteId) {
183
251
  return mapNote(row, listNoteLinks(noteId));
184
252
  }
185
253
  export function listNotes(query = {}) {
254
+ cleanupExpiredNotes();
186
255
  const parsed = notesListQuerySchema.parse(query);
187
- if (parsed.linkedEntityType &&
188
- parsed.linkedEntityId &&
189
- isEntityDeleted(parsed.linkedEntityType, parsed.linkedEntityId)) {
256
+ const linkedFilters = [
257
+ ...(parsed.linkedEntityType && parsed.linkedEntityId
258
+ ? [
259
+ {
260
+ entityType: parsed.linkedEntityType,
261
+ entityId: parsed.linkedEntityId
262
+ }
263
+ ]
264
+ : []),
265
+ ...parsed.linkedTo
266
+ ];
267
+ if (linkedFilters.some((filter) => isEntityDeleted(filter.entityType, filter.entityId))) {
190
268
  return [];
191
269
  }
192
- const matchingIds = parsed.query ? findMatchingNoteIds(parsed.query) : null;
270
+ const matchingIds = findMatchingNoteIdsForTextTerms([
271
+ ...(parsed.query ? [parsed.query] : []),
272
+ ...parsed.textTerms
273
+ ]);
193
274
  const rows = listAllNoteRows();
194
275
  const linksByNoteId = new Map();
195
276
  for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
@@ -198,32 +279,63 @@ export function listNotes(query = {}) {
198
279
  linksByNoteId.set(link.note_id, current);
199
280
  }
200
281
  return filterDeletedEntities("note", rows
201
- .filter((row) => (matchingIds ? matchingIds.has(row.id) : true))
202
- .filter((row) => (parsed.author ? (row.author ?? "").toLowerCase().includes(parsed.author.toLowerCase()) : true))
282
+ .filter((row) => parsed.author
283
+ ? (row.author ?? "")
284
+ .toLowerCase()
285
+ .includes(parsed.author.toLowerCase())
286
+ : true)
203
287
  .map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
204
- .filter((note) => parsed.linkedEntityType && parsed.linkedEntityId
205
- ? note.links.some((link) => link.entityType === parsed.linkedEntityType &&
206
- link.entityId === parsed.linkedEntityId &&
207
- (parsed.anchorKey === undefined ? true : (link.anchorKey ?? null) === parsed.anchorKey))
288
+ .filter((note) => {
289
+ if (!matchingIds) {
290
+ return true;
291
+ }
292
+ return (matchingIds.has(note.id) ||
293
+ parsed.textTerms.some((term) => noteMatchesTextTerm(note, term)) ||
294
+ (parsed.query ? noteMatchesTextTerm(note, parsed.query) : false));
295
+ })
296
+ .filter((note) => linkedFilters.length > 0
297
+ ? note.links.some((link) => linkedFilters.some((filter) => link.entityType === filter.entityType &&
298
+ link.entityId === filter.entityId &&
299
+ (parsed.anchorKey === undefined
300
+ ? true
301
+ : (link.anchorKey ?? null) === parsed.anchorKey)))
302
+ : true)
303
+ .filter((note) => parsed.tags.length > 0
304
+ ? parsed.tags.every((filterTag) => note.tags.some((noteTag) => noteTag.toLowerCase() === filterTag.toLowerCase()))
208
305
  : true)
306
+ .filter((note) => {
307
+ if (!parsed.updatedFrom && !parsed.updatedTo) {
308
+ return true;
309
+ }
310
+ const updatedDate = note.updatedAt.slice(0, 10);
311
+ if (parsed.updatedFrom && updatedDate < parsed.updatedFrom) {
312
+ return false;
313
+ }
314
+ if (parsed.updatedTo && updatedDate > parsed.updatedTo) {
315
+ return false;
316
+ }
317
+ return true;
318
+ })
209
319
  .slice(0, parsed.limit ?? 100));
210
320
  }
211
321
  export function createNote(input, context) {
322
+ cleanupExpiredNotes();
212
323
  const parsed = createNoteSchema.parse({
213
324
  ...input,
214
- links: normalizeLinks(input.links)
325
+ links: normalizeLinks(input.links),
326
+ tags: normalizeTags(input.tags)
215
327
  });
216
328
  const now = new Date().toISOString();
217
329
  const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
218
330
  const contentPlain = stripMarkdown(parsed.contentMarkdown);
219
331
  getDatabase()
220
- .prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, created_at, updated_at)
221
- VALUES (?, ?, ?, ?, ?, ?, ?)`)
222
- .run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, now, now);
332
+ .prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at)
333
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
334
+ .run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, now, now);
223
335
  insertLinks(id, parsed.links, now);
224
336
  clearDeletedEntityRecord("note", id);
225
337
  upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
226
- const note = getNoteById(id);
338
+ const note = getNoteById(id, { skipCleanup: true });
227
339
  recordNoteActivity(note, "note.created", "Note added", context);
228
340
  return note;
229
341
  }
@@ -234,31 +346,37 @@ export function createLinkedNotes(notes, entityLink, context) {
234
346
  return notes.map((note) => createNote({
235
347
  contentMarkdown: note.contentMarkdown,
236
348
  author: note.author,
349
+ tags: note.tags,
350
+ destroyAt: note.destroyAt,
237
351
  links: [entityLink, ...note.links]
238
352
  }, context));
239
353
  }
240
354
  export function updateNote(noteId, input, context) {
241
- const existing = getNoteByIdIncludingDeleted(noteId);
355
+ cleanupExpiredNotes();
356
+ const existing = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
242
357
  if (!existing) {
243
358
  return undefined;
244
359
  }
245
360
  const patch = updateNoteSchema.parse({
246
361
  ...input,
247
- links: input.links ? normalizeLinks(input.links) : undefined
362
+ links: input.links ? normalizeLinks(input.links) : undefined,
363
+ tags: input.tags ? normalizeTags(input.tags) : undefined
248
364
  });
249
365
  const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
250
366
  const nextPlain = stripMarkdown(nextMarkdown);
251
367
  const nextAuthor = patch.author === undefined ? existing.author : patch.author;
368
+ const nextTags = patch.tags ?? existing.tags;
369
+ const nextDestroyAt = patch.destroyAt === undefined ? existing.destroyAt : patch.destroyAt;
252
370
  const updatedAt = new Date().toISOString();
253
371
  getDatabase()
254
372
  .prepare(`UPDATE notes
255
- SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
373
+ SET content_markdown = ?, content_plain = ?, author = ?, tags_json = ?, destroy_at = ?, updated_at = ?
256
374
  WHERE id = ?`)
257
- .run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
375
+ .run(nextMarkdown, nextPlain, nextAuthor, JSON.stringify(nextTags), nextDestroyAt, updatedAt, noteId);
258
376
  if (patch.links) {
259
377
  replaceLinks(noteId, patch.links, updatedAt);
260
378
  }
261
- const note = getNoteByIdIncludingDeleted(noteId);
379
+ const note = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
262
380
  if (note.links.length > 0) {
263
381
  clearDeletedEntityRecord("note", noteId);
264
382
  }
@@ -275,21 +393,34 @@ export function updateNote(noteId, input, context) {
275
393
  });
276
394
  }
277
395
  upsertSearchRow(noteId, nextPlain, nextAuthor);
396
+ if (nextDestroyAt && Date.parse(nextDestroyAt) <= Date.now()) {
397
+ deleteNoteInternal(noteId, { source: "system", actor: null }, "Ephemeral note expired");
398
+ return undefined;
399
+ }
278
400
  recordNoteActivity(note, "note.updated", "Note updated", context);
279
401
  return getNoteById(noteId);
280
402
  }
281
- export function deleteNote(noteId, context) {
282
- const existing = getNoteByIdIncludingDeleted(noteId);
403
+ function deleteNoteInternal(noteId, context, title) {
404
+ const existing = getNoteRow(noteId)
405
+ ? mapNote(getNoteRow(noteId), listNoteLinks(noteId))
406
+ : getDeletedEntityRecord("note", noteId)?.snapshot;
283
407
  if (!existing) {
284
408
  return undefined;
285
409
  }
410
+ clearDeletedEntityRecord("note", noteId);
286
411
  getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
287
412
  getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
288
413
  deleteSearchRow(noteId);
289
- recordNoteActivity(existing, "note.deleted", "Note deleted", context);
414
+ clearDeletedEntityRecord("note", noteId);
415
+ recordNoteActivity(existing, "note.deleted", title, context);
290
416
  return existing;
291
417
  }
418
+ export function deleteNote(noteId, context) {
419
+ cleanupExpiredNotes();
420
+ return deleteNoteInternal(noteId, context, "Note deleted");
421
+ }
292
422
  export function buildNotesSummaryByEntity() {
423
+ cleanupExpiredNotes();
293
424
  const rows = getDatabase()
294
425
  .prepare(`SELECT
295
426
  note_links.entity_type AS entity_type,
@@ -302,8 +433,9 @@ export function buildNotesSummaryByEntity() {
302
433
  ON deleted_entities.entity_type = 'note'
303
434
  AND deleted_entities.entity_id = notes.id
304
435
  WHERE deleted_entities.entity_id IS NULL
436
+ AND (notes.destroy_at IS NULL OR notes.destroy_at = '' OR notes.destroy_at > ?)
305
437
  ORDER BY notes.created_at DESC`)
306
- .all();
438
+ .all(new Date().toISOString());
307
439
  return rows.reduce((acc, row) => {
308
440
  const key = `${row.entity_type}:${row.entity_id}`;
309
441
  const current = acc[key];
@@ -324,6 +456,7 @@ export function buildNotesSummaryByEntity() {
324
456
  }, {});
325
457
  }
326
458
  export function unlinkNotesForEntity(entityType, entityId, context) {
459
+ cleanupExpiredNotes();
327
460
  const noteIds = getDatabase()
328
461
  .prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
329
462
  .all(entityType, entityId);
@@ -6,7 +6,8 @@ import { createLinkedNotes } from "./notes.js";
6
6
  import { assertGoalExists } from "../services/relations.js";
7
7
  import { getGoalById } from "./goals.js";
8
8
  import { pruneLinkedEntityReferences } from "./psyche.js";
9
- import { createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
9
+ import { listTasks, updateTaskInTransaction } from "./tasks.js";
10
+ import { calendarSchedulingRulesSchema, createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
10
11
  function getDefaultProjectTemplate(goal) {
11
12
  switch (goal.title) {
12
13
  case "Build a durable body and calm energy":
@@ -40,10 +41,18 @@ function mapProject(row) {
40
41
  status: row.status,
41
42
  themeColor: row.theme_color,
42
43
  targetPoints: row.target_points,
44
+ schedulingRules: calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json || "{}")),
43
45
  createdAt: row.created_at,
44
46
  updatedAt: row.updated_at
45
47
  });
46
48
  }
49
+ function completeLinkedProjectTasks(projectId, activity) {
50
+ const openTasks = listTasks({ projectId }).filter((task) => task.status !== "done");
51
+ for (const task of openTasks) {
52
+ updateTaskInTransaction(task.id, { status: "done" }, activity);
53
+ }
54
+ return openTasks.length;
55
+ }
47
56
  export function listProjects(filters = {}) {
48
57
  const whereClauses = [];
49
58
  const params = [];
@@ -62,6 +71,7 @@ export function listProjects(filters = {}) {
62
71
  }
63
72
  const rows = getDatabase()
64
73
  .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
74
+ , scheduling_rules_json
65
75
  FROM projects
66
76
  ${whereSql}
67
77
  ORDER BY created_at ASC
@@ -75,6 +85,7 @@ export function getProjectById(projectId) {
75
85
  }
76
86
  const row = getDatabase()
77
87
  .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
88
+ , scheduling_rules_json
78
89
  FROM projects
79
90
  WHERE id = ?`)
80
91
  .get(projectId);
@@ -87,9 +98,9 @@ export function createProject(input, activity) {
87
98
  const now = new Date().toISOString();
88
99
  const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
89
100
  getDatabase()
90
- .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
91
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
92
- .run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, now, now);
101
+ .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
103
+ .run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, JSON.stringify(parsed.schedulingRules), now, now);
93
104
  const project = getProjectById(id);
94
105
  createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
95
106
  if (activity) {
@@ -127,32 +138,42 @@ export function updateProject(projectId, input, activity) {
127
138
  status: parsed.status ?? current.status,
128
139
  themeColor: parsed.themeColor ?? current.themeColor,
129
140
  targetPoints: parsed.targetPoints ?? current.targetPoints,
141
+ schedulingRules: parsed.schedulingRules ?? current.schedulingRules,
130
142
  updatedAt: new Date().toISOString()
131
143
  };
132
144
  getDatabase()
133
145
  .prepare(`UPDATE projects
134
- SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
146
+ SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, scheduling_rules_json = ?, updated_at = ?
135
147
  WHERE id = ?`)
136
- .run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
148
+ .run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, JSON.stringify(next.schedulingRules), next.updatedAt, projectId);
137
149
  // Keep legacy task.goal_id aligned with the project's parent goal.
138
150
  getDatabase()
139
151
  .prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
140
152
  .run(next.goalId, next.updatedAt, projectId);
153
+ const completedLinkedTaskCount = current.status !== "completed" && next.status === "completed"
154
+ ? completeLinkedProjectTasks(projectId, activity)
155
+ : 0;
141
156
  const project = getProjectById(projectId);
142
157
  if (project && activity) {
158
+ const statusChanged = current.status !== project.status;
143
159
  recordActivityEvent({
144
160
  entityType: "project",
145
161
  entityId: project.id,
146
- eventType: current.status !== project.status ? "project_status_changed" : "project_updated",
147
- title: current.status !== project.status ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
148
- description: "Project details were updated.",
162
+ eventType: statusChanged ? "project_status_changed" : "project_updated",
163
+ title: statusChanged ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
164
+ description: statusChanged && project.status === "completed"
165
+ ? `Project finished and auto-completed ${completedLinkedTaskCount} linked unfinished task${completedLinkedTaskCount === 1 ? "" : "s"}.`
166
+ : statusChanged
167
+ ? `Project status changed from ${current.status} to ${project.status}.`
168
+ : "Project details were updated.",
149
169
  actor: activity.actor ?? null,
150
170
  source: activity.source,
151
171
  metadata: {
152
172
  goalId: project.goalId,
153
173
  previousGoalId: current.goalId,
154
174
  status: project.status,
155
- previousStatus: current.status
175
+ previousStatus: current.status,
176
+ completedLinkedTaskCount
156
177
  }
157
178
  });
158
179
  }
@@ -174,9 +195,20 @@ export function ensureDefaultProjectForGoal(goalId) {
174
195
  const now = new Date().toISOString();
175
196
  const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
176
197
  getDatabase()
177
- .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
178
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
179
- .run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), now, now);
198
+ .prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
200
+ .run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), JSON.stringify({
201
+ allowWorkBlockKinds: [],
202
+ blockWorkBlockKinds: [],
203
+ allowCalendarIds: [],
204
+ blockCalendarIds: [],
205
+ allowEventTypes: [],
206
+ blockEventTypes: [],
207
+ allowEventKeywords: [],
208
+ blockEventKeywords: [],
209
+ allowAvailability: [],
210
+ blockAvailability: []
211
+ }), now, now);
180
212
  return getProjectById(id);
181
213
  });
182
214
  }