projectionlab-mcp 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/HOW-IT-WORKS.md +2 -2
  2. package/MANUAL-SETUP.md +64 -0
  3. package/README.md +34 -271
  4. package/REFERENCE.md +221 -0
  5. package/SETUP-FOR-AGENTS.md +37 -0
  6. package/dist/bootstrap.js +20 -3
  7. package/dist/bootstrap.js.map +1 -1
  8. package/dist/bridge.d.ts +2 -0
  9. package/dist/clonePlan.d.ts +9 -0
  10. package/dist/clonePlan.js +15 -14
  11. package/dist/clonePlan.js.map +1 -1
  12. package/dist/config.d.ts +4 -0
  13. package/dist/config.js +41 -6
  14. package/dist/config.js.map +1 -1
  15. package/dist/currentFinances.d.ts +12 -1
  16. package/dist/currentFinances.js +60 -0
  17. package/dist/currentFinances.js.map +1 -1
  18. package/dist/dataModel.d.ts +58 -0
  19. package/dist/dataModel.js +1429 -0
  20. package/dist/dataModel.js.map +1 -0
  21. package/dist/deletePlan.d.ts +17 -0
  22. package/dist/deletePlan.js +72 -0
  23. package/dist/deletePlan.js.map +1 -0
  24. package/dist/diff.d.ts +288 -0
  25. package/dist/diff.js +744 -0
  26. package/dist/diff.js.map +1 -0
  27. package/dist/errors.d.ts +6 -0
  28. package/dist/errors.js +14 -1
  29. package/dist/errors.js.map +1 -1
  30. package/dist/extensionBridge.d.ts +16 -2
  31. package/dist/extensionBridge.js +104 -23
  32. package/dist/extensionBridge.js.map +1 -1
  33. package/dist/index.js +27 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/instructions.d.ts +1 -1
  36. package/dist/instructions.js +3 -2
  37. package/dist/instructions.js.map +1 -1
  38. package/dist/largeOutput.js +25 -0
  39. package/dist/largeOutput.js.map +1 -1
  40. package/dist/logger.js +11 -4
  41. package/dist/logger.js.map +1 -1
  42. package/dist/pathPatch.d.ts +5 -2
  43. package/dist/pathPatch.js +16 -2
  44. package/dist/pathPatch.js.map +1 -1
  45. package/dist/planSettings.d.ts +1 -1
  46. package/dist/planSettings.js +3 -3
  47. package/dist/planSettings.js.map +1 -1
  48. package/dist/plans.d.ts +73 -10
  49. package/dist/plans.js +206 -42
  50. package/dist/plans.js.map +1 -1
  51. package/dist/prompts/index.js +7 -4
  52. package/dist/prompts/index.js.map +1 -1
  53. package/dist/realEstate.d.ts +22 -0
  54. package/dist/realEstate.js +118 -0
  55. package/dist/realEstate.js.map +1 -0
  56. package/dist/redact.d.ts +26 -0
  57. package/dist/redact.js +93 -1
  58. package/dist/redact.js.map +1 -1
  59. package/dist/resources/dataModel.d.ts +3 -0
  60. package/dist/resources/dataModel.js +66 -0
  61. package/dist/resources/dataModel.js.map +1 -0
  62. package/dist/resources/knowledge.js +189 -47
  63. package/dist/resources/knowledge.js.map +1 -1
  64. package/dist/resources/planData.js +3 -1
  65. package/dist/resources/planData.js.map +1 -1
  66. package/dist/resources/summary.js +4 -1
  67. package/dist/resources/summary.js.map +1 -1
  68. package/dist/snapshot.d.ts +21 -0
  69. package/dist/snapshot.js +116 -12
  70. package/dist/snapshot.js.map +1 -1
  71. package/dist/summary.js +19 -6
  72. package/dist/summary.js.map +1 -1
  73. package/dist/tools/clonePlan.js +17 -8
  74. package/dist/tools/clonePlan.js.map +1 -1
  75. package/dist/tools/connection.js +29 -4
  76. package/dist/tools/connection.js.map +1 -1
  77. package/dist/tools/deletePlan.d.ts +4 -0
  78. package/dist/tools/deletePlan.js +103 -0
  79. package/dist/tools/deletePlan.js.map +1 -0
  80. package/dist/tools/diff.d.ts +4 -0
  81. package/dist/tools/diff.js +417 -0
  82. package/dist/tools/diff.js.map +1 -0
  83. package/dist/tools/getCurrentFinances.js +27 -4
  84. package/dist/tools/getCurrentFinances.js.map +1 -1
  85. package/dist/tools/getPlan.js +4 -4
  86. package/dist/tools/getPlan.js.map +1 -1
  87. package/dist/tools/getPlanSection.js +7 -6
  88. package/dist/tools/getPlanSection.js.map +1 -1
  89. package/dist/tools/getPlanSettings.js +10 -6
  90. package/dist/tools/getPlanSettings.js.map +1 -1
  91. package/dist/tools/restore.js +18 -1
  92. package/dist/tools/restore.js.map +1 -1
  93. package/dist/tools/schema.js +28 -4
  94. package/dist/tools/schema.js.map +1 -1
  95. package/dist/tools/snapshots.d.ts +1 -1
  96. package/dist/tools/snapshots.js +53 -6
  97. package/dist/tools/snapshots.js.map +1 -1
  98. package/dist/tools/summary.js +3 -2
  99. package/dist/tools/summary.js.map +1 -1
  100. package/dist/tools/updateAccount.js +9 -7
  101. package/dist/tools/updateAccount.js.map +1 -1
  102. package/dist/tools/updatePlanEvent.js +71 -33
  103. package/dist/tools/updatePlanEvent.js.map +1 -1
  104. package/dist/tools/updatePlanSection.js +17 -32
  105. package/dist/tools/updatePlanSection.js.map +1 -1
  106. package/dist/tools/updatePlanSettings.js +11 -7
  107. package/dist/tools/updatePlanSettings.js.map +1 -1
  108. package/dist/types/projectionlab.d.ts +3 -1
  109. package/dist/types/wire.d.ts +27 -2
  110. package/dist/types/wire.js +6 -2
  111. package/dist/types/wire.js.map +1 -1
  112. package/dist/updateCheck.d.ts +22 -0
  113. package/dist/updateCheck.js +58 -0
  114. package/dist/updateCheck.js.map +1 -0
  115. package/dist/versionCompare.d.ts +5 -0
  116. package/dist/versionCompare.js +33 -0
  117. package/dist/versionCompare.js.map +1 -0
  118. package/dist/versionNotices.d.ts +9 -0
  119. package/dist/versionNotices.js +42 -0
  120. package/dist/versionNotices.js.map +1 -0
  121. package/package.json +6 -3
  122. package/skills/projectionlab/SKILL.md +329 -29
package/dist/diff.js ADDED
@@ -0,0 +1,744 @@
1
+ import { CURRENT_FINANCE_COLLECTIONS, } from "./currentFinances.js";
2
+ import { PLAN_SECTIONS, getEvents, keyEvents, } from "./plans.js";
3
+ import { REDACTED, isSecretFieldName } from "./redact.js";
4
+ /**
5
+ * Field-level diff of two readings of ProjectionLab data, shared by
6
+ * projectionlab_diff. It compares the same data at two points in time
7
+ * (`diffSectionData`, `diffPlan`) or two different plans field by field
8
+ * (`diffPlanPair`), where each event's own id is dropped and each reference to
9
+ * another event of the same plan is compared by the event it names. Values
10
+ * render as `before → after` the way `diffFields` renders them, and every path
11
+ * is a dot path in the form `projectionlab_get_plan_section` and the patch
12
+ * tools use (`events.2.amount`), so a reported path can be read back or patched
13
+ * with it.
14
+ *
15
+ * The engine is pure and bounds its own output: `DiffBudget` caps how many
16
+ * field changes and how many list entries one call may report, so the payload
17
+ * stays small enough for a local model to hold in context, and it can ask for
18
+ * the differing paths with the values withheld. Omitted items are counted
19
+ * rather than dropped silently, and whether something counts as changed is
20
+ * decided from the data, never from what was left to report.
21
+ *
22
+ * A change whose path names a credential (`credentials.apiKey`,
23
+ * `access.0.password`) is recorded as `[REDACTED]`, so neither value leaves
24
+ * the process, and a value longer than `MAX_VALUE_CHARS` is truncated with its
25
+ * original length so one long note cannot flood the diff.
26
+ */
27
+ /** Field changes one diff reports before it starts omitting. */
28
+ export const FIELD_CHANGE_CAP = 200;
29
+ /** Added/removed names and ids one diff reports before it starts omitting. */
30
+ export const ENTRY_CAP = 200;
31
+ /** Serialized values longer than this are truncated in a change entry. */
32
+ export const MAX_VALUE_CHARS = 200;
33
+ /**
34
+ * Plan fields ProjectionLab computes or re-stamps on its own. Leaving them out
35
+ * is what makes a snapshot-to-live diff of an untouched plan read as clean:
36
+ * saving a plan moves `lastUpdated`, and re-running a simulation rewrites
37
+ * `simKey` and `meta` (same rule as `samePlanContent`).
38
+ */
39
+ const COMPUTED_PLAN_FIELDS = ["lastUpdated", "simKey", "meta"];
40
+ /**
41
+ * Plan fields that say which plan an object is rather than what it plans. Two
42
+ * plans never share them, so a plan-to-plan comparison leaves them out of the
43
+ * field comparison and reports them as the identity of each side.
44
+ */
45
+ const PLAN_IDENTITY_FIELDS = ["id", "name"];
46
+ export function createDiffBudget(options = {}) {
47
+ const { fieldChangeCap = FIELD_CHANGE_CAP, entryCap = ENTRY_CAP, pathsOnly = false, } = options;
48
+ return {
49
+ fieldChangeCap,
50
+ entryCap,
51
+ pathsOnly,
52
+ fieldChangesUsed: 0,
53
+ entriesUsed: 0,
54
+ omittedFieldChanges: 0,
55
+ omittedEntries: 0,
56
+ };
57
+ }
58
+ function isContainer(value) {
59
+ return !!value && typeof value === "object";
60
+ }
61
+ function renderValue(value) {
62
+ const json = JSON.stringify(value);
63
+ if (json === undefined)
64
+ return "(absent)";
65
+ if (json.length <= MAX_VALUE_CHARS)
66
+ return json;
67
+ return `${json.slice(0, MAX_VALUE_CHARS)}...[truncated from ${json.length} characters]`;
68
+ }
69
+ /**
70
+ * True when any element of a dot path names a credential, so a nested
71
+ * `credentials.apiKey` or `access.0.password` is caught even though the whole
72
+ * path is not a field name. Numeric elements are array positions, not names.
73
+ */
74
+ function pathHoldsSecret(path) {
75
+ for (const element of path.split(".")) {
76
+ if (/^\d+$/.test(element))
77
+ continue;
78
+ if (isSecretFieldName(element))
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ /**
84
+ * Record one added/removed entry, unless the entry cap is reached. The caller
85
+ * keeps its own count of what it saw, so a capped list still reports the true
86
+ * count and `omittedEntries` says how many names were left out.
87
+ */
88
+ function addEntry(list, entry, budget) {
89
+ if (budget.entriesUsed >= budget.entryCap) {
90
+ budget.omittedEntries++;
91
+ return;
92
+ }
93
+ budget.entriesUsed++;
94
+ list.push(entry);
95
+ }
96
+ function walkLeaves(value, prefix, out) {
97
+ if (Array.isArray(value)) {
98
+ if (value.length === 0) {
99
+ if (prefix)
100
+ out.set(prefix, []);
101
+ return;
102
+ }
103
+ for (let i = 0; i < value.length; i++) {
104
+ walkLeaves(value[i], prefix ? `${prefix}.${i}` : String(i), out);
105
+ }
106
+ return;
107
+ }
108
+ if (isContainer(value)) {
109
+ const keys = Object.keys(value).sort();
110
+ // An empty container is a leaf, so `{}` versus a value still reads as a
111
+ // change instead of vanishing.
112
+ if (keys.length === 0) {
113
+ if (prefix)
114
+ out.set(prefix, value);
115
+ return;
116
+ }
117
+ for (const key of keys) {
118
+ walkLeaves(value[key], prefix ? `${prefix}.${key}` : key, out);
119
+ }
120
+ return;
121
+ }
122
+ if (prefix)
123
+ out.set(prefix, value);
124
+ }
125
+ /**
126
+ * Leaf-by-leaf diff of two values as `path: "before → after"`. Arrays carry
127
+ * their index in the path, so a reordered list reads as changed leaves rather
128
+ * than as nothing.
129
+ */
130
+ export function diffLeafValues(before, after, budget) {
131
+ const changes = {};
132
+ const paths = budget.pathsOnly ? [] : undefined;
133
+ let detected = 0;
134
+ const record = (path, from, to) => {
135
+ if (JSON.stringify(from) === JSON.stringify(to))
136
+ return;
137
+ detected++;
138
+ if (budget.fieldChangesUsed >= budget.fieldChangeCap) {
139
+ budget.omittedFieldChanges++;
140
+ return;
141
+ }
142
+ budget.fieldChangesUsed++;
143
+ if (paths) {
144
+ paths.push(path);
145
+ return;
146
+ }
147
+ changes[path] = pathHoldsSecret(path)
148
+ ? REDACTED
149
+ : `${renderValue(from)} → ${renderValue(to)}`;
150
+ };
151
+ if (!isContainer(before) && !isContainer(after)) {
152
+ record("(value)", before, after);
153
+ return { changes, ...(paths ? { paths } : {}), detected };
154
+ }
155
+ const beforeLeaves = new Map();
156
+ const afterLeaves = new Map();
157
+ walkLeaves(before, "", beforeLeaves);
158
+ walkLeaves(after, "", afterLeaves);
159
+ const allPaths = [
160
+ ...new Set([...beforeLeaves.keys(), ...afterLeaves.keys()]),
161
+ ].sort();
162
+ for (const path of allPaths) {
163
+ record(path, beforeLeaves.get(path), afterLeaves.get(path));
164
+ }
165
+ return { changes, ...(paths ? { paths } : {}), detected };
166
+ }
167
+ /**
168
+ * The change entry for one event or row: its identity, the field changes the
169
+ * budget recorded (or, when values are withheld, just their paths), and how
170
+ * many more it found than it could record.
171
+ */
172
+ function changeEntry(identity, leaf) {
173
+ const recorded = leaf.paths
174
+ ? leaf.paths.length
175
+ : Object.keys(leaf.changes).length;
176
+ const reported = recorded === 0
177
+ ? {}
178
+ : leaf.paths
179
+ ? { paths: leaf.paths }
180
+ : { changes: leaf.changes };
181
+ return {
182
+ ...identity,
183
+ ...reported,
184
+ ...(leaf.detected > recorded
185
+ ? { omittedChanges: leaf.detected - recorded }
186
+ : {}),
187
+ };
188
+ }
189
+ function isEventish(section) {
190
+ if (Array.isArray(section))
191
+ return true;
192
+ return isContainer(section) && Array.isArray(section.events);
193
+ }
194
+ /**
195
+ * Diff one plan section. Event-shaped sections (`{ events: [] }`, or a bare
196
+ * array such as `milestones`) compare events by name plus occurrence, the same
197
+ * keying the section dry run uses, so events sharing a name each keep their
198
+ * own entry. Anything else (`variables`, `montecarlo`,
199
+ * `withdrawalStrategy`) compares leaf by leaf.
200
+ */
201
+ export function diffEventSection(before, after, budget) {
202
+ const beforeEvents = getEvents(before);
203
+ const afterEvents = getEvents(after);
204
+ const beforeKeyed = keyEvents(beforeEvents);
205
+ const afterKeyed = keyEvents(afterEvents);
206
+ const added = [];
207
+ const removed = [];
208
+ const modified = [];
209
+ let addedCount = 0;
210
+ let removedCount = 0;
211
+ let modifiedCount = 0;
212
+ let unchangedCount = 0;
213
+ for (const [key, { name, event }] of afterKeyed) {
214
+ const previous = beforeKeyed.get(key);
215
+ if (!previous) {
216
+ addedCount++;
217
+ addEntry(added, name, budget);
218
+ continue;
219
+ }
220
+ const leaf = diffLeafValues(previous.event, event, budget);
221
+ if (leaf.detected > 0) {
222
+ modifiedCount++;
223
+ addEntry(modified, changeEntry({ name }, leaf), budget);
224
+ }
225
+ else {
226
+ unchangedCount++;
227
+ }
228
+ }
229
+ for (const [key, { name }] of beforeKeyed) {
230
+ if (!afterKeyed.has(key)) {
231
+ removedCount++;
232
+ addEntry(removed, name, budget);
233
+ }
234
+ }
235
+ added.sort();
236
+ removed.sort();
237
+ return {
238
+ kind: "events",
239
+ count: `${beforeEvents.length} → ${afterEvents.length}`,
240
+ changed: addedCount > 0 || removedCount > 0 || modifiedCount > 0,
241
+ addedCount,
242
+ ...(added.length > 0 ? { added } : {}),
243
+ removedCount,
244
+ ...(removed.length > 0 ? { removed } : {}),
245
+ modifiedCount,
246
+ ...(modified.length > 0 ? { modified } : {}),
247
+ unchangedCount,
248
+ };
249
+ }
250
+ /**
251
+ * Diff a data area that holds neither events nor rows: a plan's `variables`,
252
+ * `montecarlo` or `withdrawalStrategy`, and the whole `progress` or `settings`
253
+ * section.
254
+ */
255
+ export function diffFieldSection(before, after, budget) {
256
+ const leaf = diffLeafValues(before, after, budget);
257
+ return {
258
+ kind: "fields",
259
+ changed: leaf.detected > 0,
260
+ ...changeEntry({}, leaf),
261
+ };
262
+ }
263
+ /**
264
+ * Choose how to compare one plan section. A section that is event-shaped on
265
+ * either side compares as events, including one that exists on only one side,
266
+ * so a section added or dropped reads as events appearing or disappearing
267
+ * rather than as a wall of leaf paths. `compareEvents` decides how two sets of
268
+ * events are reported: as added/removed/modified over time, or as
269
+ * onlyInA/onlyInB/different between two plans.
270
+ */
271
+ function diffSectionShape(before, after, budget, compareEvents) {
272
+ const beforeIsEvent = isEventish(before);
273
+ const afterIsEvent = isEventish(after);
274
+ if (beforeIsEvent && afterIsEvent)
275
+ return compareEvents(before, after, budget);
276
+ if (beforeIsEvent && (after === undefined || after === null)) {
277
+ return compareEvents(before, after, budget);
278
+ }
279
+ if (afterIsEvent && (before === undefined || before === null)) {
280
+ return compareEvents(before, after, budget);
281
+ }
282
+ return diffFieldSection(before, after, budget);
283
+ }
284
+ function planNameOf(plan) {
285
+ return String(plan?.name ?? plan?.id ?? "unnamed");
286
+ }
287
+ /** Match plans across two reads: by id when they carry one, else by name. */
288
+ export function planKey(plan) {
289
+ const obj = (plan ?? {});
290
+ const id = obj.id;
291
+ if (id !== undefined && id !== null && id !== "")
292
+ return `id:${String(id)}`;
293
+ return `name:${planNameOf(plan)}`;
294
+ }
295
+ export function planIdentity(plan) {
296
+ const obj = (plan ?? {});
297
+ return {
298
+ id: String(obj.id ?? ""),
299
+ name: String(obj.name ?? "unnamed"),
300
+ };
301
+ }
302
+ function asArray(value) {
303
+ return Array.isArray(value) ? value : [];
304
+ }
305
+ /**
306
+ * Diff two copies of one plan. `section` narrows the comparison to a single
307
+ * plan section; the plan's own settings (every field that is not a known
308
+ * section, such as `retirementAge` or `endAge`) are always compared, minus the
309
+ * computed fields.
310
+ */
311
+ export function diffPlan(before, after, budget, section) {
312
+ const sections = {};
313
+ const changedSections = [];
314
+ const sectionNames = section
315
+ ? [section]
316
+ : PLAN_SECTIONS.filter((name) => name in before || name in after);
317
+ for (const name of sectionNames) {
318
+ const result = diffSectionShape(before[name], after[name], budget, diffEventSection);
319
+ sections[name] = result;
320
+ if (result.changed)
321
+ changedSections.push(name);
322
+ }
323
+ // Plan settings are every field that is not a known section and not one of
324
+ // the fields ProjectionLab computes, whatever the section filter is.
325
+ const skip = new Set([...PLAN_SECTIONS, ...COMPUTED_PLAN_FIELDS]);
326
+ const beforeRest = {};
327
+ const afterRest = {};
328
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
329
+ if (skip.has(key))
330
+ continue;
331
+ beforeRest[key] = before[key];
332
+ afterRest[key] = after[key];
333
+ }
334
+ const planFields = diffLeafValues(beforeRest, afterRest, budget);
335
+ return {
336
+ ...planIdentity(after ?? before),
337
+ changed: changedSections.length > 0 || planFields.detected > 0,
338
+ ...changeEntry({}, planFields),
339
+ ...(sectionNames.length > 0 ? { sections } : {}),
340
+ ...(changedSections.length > 0 ? { changedSections } : {}),
341
+ };
342
+ }
343
+ /**
344
+ * Tokens that name each event of `plan` by its section and display name, keyed
345
+ * by the id that plan's own events use to point at it.
346
+ *
347
+ * Events refer to other events of the same plan by id (`start: { type:
348
+ * "milestone", value: "<milestone id>" }`, a priority's `accountId`), and two
349
+ * plans built independently give the same milestone unrelated ids. Comparing
350
+ * the raw ids would report a difference at every reference of a hand-rebuilt
351
+ * plan; comparing tokens makes a reference to the same-named event compare
352
+ * equal and a reference to a differently named one read as a difference. An id
353
+ * that is not an event of this plan (a Current Finances `accountId`, which
354
+ * every plan shares) gets no token and is compared as it is.
355
+ */
356
+ export function eventReferenceTokens(plan) {
357
+ const tokens = new Map();
358
+ if (!isContainer(plan) || Array.isArray(plan))
359
+ return tokens;
360
+ for (const section of Object.keys(plan)) {
361
+ const value = plan[section];
362
+ if (!isEventish(value))
363
+ continue;
364
+ for (const { name, event } of keyEvents(getEvents(value)).values()) {
365
+ const id = event?.id;
366
+ if (id === undefined || id === null || id === "")
367
+ continue;
368
+ const token = `@${section}:${name}`;
369
+ if (!tokens.has(String(id)))
370
+ tokens.set(String(id), token);
371
+ }
372
+ }
373
+ return tokens;
374
+ }
375
+ /** A copy of `value` with each string naming an event of the plan replaced by its token. */
376
+ function substituteReferences(value, tokens) {
377
+ if (typeof value === "string")
378
+ return tokens.get(value) ?? value;
379
+ if (Array.isArray(value)) {
380
+ return value.map((element) => substituteReferences(element, tokens));
381
+ }
382
+ if (isContainer(value)) {
383
+ const copy = {};
384
+ for (const key of Object.keys(value)) {
385
+ copy[key] = substituteReferences(value[key], tokens);
386
+ }
387
+ return copy;
388
+ }
389
+ return value;
390
+ }
391
+ /**
392
+ * One event as plan-to-plan comparison sees it: its own `id` and `key` dropped,
393
+ * references tokened. ProjectionLab gives each event a random `key` when the
394
+ * event is created, so two independently built plans never share it.
395
+ */
396
+ function eventForPair(event, tokens) {
397
+ if (Array.isArray(event) || !isContainer(event)) {
398
+ return substituteReferences(event, tokens);
399
+ }
400
+ const copy = {};
401
+ for (const key of Object.keys(event)) {
402
+ if (key === "id" || key === "key")
403
+ continue;
404
+ copy[key] = substituteReferences(event[key], tokens);
405
+ }
406
+ return copy;
407
+ }
408
+ /**
409
+ * One plan section with each event's own `id` and `key` dropped and each
410
+ * reference tokened.
411
+ */
412
+ function sectionForPair(section, tokens) {
413
+ if (Array.isArray(section)) {
414
+ return section.map((event) => eventForPair(event, tokens));
415
+ }
416
+ const obj = section;
417
+ if (isContainer(section) && Array.isArray(obj.events)) {
418
+ const copy = {};
419
+ for (const key of Object.keys(obj)) {
420
+ copy[key] =
421
+ key === "events"
422
+ ? obj.events.map((event) => eventForPair(event, tokens))
423
+ : substituteReferences(obj[key], tokens);
424
+ }
425
+ return copy;
426
+ }
427
+ return substituteReferences(section, tokens);
428
+ }
429
+ /**
430
+ * One plan reshaped so it can be compared against a different plan: each
431
+ * event's own `id` and `key` are dropped and each reference to another event of
432
+ * the same plan is replaced by that event's token. Those are the only fields
433
+ * two independently built plans of the same content cannot share.
434
+ */
435
+ function planForPair(plan) {
436
+ const tokens = eventReferenceTokens(plan);
437
+ const prepared = {};
438
+ for (const key of Object.keys(plan)) {
439
+ prepared[key] = sectionForPair(plan[key], tokens);
440
+ }
441
+ return prepared;
442
+ }
443
+ /**
444
+ * Compare one event-shaped section of two different plans. `a` is plan A and
445
+ * `b` is plan B, so each change reads `A's value → B's value`. Both sides carry
446
+ * the ids and random keys dropped and the references tokened by `planForPair`.
447
+ */
448
+ export function diffEventPair(a, b, budget) {
449
+ const aEvents = getEvents(a);
450
+ const bEvents = getEvents(b);
451
+ const aKeyed = keyEvents(aEvents);
452
+ const bKeyed = keyEvents(bEvents);
453
+ const onlyInA = [];
454
+ const onlyInB = [];
455
+ const different = [];
456
+ let onlyInACount = 0;
457
+ let onlyInBCount = 0;
458
+ let differentCount = 0;
459
+ let sameCount = 0;
460
+ for (const [key, { name, event }] of bKeyed) {
461
+ const counterpart = aKeyed.get(key);
462
+ if (!counterpart) {
463
+ onlyInBCount++;
464
+ addEntry(onlyInB, name, budget);
465
+ continue;
466
+ }
467
+ const leaf = diffLeafValues(counterpart.event, event, budget);
468
+ if (leaf.detected > 0) {
469
+ differentCount++;
470
+ addEntry(different, changeEntry({ name }, leaf), budget);
471
+ }
472
+ else {
473
+ sameCount++;
474
+ }
475
+ }
476
+ for (const [key, { name }] of aKeyed) {
477
+ if (!bKeyed.has(key)) {
478
+ onlyInACount++;
479
+ addEntry(onlyInA, name, budget);
480
+ }
481
+ }
482
+ onlyInA.sort();
483
+ onlyInB.sort();
484
+ return {
485
+ kind: "events",
486
+ count: `${aEvents.length} → ${bEvents.length}`,
487
+ changed: onlyInACount > 0 || onlyInBCount > 0 || differentCount > 0,
488
+ onlyInACount,
489
+ ...(onlyInA.length > 0 ? { onlyInA } : {}),
490
+ onlyInBCount,
491
+ ...(onlyInB.length > 0 ? { onlyInB } : {}),
492
+ differentCount,
493
+ ...(different.length > 0 ? { different } : {}),
494
+ sameCount,
495
+ };
496
+ }
497
+ /**
498
+ * Compare two different plans field by field, so a plan rebuilt by hand can be
499
+ * checked against the original. Plan A's values are reported first in every
500
+ * `A → B` pair. Events match by name plus occurrence, the same keying the
501
+ * time diff uses; the fields that say which plan an object is (`id`, `name`),
502
+ * the fields ProjectionLab computes (`lastUpdated`, `simKey`, `meta`) and each
503
+ * event's own `id` and randomly assigned `key` are left out, and references
504
+ * between a plan's own events are compared by the event they name. `section`
505
+ * narrows the section comparison; plan settings outside the sections are always
506
+ * compared.
507
+ */
508
+ export function diffPlanPair(planA, planB, budget, section) {
509
+ const a = planForPair(planA);
510
+ const b = planForPair(planB);
511
+ const sections = {};
512
+ const changedSections = [];
513
+ const sectionNames = section
514
+ ? [section]
515
+ : PLAN_SECTIONS.filter((name) => name in planA || name in planB);
516
+ for (const name of sectionNames) {
517
+ const result = diffSectionShape(a[name], b[name], budget, diffEventPair);
518
+ sections[name] = result;
519
+ if (result.changed)
520
+ changedSections.push(name);
521
+ }
522
+ const skip = new Set([
523
+ ...PLAN_SECTIONS,
524
+ ...COMPUTED_PLAN_FIELDS,
525
+ ...PLAN_IDENTITY_FIELDS,
526
+ ]);
527
+ const aRest = {};
528
+ const bRest = {};
529
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
530
+ if (skip.has(key))
531
+ continue;
532
+ aRest[key] = a[key];
533
+ bRest[key] = b[key];
534
+ }
535
+ const settings = diffFieldSection(aRest, bRest, budget);
536
+ return {
537
+ a: planIdentity(planA),
538
+ b: planIdentity(planB),
539
+ changed: changedSections.length > 0 || settings.changed,
540
+ settings,
541
+ ...(sectionNames.length > 0 ? { sections } : {}),
542
+ ...(changedSections.length > 0 ? { changedSections } : {}),
543
+ };
544
+ }
545
+ /**
546
+ * Diff two plans arrays. `onlyKey` (from `planKey`) reports one plan alone, so
547
+ * a caller narrowed to a single plan gets just that plan's entry.
548
+ */
549
+ export function diffPlanList(before, after, budget, options = {}) {
550
+ const beforePlans = asArray(before);
551
+ const afterPlans = asArray(after);
552
+ const beforeByKey = new Map();
553
+ for (const plan of beforePlans) {
554
+ if (isContainer(plan))
555
+ beforeByKey.set(planKey(plan), plan);
556
+ }
557
+ const afterByKey = new Map();
558
+ for (const plan of afterPlans) {
559
+ if (isContainer(plan))
560
+ afterByKey.set(planKey(plan), plan);
561
+ }
562
+ const added = [];
563
+ const removed = [];
564
+ const changedPlans = [];
565
+ const unchanged = [];
566
+ let addedCount = 0;
567
+ let removedCount = 0;
568
+ let changedCount = 0;
569
+ let unchangedCount = 0;
570
+ for (const [key, plan] of afterByKey) {
571
+ if (options.onlyKey && key !== options.onlyKey)
572
+ continue;
573
+ const previous = beforeByKey.get(key);
574
+ if (!previous) {
575
+ addedCount++;
576
+ addEntry(added, planIdentity(plan), budget);
577
+ continue;
578
+ }
579
+ const diff = diffPlan(previous, plan, budget, options.section);
580
+ if (diff.changed) {
581
+ changedCount++;
582
+ addEntry(changedPlans, diff, budget);
583
+ }
584
+ else {
585
+ unchangedCount++;
586
+ addEntry(unchanged, diff.name, budget);
587
+ }
588
+ }
589
+ for (const [key, plan] of beforeByKey) {
590
+ if (options.onlyKey && key !== options.onlyKey)
591
+ continue;
592
+ if (!afterByKey.has(key)) {
593
+ removedCount++;
594
+ addEntry(removed, planIdentity(plan), budget);
595
+ }
596
+ }
597
+ return {
598
+ changed: addedCount > 0 || removedCount > 0 || changedCount > 0,
599
+ planCount: `${beforePlans.length} → ${afterPlans.length}`,
600
+ addedCount,
601
+ ...(added.length > 0 ? { added } : {}),
602
+ removedCount,
603
+ ...(removed.length > 0 ? { removed } : {}),
604
+ changedCount,
605
+ ...(changedPlans.length > 0 ? { changedPlans } : {}),
606
+ unchangedCount,
607
+ ...(unchanged.length > 0 ? { unchanged } : {}),
608
+ };
609
+ }
610
+ /**
611
+ * Key Current Finances rows for matching: by id when present, since ProjectionLab
612
+ * ids are stable, else by name plus occurrence the way events are keyed.
613
+ */
614
+ function keyRows(rows) {
615
+ // Rows without an id keep their own entry under the event keying, which
616
+ // numbers repeated names so two of them can't collapse into one.
617
+ const idlessKeys = [
618
+ ...keyEvents(rows.filter((row) => {
619
+ const id = (row ?? {});
620
+ return id.id === undefined || id.id === null || id.id === "";
621
+ })).keys(),
622
+ ];
623
+ const keyed = new Map();
624
+ let idlessAt = 0;
625
+ for (const row of rows) {
626
+ const obj = (row ?? {});
627
+ const id = obj.id;
628
+ if (id !== undefined && id !== null && id !== "") {
629
+ keyed.set(`id:${String(id)}`, {
630
+ id: String(id),
631
+ name: planNameOf(row),
632
+ row,
633
+ });
634
+ continue;
635
+ }
636
+ const key = idlessKeys[idlessAt++];
637
+ keyed.set(key, { id: "", name: planNameOf(row), row });
638
+ }
639
+ return keyed;
640
+ }
641
+ function diffCollection(before, after, budget) {
642
+ const beforeRows = asArray(before);
643
+ const afterRows = asArray(after);
644
+ const beforeKeyed = keyRows(beforeRows);
645
+ const afterKeyed = keyRows(afterRows);
646
+ const added = [];
647
+ const removed = [];
648
+ const modified = [];
649
+ let addedCount = 0;
650
+ let removedCount = 0;
651
+ let modifiedCount = 0;
652
+ let unchangedCount = 0;
653
+ for (const [key, { id, name, row }] of afterKeyed) {
654
+ const previous = beforeKeyed.get(key);
655
+ if (!previous) {
656
+ addedCount++;
657
+ addEntry(added, { id, name }, budget);
658
+ continue;
659
+ }
660
+ const leaf = diffLeafValues(previous.row, row, budget);
661
+ if (leaf.detected > 0) {
662
+ modifiedCount++;
663
+ addEntry(modified, changeEntry({ id, name }, leaf), budget);
664
+ }
665
+ else {
666
+ unchangedCount++;
667
+ }
668
+ }
669
+ for (const [key, { id, name }] of beforeKeyed) {
670
+ if (!afterKeyed.has(key)) {
671
+ removedCount++;
672
+ addEntry(removed, { id, name }, budget);
673
+ }
674
+ }
675
+ return {
676
+ kind: "rows",
677
+ count: `${beforeRows.length} → ${afterRows.length}`,
678
+ changed: addedCount > 0 || removedCount > 0 || modifiedCount > 0,
679
+ addedCount,
680
+ ...(added.length > 0 ? { added } : {}),
681
+ removedCount,
682
+ ...(removed.length > 0 ? { removed } : {}),
683
+ modifiedCount,
684
+ ...(modified.length > 0 ? { modified } : {}),
685
+ unchangedCount,
686
+ };
687
+ }
688
+ /**
689
+ * Diff Current Finances collection by collection, matching rows by id. Fields
690
+ * outside the account collections (a `today` key that is not one of the four
691
+ * collections) are compared leaf by leaf.
692
+ */
693
+ export function diffCurrentFinances(before, after, budget) {
694
+ const beforeRoot = (isContainer(before) ? before : {});
695
+ const afterRoot = (isContainer(after) ? after : {});
696
+ const collections = {};
697
+ let changed = false;
698
+ for (const collection of CURRENT_FINANCE_COLLECTIONS) {
699
+ if (!Array.isArray(beforeRoot[collection]) &&
700
+ !Array.isArray(afterRoot[collection])) {
701
+ continue;
702
+ }
703
+ const result = diffCollection(beforeRoot[collection], afterRoot[collection], budget);
704
+ collections[collection] = result;
705
+ if (result.changed)
706
+ changed = true;
707
+ }
708
+ const skip = new Set(CURRENT_FINANCE_COLLECTIONS);
709
+ const beforeRest = {};
710
+ const afterRest = {};
711
+ for (const key of new Set([
712
+ ...Object.keys(beforeRoot),
713
+ ...Object.keys(afterRoot),
714
+ ])) {
715
+ if (skip.has(key))
716
+ continue;
717
+ beforeRest[key] = beforeRoot[key];
718
+ afterRest[key] = afterRoot[key];
719
+ }
720
+ const rest = diffLeafValues(beforeRest, afterRest, budget);
721
+ return {
722
+ changed: changed || rest.detected > 0,
723
+ collections,
724
+ ...changeEntry({}, rest),
725
+ };
726
+ }
727
+ /**
728
+ * Diff one snapshot data section (`plans`, `currentFinances`, `progress`,
729
+ * `settings`) by its stored key.
730
+ */
731
+ export function diffSectionData(section, before, after, budget, options = {}) {
732
+ switch (section) {
733
+ case "plans":
734
+ return diffPlanList(before, after, budget, {
735
+ section: options.planSection,
736
+ onlyKey: options.planOnlyKey,
737
+ });
738
+ case "currentFinances":
739
+ return diffCurrentFinances(before, after, budget);
740
+ default:
741
+ return diffFieldSection(before, after, budget);
742
+ }
743
+ }
744
+ //# sourceMappingURL=diff.js.map