forge-openclaw-plugin 0.2.27 → 0.2.29

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 (39) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{board-C6jCchjI.js → board-q8cfwaAW.js} +2 -2
  3. package/dist/assets/{board-C6jCchjI.js.map → board-q8cfwaAW.js.map} +1 -1
  4. package/dist/assets/index-C6PCeHD_.css +1 -0
  5. package/dist/assets/index-bfHIqj0-.js +85 -0
  6. package/dist/assets/index-bfHIqj0-.js.map +1 -0
  7. package/dist/assets/{motion-DFHrH2rd.js → motion-DHfqFntt.js} +2 -2
  8. package/dist/assets/{motion-DFHrH2rd.js.map → motion-DHfqFntt.js.map} +1 -1
  9. package/dist/assets/{table-ZL7Di_u3.js → table-DLweENXt.js} +2 -2
  10. package/dist/assets/{table-ZL7Di_u3.js.map → table-DLweENXt.js.map} +1 -1
  11. package/dist/assets/{ui-CKNPpz7q.js → ui-BV0OYxkH.js} +2 -2
  12. package/dist/assets/{ui-CKNPpz7q.js.map → ui-BV0OYxkH.js.map} +1 -1
  13. package/dist/assets/{vendor-DoNZuFhn.js → vendor-OwcH20PM.js} +204 -204
  14. package/dist/assets/vendor-OwcH20PM.js.map +1 -0
  15. package/dist/index.html +7 -7
  16. package/dist/server/server/migrations/044_macos_local_calendar_provider.sql +21 -0
  17. package/dist/server/server/src/app.js +331 -14
  18. package/dist/server/server/src/openapi.js +828 -3
  19. package/dist/server/server/src/repositories/calendar.js +295 -12
  20. package/dist/server/server/src/repositories/tasks.js +36 -17
  21. package/dist/server/server/src/services/calendar-runtime.js +613 -32
  22. package/dist/server/server/src/services/life-force-model.js +20 -0
  23. package/dist/server/server/src/services/life-force.js +1333 -97
  24. package/dist/server/server/src/services/macos-calendar-helper.js +748 -0
  25. package/dist/server/server/src/types.js +67 -3
  26. package/dist/server/src/lib/api-error.js +2 -0
  27. package/dist/server/src/lib/api.js +39 -2
  28. package/dist/server/src/lib/calendar-name-deduper.js +2 -0
  29. package/dist/server/src/lib/snapshot-normalizer.js +2 -0
  30. package/openclaw.plugin.json +1 -1
  31. package/package.json +1 -1
  32. package/server/migrations/044_macos_local_calendar_provider.sql +21 -0
  33. package/skills/forge-openclaw/SKILL.md +38 -5
  34. package/skills/forge-openclaw/entity_conversation_playbooks.md +326 -5
  35. package/skills/forge-openclaw/psyche_entity_playbooks.md +57 -0
  36. package/dist/assets/index-DVvS8iiU.css +0 -1
  37. package/dist/assets/index-zYB-9Dfo.js +0 -85
  38. package/dist/assets/index-zYB-9Dfo.js.map +0 -1
  39. package/dist/assets/vendor-DoNZuFhn.js.map +0 -1
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { getDatabase, runInTransaction } from "../db.js";
3
3
  import { getDefaultUser, getUserById } from "../repositories/users.js";
4
4
  import { actionProfileSchema, fatigueSignalCreateSchema, lifeForcePayloadSchema, lifeForceProfilePatchSchema, lifeForceTemplateUpdateSchema } from "../types.js";
5
- import { DEFAULT_TASK_TOTAL_AP, LIFE_FORCE_BASELINE_DAILY_AP, buildDefaultTaskActionProfile, buildTaskActionPointSummary, buildTaskSplitSuggestion, clamp, interpolateCurveRate, normalizeCurveToBudget, resolveBandTotalCostAp, resolveTaskExpectedDurationSeconds } from "./life-force-model.js";
5
+ import { LIFE_FORCE_BASELINE_DAILY_AP, buildDefaultTaskActionProfile, buildTaskActionPointSummary, buildTaskSplitSuggestion, clamp, computeActionCostModifier, computeLifeForceLevelMultiplier, computeStatCostModifier, interpolateCurveRate, normalizeCurveToBudget, resolveBandTotalCostAp, resolveTaskExpectedDurationSeconds } from "./life-force-model.js";
6
6
  import { computeWorkTime } from "./work-time.js";
7
7
  const DEFAULT_TEMPLATE_POINTS = [
8
8
  { minuteOfDay: 0, rateApPerHour: 0 },
@@ -23,6 +23,217 @@ const LIFE_FORCE_STAT_LABELS = {
23
23
  composure: "Composure",
24
24
  flow: "Flow"
25
25
  };
26
+ const CALENDAR_ACTIVITY_PRESETS = {
27
+ deep_work: {
28
+ title: "Deep work",
29
+ mode: "container",
30
+ sustainRateApPerHour: 14,
31
+ demandWeights: {
32
+ activation: 0.1,
33
+ focus: 0.55,
34
+ vigor: 0.05,
35
+ composure: 0.05,
36
+ flow: 0.25
37
+ },
38
+ doubleCountPolicy: "container_only",
39
+ costBand: "light",
40
+ recoveryEffect: 0,
41
+ metadata: {
42
+ physicalIntensity: 0.15,
43
+ cognitiveDemand: 0.9,
44
+ socialLoad: 0.1,
45
+ switchingLoad: 0.2
46
+ }
47
+ },
48
+ admin: {
49
+ title: "Admin and coordination",
50
+ mode: "container",
51
+ sustainRateApPerHour: 9,
52
+ demandWeights: {
53
+ activation: 0.15,
54
+ focus: 0.3,
55
+ vigor: 0.05,
56
+ composure: 0.1,
57
+ flow: 0.4
58
+ },
59
+ doubleCountPolicy: "container_only",
60
+ costBand: "light",
61
+ recoveryEffect: 0,
62
+ metadata: {
63
+ physicalIntensity: 0.1,
64
+ cognitiveDemand: 0.45,
65
+ socialLoad: 0.25,
66
+ switchingLoad: 0.7
67
+ }
68
+ },
69
+ maintenance: {
70
+ title: "Maintenance and light activity",
71
+ mode: "container",
72
+ sustainRateApPerHour: 6,
73
+ demandWeights: {
74
+ activation: 0.15,
75
+ focus: 0.2,
76
+ vigor: 0.1,
77
+ composure: 0.05,
78
+ flow: 0.5
79
+ },
80
+ doubleCountPolicy: "container_only",
81
+ costBand: "light",
82
+ recoveryEffect: 0,
83
+ metadata: {
84
+ physicalIntensity: 0.2,
85
+ cognitiveDemand: 0.25,
86
+ socialLoad: 0.1,
87
+ switchingLoad: 0.5
88
+ }
89
+ },
90
+ meeting: {
91
+ title: "Meeting or social commitment",
92
+ mode: "container",
93
+ sustainRateApPerHour: 13,
94
+ demandWeights: {
95
+ activation: 0.05,
96
+ focus: 0.25,
97
+ vigor: 0.05,
98
+ composure: 0.45,
99
+ flow: 0.2
100
+ },
101
+ doubleCountPolicy: "container_only",
102
+ costBand: "standard",
103
+ recoveryEffect: 0,
104
+ metadata: {
105
+ physicalIntensity: 0.1,
106
+ cognitiveDemand: 0.45,
107
+ socialLoad: 0.85,
108
+ switchingLoad: 0.35
109
+ }
110
+ },
111
+ recovery_break: {
112
+ title: "Rest and recovery",
113
+ mode: "recovery",
114
+ sustainRateApPerHour: 3,
115
+ demandWeights: {
116
+ activation: 0.05,
117
+ focus: 0.05,
118
+ vigor: 0.15,
119
+ composure: 0.15,
120
+ flow: 0.1
121
+ },
122
+ doubleCountPolicy: "container_only",
123
+ costBand: "tiny",
124
+ recoveryEffect: 4,
125
+ metadata: {
126
+ physicalIntensity: 0.1,
127
+ cognitiveDemand: 0.05,
128
+ socialLoad: 0.1,
129
+ switchingLoad: 0.05
130
+ }
131
+ },
132
+ holiday_leisure: {
133
+ title: "Holiday or leisure time",
134
+ mode: "container",
135
+ sustainRateApPerHour: 4,
136
+ demandWeights: {
137
+ activation: 0.05,
138
+ focus: 0.05,
139
+ vigor: 0.15,
140
+ composure: 0.25,
141
+ flow: 0.1
142
+ },
143
+ doubleCountPolicy: "container_only",
144
+ costBand: "tiny",
145
+ recoveryEffect: 2,
146
+ metadata: {
147
+ physicalIntensity: 0.15,
148
+ cognitiveDemand: 0.1,
149
+ socialLoad: 0.35,
150
+ switchingLoad: 0.1
151
+ }
152
+ },
153
+ light_context: {
154
+ title: "Light context",
155
+ mode: "container",
156
+ sustainRateApPerHour: 2,
157
+ demandWeights: {
158
+ activation: 0.05,
159
+ focus: 0.1,
160
+ vigor: 0.05,
161
+ composure: 0.15,
162
+ flow: 0.15
163
+ },
164
+ doubleCountPolicy: "container_only",
165
+ costBand: "tiny",
166
+ recoveryEffect: 0,
167
+ metadata: {
168
+ physicalIntensity: 0.05,
169
+ cognitiveDemand: 0.15,
170
+ socialLoad: 0.2,
171
+ switchingLoad: 0.15
172
+ }
173
+ }
174
+ };
175
+ function buildStatLevels(profile) {
176
+ return {
177
+ life_force: profile.life_force_level,
178
+ activation: profile.activation_level,
179
+ focus: profile.focus_level,
180
+ vigor: profile.vigor_level,
181
+ composure: profile.composure_level,
182
+ flow: profile.flow_level
183
+ };
184
+ }
185
+ function buildEffectiveProfile(profile, lifeForceProfile) {
186
+ const costModifier = computeActionCostModifier(profile.demandWeights, buildStatLevels(lifeForceProfile));
187
+ return {
188
+ ...profile,
189
+ startupAp: Number((profile.startupAp * costModifier).toFixed(4)),
190
+ totalCostAp: Number((profile.totalCostAp * costModifier).toFixed(4)),
191
+ sustainRateApPerHour: Number((profile.sustainRateApPerHour * costModifier).toFixed(4)),
192
+ metadata: {
193
+ ...profile.metadata,
194
+ costModifier
195
+ }
196
+ };
197
+ }
198
+ function rateToTotalAp(rateApPerHour, durationSeconds) {
199
+ return Number(((durationSeconds / 3600) * rateApPerHour).toFixed(4));
200
+ }
201
+ function overlapsWindow(leftStartIso, leftEndIso, rightStartIso, rightEndIso) {
202
+ return (Date.parse(leftStartIso) < Date.parse(rightEndIso) &&
203
+ Date.parse(leftEndIso) > Date.parse(rightStartIso));
204
+ }
205
+ function clipWindowToRange(window, range) {
206
+ const startMs = Math.max(Date.parse(window.startAt), Date.parse(range.startAt));
207
+ const endMs = Math.min(Date.parse(window.endAt), Date.parse(range.endAt));
208
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
209
+ return null;
210
+ }
211
+ return { startMs, endMs };
212
+ }
213
+ function computeUncoveredSeconds(window, blockingWindows) {
214
+ const clippedBlocks = blockingWindows
215
+ .map((candidate) => clipWindowToRange(candidate, window))
216
+ .filter((candidate) => candidate !== null)
217
+ .sort((left, right) => left.startMs - right.startMs);
218
+ const totalSeconds = Math.max(0, Math.floor((Date.parse(window.endAt) - Date.parse(window.startAt)) / 1000));
219
+ if (totalSeconds <= 0 || clippedBlocks.length === 0) {
220
+ return totalSeconds;
221
+ }
222
+ let coveredMs = 0;
223
+ let activeStartMs = clippedBlocks[0].startMs;
224
+ let activeEndMs = clippedBlocks[0].endMs;
225
+ for (const block of clippedBlocks.slice(1)) {
226
+ if (block.startMs <= activeEndMs) {
227
+ activeEndMs = Math.max(activeEndMs, block.endMs);
228
+ continue;
229
+ }
230
+ coveredMs += activeEndMs - activeStartMs;
231
+ activeStartMs = block.startMs;
232
+ activeEndMs = block.endMs;
233
+ }
234
+ coveredMs += activeEndMs - activeStartMs;
235
+ return Math.max(0, totalSeconds - Math.floor(coveredMs / 1000));
236
+ }
26
237
  function nowIso() {
27
238
  return new Date().toISOString();
28
239
  }
@@ -69,6 +280,125 @@ function parseCurvePoints(raw) {
69
280
  function defaultTemplatePoints() {
70
281
  return normalizeCurveToBudget(DEFAULT_TEMPLATE_POINTS, LIFE_FORCE_BASELINE_DAILY_AP);
71
282
  }
283
+ function resolveWorkBlockPresetKey(kind) {
284
+ if (kind === "main_activity") {
285
+ return "deep_work";
286
+ }
287
+ if (kind === "secondary_activity") {
288
+ return "admin";
289
+ }
290
+ if (kind === "third_activity" || kind === "custom") {
291
+ return "maintenance";
292
+ }
293
+ if (kind === "holiday") {
294
+ return "holiday_leisure";
295
+ }
296
+ return "recovery_break";
297
+ }
298
+ function resolveCalendarEventPresetKey(input) {
299
+ const searchable = `${input.title} ${input.eventType ?? ""}`.toLowerCase();
300
+ if (searchable.includes("meeting") || searchable.includes("call") || searchable.includes("interview")) {
301
+ return "meeting";
302
+ }
303
+ if (searchable.includes("deep work") || searchable.includes("focus")) {
304
+ return "deep_work";
305
+ }
306
+ if (searchable.includes("admin") || searchable.includes("email") || searchable.includes("inbox")) {
307
+ return "admin";
308
+ }
309
+ if (searchable.includes("lunch") ||
310
+ searchable.includes("break") ||
311
+ searchable.includes("rest")) {
312
+ return "recovery_break";
313
+ }
314
+ if (searchable.includes("holiday") || searchable.includes("vacation")) {
315
+ return "holiday_leisure";
316
+ }
317
+ return input.availability === "busy" ? "meeting" : "light_context";
318
+ }
319
+ function buildCalendarActivityProfile(input) {
320
+ const durationSeconds = Math.max(1, input.expectedDurationSeconds);
321
+ const presetDefinition = input.activityPresetKey === "task_inherited"
322
+ ? null
323
+ : CALENDAR_ACTIVITY_PRESETS[input.activityPresetKey];
324
+ const inheritedProfile = input.fallbackProfile;
325
+ const baseProfile = presetDefinition === null
326
+ ? inheritedProfile
327
+ : actionProfileSchema.parse({
328
+ id: `profile_${input.entityType}_${input.entityId}`,
329
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
330
+ title: input.title || presetDefinition.title,
331
+ entityType: input.entityType,
332
+ mode: presetDefinition.mode,
333
+ startupAp: 0,
334
+ totalCostAp: rateToTotalAp(presetDefinition.sustainRateApPerHour, durationSeconds),
335
+ expectedDurationSeconds: durationSeconds,
336
+ sustainRateApPerHour: presetDefinition.sustainRateApPerHour,
337
+ demandWeights: presetDefinition.demandWeights,
338
+ doubleCountPolicy: presetDefinition.doubleCountPolicy,
339
+ sourceMethod: input.sourceMethod ?? "inferred",
340
+ costBand: presetDefinition.costBand,
341
+ recoveryEffect: presetDefinition.recoveryEffect,
342
+ metadata: {
343
+ activityPresetKey: input.activityPresetKey,
344
+ ...presetDefinition.metadata,
345
+ ...(input.metadata ?? {})
346
+ },
347
+ createdAt: nowIso(),
348
+ updatedAt: nowIso()
349
+ });
350
+ const base = baseProfile ??
351
+ actionProfileSchema.parse({
352
+ id: `profile_${input.entityType}_${input.entityId}`,
353
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
354
+ title: input.title || "Activity",
355
+ entityType: input.entityType,
356
+ mode: "container",
357
+ startupAp: 0,
358
+ totalCostAp: rateToTotalAp(8, durationSeconds),
359
+ expectedDurationSeconds: durationSeconds,
360
+ sustainRateApPerHour: 8,
361
+ demandWeights: {
362
+ activation: 0.1,
363
+ focus: 0.3,
364
+ vigor: 0.1,
365
+ composure: 0.1,
366
+ flow: 0.4
367
+ },
368
+ doubleCountPolicy: "container_only",
369
+ sourceMethod: input.sourceMethod ?? "inferred",
370
+ costBand: "light",
371
+ recoveryEffect: 0,
372
+ metadata: {
373
+ activityPresetKey: "maintenance",
374
+ ...(input.metadata ?? {})
375
+ },
376
+ createdAt: nowIso(),
377
+ updatedAt: nowIso()
378
+ });
379
+ const sustainRateApPerHour = input.customSustainRateApPerHour ?? base.sustainRateApPerHour;
380
+ return actionProfileSchema.parse({
381
+ ...base,
382
+ id: `profile_${input.entityType}_${input.entityId}`,
383
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
384
+ title: input.title || base.title,
385
+ entityType: input.entityType,
386
+ expectedDurationSeconds: durationSeconds,
387
+ totalCostAp: rateToTotalAp(sustainRateApPerHour, durationSeconds),
388
+ sustainRateApPerHour,
389
+ sourceMethod: input.customSustainRateApPerHour !== null &&
390
+ input.customSustainRateApPerHour !== undefined
391
+ ? "manual"
392
+ : input.sourceMethod ?? base.sourceMethod,
393
+ metadata: {
394
+ ...base.metadata,
395
+ activityPresetKey: input.activityPresetKey,
396
+ customSustainRateApPerHour: input.customSustainRateApPerHour ?? null,
397
+ ...(input.metadata ?? {})
398
+ },
399
+ updatedAt: nowIso()
400
+ });
401
+ }
72
402
  function seededActionProfiles() {
73
403
  const now = nowIso();
74
404
  const taskDefault = buildDefaultTaskActionProfile({});
@@ -198,6 +528,206 @@ function seededActionProfiles() {
198
528
  metadata: {},
199
529
  createdAt: now,
200
530
  updatedAt: now
531
+ }),
532
+ actionProfileSchema.parse({
533
+ id: "profile_wake_start",
534
+ profileKey: "wake_start",
535
+ title: "Get out of bed",
536
+ entityType: "sleep_session",
537
+ mode: "impulse",
538
+ startupAp: 5,
539
+ totalCostAp: 5,
540
+ expectedDurationSeconds: null,
541
+ sustainRateApPerHour: 0,
542
+ demandWeights: {
543
+ activation: 0.75,
544
+ focus: 0.05,
545
+ vigor: 0.1,
546
+ composure: 0,
547
+ flow: 0.1
548
+ },
549
+ doubleCountPolicy: "primary_only",
550
+ sourceMethod: "seeded",
551
+ costBand: "light",
552
+ recoveryEffect: 0,
553
+ metadata: {},
554
+ createdAt: now,
555
+ updatedAt: now
556
+ }),
557
+ actionProfileSchema.parse({
558
+ id: "profile_timebox_default",
559
+ profileKey: "task_timebox_default",
560
+ title: "Task timebox",
561
+ entityType: "task_timebox",
562
+ mode: "container",
563
+ startupAp: 0,
564
+ totalCostAp: 12,
565
+ expectedDurationSeconds: 3_600,
566
+ sustainRateApPerHour: 12,
567
+ demandWeights: {
568
+ activation: 0.15,
569
+ focus: 0.45,
570
+ vigor: 0.05,
571
+ composure: 0.05,
572
+ flow: 0.3
573
+ },
574
+ doubleCountPolicy: "container_only",
575
+ sourceMethod: "seeded",
576
+ costBand: "light",
577
+ recoveryEffect: 0,
578
+ metadata: {},
579
+ createdAt: now,
580
+ updatedAt: now
581
+ }),
582
+ actionProfileSchema.parse({
583
+ id: "profile_work_block_main",
584
+ profileKey: "work_block_main",
585
+ title: "Main activity block",
586
+ entityType: "work_block",
587
+ mode: "container",
588
+ startupAp: 0,
589
+ totalCostAp: 14,
590
+ expectedDurationSeconds: 3_600,
591
+ sustainRateApPerHour: 14,
592
+ demandWeights: {
593
+ activation: 0.1,
594
+ focus: 0.5,
595
+ vigor: 0.1,
596
+ composure: 0.05,
597
+ flow: 0.25
598
+ },
599
+ doubleCountPolicy: "container_only",
600
+ sourceMethod: "seeded",
601
+ costBand: "light",
602
+ recoveryEffect: 0,
603
+ metadata: { kind: "main_activity" },
604
+ createdAt: now,
605
+ updatedAt: now
606
+ }),
607
+ actionProfileSchema.parse({
608
+ id: "profile_work_block_secondary",
609
+ profileKey: "work_block_secondary",
610
+ title: "Secondary activity block",
611
+ entityType: "work_block",
612
+ mode: "container",
613
+ startupAp: 0,
614
+ totalCostAp: 9,
615
+ expectedDurationSeconds: 3_600,
616
+ sustainRateApPerHour: 9,
617
+ demandWeights: {
618
+ activation: 0.15,
619
+ focus: 0.3,
620
+ vigor: 0.1,
621
+ composure: 0.05,
622
+ flow: 0.4
623
+ },
624
+ doubleCountPolicy: "container_only",
625
+ sourceMethod: "seeded",
626
+ costBand: "light",
627
+ recoveryEffect: 0,
628
+ metadata: { kind: "secondary_activity" },
629
+ createdAt: now,
630
+ updatedAt: now
631
+ }),
632
+ actionProfileSchema.parse({
633
+ id: "profile_work_block_third",
634
+ profileKey: "work_block_third",
635
+ title: "Third activity block",
636
+ entityType: "work_block",
637
+ mode: "container",
638
+ startupAp: 0,
639
+ totalCostAp: 6,
640
+ expectedDurationSeconds: 3_600,
641
+ sustainRateApPerHour: 6,
642
+ demandWeights: {
643
+ activation: 0.15,
644
+ focus: 0.2,
645
+ vigor: 0.1,
646
+ composure: 0.05,
647
+ flow: 0.5
648
+ },
649
+ doubleCountPolicy: "container_only",
650
+ sourceMethod: "seeded",
651
+ costBand: "light",
652
+ recoveryEffect: 0,
653
+ metadata: { kind: "third_activity" },
654
+ createdAt: now,
655
+ updatedAt: now
656
+ }),
657
+ actionProfileSchema.parse({
658
+ id: "profile_work_block_rest",
659
+ profileKey: "work_block_rest",
660
+ title: "Rest block",
661
+ entityType: "work_block",
662
+ mode: "recovery",
663
+ startupAp: 0,
664
+ totalCostAp: 3,
665
+ expectedDurationSeconds: 3_600,
666
+ sustainRateApPerHour: 3,
667
+ demandWeights: {
668
+ activation: 0.05,
669
+ focus: 0.05,
670
+ vigor: 0.15,
671
+ composure: 0.15,
672
+ flow: 0.1
673
+ },
674
+ doubleCountPolicy: "container_only",
675
+ sourceMethod: "seeded",
676
+ costBand: "tiny",
677
+ recoveryEffect: 4,
678
+ metadata: { kind: "rest", activityPresetKey: "recovery_break" },
679
+ createdAt: now,
680
+ updatedAt: now
681
+ }),
682
+ actionProfileSchema.parse({
683
+ id: "profile_work_block_holiday",
684
+ profileKey: "work_block_holiday",
685
+ title: "Holiday block",
686
+ entityType: "work_block",
687
+ mode: "container",
688
+ startupAp: 0,
689
+ totalCostAp: 4,
690
+ expectedDurationSeconds: 3_600,
691
+ sustainRateApPerHour: 4,
692
+ demandWeights: {
693
+ activation: 0.05,
694
+ focus: 0.05,
695
+ vigor: 0.15,
696
+ composure: 0.25,
697
+ flow: 0.1
698
+ },
699
+ doubleCountPolicy: "container_only",
700
+ sourceMethod: "seeded",
701
+ costBand: "tiny",
702
+ recoveryEffect: 2,
703
+ metadata: { kind: "holiday", activityPresetKey: "holiday_leisure" },
704
+ createdAt: now,
705
+ updatedAt: now
706
+ }),
707
+ actionProfileSchema.parse({
708
+ id: "profile_movement_trip",
709
+ profileKey: "movement_trip_default",
710
+ title: "Movement trip",
711
+ entityType: "movement_trip",
712
+ mode: "rate",
713
+ startupAp: 0,
714
+ totalCostAp: 8,
715
+ expectedDurationSeconds: 3_600,
716
+ sustainRateApPerHour: 8,
717
+ demandWeights: {
718
+ activation: 0.05,
719
+ focus: 0.15,
720
+ vigor: 0.55,
721
+ composure: 0,
722
+ flow: 0.25
723
+ },
724
+ doubleCountPolicy: "primary_only",
725
+ sourceMethod: "seeded",
726
+ costBand: "light",
727
+ recoveryEffect: 0,
728
+ metadata: {},
729
+ createdAt: now,
730
+ updatedAt: now
201
731
  })
202
732
  ];
203
733
  }
@@ -223,6 +753,17 @@ function mapEntityProfileRow(row, fallback) {
223
753
  ...JSON.parse(row.profile_json)
224
754
  });
225
755
  }
756
+ function readEntityActionProfileRow(entityType, entityId) {
757
+ return getDatabase()
758
+ .prepare(`SELECT id, entity_type, entity_id, profile_json, created_at, updated_at
759
+ FROM entity_action_profiles
760
+ WHERE entity_type = ? AND entity_id = ?`)
761
+ .get(entityType, entityId);
762
+ }
763
+ export function readEntityActionProfile(entityType, entityId, fallback) {
764
+ const row = readEntityActionProfileRow(entityType, entityId);
765
+ return row ? mapEntityProfileRow(row, fallback) : null;
766
+ }
226
767
  function ensureActionProfileTemplates() {
227
768
  const database = getDatabase();
228
769
  const insert = database.prepare(`INSERT OR IGNORE INTO action_profile_templates (
@@ -290,6 +831,80 @@ export function upsertTaskActionProfile(input) {
290
831
  metadata: profile.metadata
291
832
  }), now, now);
292
833
  }
834
+ export function upsertEntityActionProfile(input) {
835
+ const now = nowIso();
836
+ getDatabase()
837
+ .prepare(`INSERT INTO entity_action_profiles (
838
+ id,
839
+ entity_type,
840
+ entity_id,
841
+ profile_json,
842
+ created_at,
843
+ updated_at
844
+ ) VALUES (?, ?, ?, ?, ?, ?)
845
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
846
+ profile_json = excluded.profile_json,
847
+ updated_at = excluded.updated_at`)
848
+ .run(input.profile.id, input.entityType, input.entityId, JSON.stringify({
849
+ mode: input.profile.mode,
850
+ startupAp: input.profile.startupAp,
851
+ totalCostAp: input.profile.totalCostAp,
852
+ expectedDurationSeconds: input.profile.expectedDurationSeconds,
853
+ sustainRateApPerHour: input.profile.sustainRateApPerHour,
854
+ demandWeights: input.profile.demandWeights,
855
+ doubleCountPolicy: input.profile.doubleCountPolicy,
856
+ sourceMethod: input.profile.sourceMethod,
857
+ costBand: input.profile.costBand,
858
+ recoveryEffect: input.profile.recoveryEffect,
859
+ metadata: input.profile.metadata
860
+ }), now, now);
861
+ }
862
+ export function buildWorkBlockTemplateActionProfile(input) {
863
+ const durationMinutes = input.endMinute > input.startMinute
864
+ ? input.endMinute - input.startMinute
865
+ : 24 * 60 - input.startMinute + input.endMinute;
866
+ const activityPresetKey = input.activityPresetKey ??
867
+ resolveWorkBlockPresetKey(input.kind);
868
+ return buildCalendarActivityProfile({
869
+ entityType: "work_block_template",
870
+ entityId: input.templateId,
871
+ title: input.title,
872
+ expectedDurationSeconds: durationMinutes * 60,
873
+ activityPresetKey,
874
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
875
+ sourceMethod: input.customSustainRateApPerHour !== null &&
876
+ input.customSustainRateApPerHour !== undefined
877
+ ? "manual"
878
+ : "inferred",
879
+ metadata: {
880
+ kind: input.kind
881
+ }
882
+ });
883
+ }
884
+ export function buildCalendarEventActionProfile(input) {
885
+ const activityPresetKey = input.activityPresetKey ??
886
+ resolveCalendarEventPresetKey({
887
+ title: input.title,
888
+ eventType: input.eventType,
889
+ availability: input.availability
890
+ });
891
+ return buildCalendarActivityProfile({
892
+ entityType: "calendar_event",
893
+ entityId: input.eventId,
894
+ title: input.title,
895
+ expectedDurationSeconds: Math.max(60, Math.floor((Date.parse(input.endAt) - Date.parse(input.startAt)) / 1000)),
896
+ activityPresetKey,
897
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
898
+ sourceMethod: input.customSustainRateApPerHour !== null &&
899
+ input.customSustainRateApPerHour !== undefined
900
+ ? "manual"
901
+ : "inferred",
902
+ metadata: {
903
+ availability: input.availability,
904
+ eventType: input.eventType ?? ""
905
+ }
906
+ });
907
+ }
293
908
  function ensureLifeForceProfile(userId) {
294
909
  const database = getDatabase();
295
910
  const existing = database
@@ -404,14 +1019,7 @@ function readStatXpByKey(userId) {
404
1019
  }
405
1020
  function buildStats(profile, userId) {
406
1021
  const xpByKey = readStatXpByKey(userId);
407
- const levelByKey = {
408
- life_force: profile.life_force_level,
409
- activation: profile.activation_level,
410
- focus: profile.focus_level,
411
- vigor: profile.vigor_level,
412
- composure: profile.composure_level,
413
- flow: profile.flow_level
414
- };
1022
+ const levelByKey = buildStatLevels(profile);
415
1023
  return Object.keys(levelByKey).map((key) => {
416
1024
  const level = levelByKey[key];
417
1025
  return {
@@ -421,16 +1029,43 @@ function buildStats(profile, userId) {
421
1029
  xp: xpByKey.get(key) ?? 0,
422
1030
  xpToNextLevel: level * 100,
423
1031
  costModifier: key === "life_force"
424
- ? 1 + level * 0.03
425
- : Math.max(0.55, Number((1 - level * 0.02).toFixed(3)))
1032
+ ? Number(computeLifeForceLevelMultiplier(level).toFixed(3))
1033
+ : Number(computeStatCostModifier(level).toFixed(3))
426
1034
  };
427
1035
  });
428
1036
  }
429
1037
  function computeLifeForceMultiplier(profile) {
430
- return 1 + (profile.life_force_level - 1) * 0.03;
1038
+ return computeLifeForceLevelMultiplier(profile.life_force_level);
431
1039
  }
432
- function computeSleepRecoveryMultiplier() {
433
- return 1;
1040
+ function readPrimarySleepSessionForDate(userId, date) {
1041
+ const range = buildDayRange(date);
1042
+ const lookback = new Date(range.startMs - 18 * 60 * 60 * 1000).toISOString();
1043
+ try {
1044
+ return getDatabase()
1045
+ .prepare(`SELECT id, started_at, ended_at, asleep_seconds, sleep_score
1046
+ FROM health_sleep_sessions
1047
+ WHERE user_id = ?
1048
+ AND ended_at >= ?
1049
+ AND ended_at < ?
1050
+ ORDER BY asleep_seconds DESC, ended_at DESC
1051
+ LIMIT 1`)
1052
+ .get(userId, lookback, range.to);
1053
+ }
1054
+ catch {
1055
+ return undefined;
1056
+ }
1057
+ }
1058
+ function computeSleepRecoveryMultiplier(userId, date) {
1059
+ const session = readPrimarySleepSessionForDate(userId, date);
1060
+ if (!session) {
1061
+ return 1;
1062
+ }
1063
+ const sleepHours = session.asleep_seconds / 3600;
1064
+ const durationFactor = clamp(0.82 + ((sleepHours - 4.5) / 4.5) * 0.22, 0.85, 1.1);
1065
+ const scoreFactor = session.sleep_score === null
1066
+ ? 1
1067
+ : clamp(0.92 + (session.sleep_score / 100) * 0.16, 0.9, 1.08);
1068
+ return Number((durationFactor * scoreFactor).toFixed(3));
434
1069
  }
435
1070
  function computeFatigueDebtCarry(userId, date) {
436
1071
  const previous = new Date(date);
@@ -463,7 +1098,7 @@ function getOrCreateDaySnapshot(userId, date) {
463
1098
  }
464
1099
  const profile = ensureLifeForceProfile(userId);
465
1100
  const template = readWeekdayTemplate(userId, date.getUTCDay());
466
- const sleepRecoveryMultiplier = computeSleepRecoveryMultiplier();
1101
+ const sleepRecoveryMultiplier = computeSleepRecoveryMultiplier(userId, date);
467
1102
  const fatigueDebtCarry = computeFatigueDebtCarry(userId, date);
468
1103
  const readinessMultiplier = profile.readiness_multiplier;
469
1104
  const dailyBudgetAp = Math.max(40, Math.round(profile.base_daily_ap *
@@ -493,27 +1128,50 @@ function getOrCreateDaySnapshot(userId, date) {
493
1128
  WHERE id = ?`)
494
1129
  .get(id);
495
1130
  }
496
- function resolveTaskActionProfile(task) {
497
- const row = getDatabase()
498
- .prepare(`SELECT id, entity_type, entity_id, profile_json, created_at, updated_at
499
- FROM entity_action_profiles
500
- WHERE entity_type = 'task' AND entity_id = ?`)
501
- .get(task.id);
502
- if (!row) {
503
- return buildDefaultTaskActionProfile({
1131
+ export function resolveTaskActionProfile(task, lifeForceProfile) {
1132
+ const row = readEntityActionProfileRow("task", task.id);
1133
+ const baseProfile = !row
1134
+ ? buildDefaultTaskActionProfile({
504
1135
  id: `profile_task_${task.id}`,
505
1136
  expectedDurationSeconds: task.plannedDurationSeconds
1137
+ })
1138
+ : mapEntityProfileRow(row, {
1139
+ profileKey: `task_${task.id}`,
1140
+ title: "Task",
1141
+ entityType: "task"
506
1142
  });
507
- }
508
- const profile = mapEntityProfileRow(row, {
509
- profileKey: `task_${task.id}`,
510
- title: "Task",
511
- entityType: "task"
512
- });
513
- return {
514
- ...profile,
515
- expectedDurationSeconds: resolveTaskExpectedDurationSeconds(profile.expectedDurationSeconds ?? task.plannedDurationSeconds)
1143
+ const profile = {
1144
+ ...baseProfile,
1145
+ expectedDurationSeconds: resolveTaskExpectedDurationSeconds(baseProfile.expectedDurationSeconds ?? task.plannedDurationSeconds)
516
1146
  };
1147
+ return lifeForceProfile ? buildEffectiveProfile(profile, lifeForceProfile) : profile;
1148
+ }
1149
+ export function buildTaskTimeboxActionProfile(input) {
1150
+ const durationSeconds = Math.max(60, Math.floor((Date.parse(input.endsAt) - Date.parse(input.startsAt)) / 1000));
1151
+ const fallbackTaskProfile = resolveTaskActionProfile({
1152
+ id: input.taskId,
1153
+ plannedDurationSeconds: input.taskPlannedDurationSeconds ?? null
1154
+ });
1155
+ const activityPresetKey = input.activityPresetKey ??
1156
+ "task_inherited";
1157
+ return buildCalendarActivityProfile({
1158
+ entityType: "task_timebox",
1159
+ entityId: input.timeboxId,
1160
+ title: input.title,
1161
+ expectedDurationSeconds: durationSeconds,
1162
+ activityPresetKey,
1163
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
1164
+ sourceMethod: input.customSustainRateApPerHour !== null &&
1165
+ input.customSustainRateApPerHour !== undefined
1166
+ ? "manual"
1167
+ : activityPresetKey === "task_inherited"
1168
+ ? "inferred"
1169
+ : "manual",
1170
+ fallbackProfile: fallbackTaskProfile,
1171
+ metadata: {
1172
+ taskId: input.taskId
1173
+ }
1174
+ });
517
1175
  }
518
1176
  function readTodayAdjustmentRows(userId, range) {
519
1177
  return getDatabase()
@@ -538,17 +1196,18 @@ function readTodayAdjustmentRows(userId, range) {
538
1196
  AND work_adjustments.created_at < ?`)
539
1197
  .all(userId, range.from, range.to);
540
1198
  }
541
- function readTodayAdjustmentApByTaskId(userId, range) {
1199
+ function readTodayAdjustmentApByTaskId(userId, range, lifeForceProfile) {
542
1200
  const rows = readTodayAdjustmentRows(userId, range);
543
1201
  const totals = new Map();
544
1202
  for (const row of rows) {
545
1203
  if (row.entity_type !== "task") {
546
1204
  continue;
547
1205
  }
548
- const expectedDurationSeconds = resolveTaskExpectedDurationSeconds(row.planned_duration_seconds);
549
- const deltaAp = (DEFAULT_TASK_TOTAL_AP / expectedDurationSeconds) *
550
- row.applied_delta_minutes *
551
- 60;
1206
+ const profile = resolveTaskActionProfile({
1207
+ id: row.entity_id,
1208
+ plannedDurationSeconds: row.planned_duration_seconds
1209
+ }, lifeForceProfile);
1210
+ const deltaAp = rateToTotalAp(profile.sustainRateApPerHour, row.applied_delta_minutes * 60);
552
1211
  totals.set(row.entity_id, (totals.get(row.entity_id) ?? 0) + deltaAp);
553
1212
  }
554
1213
  return totals;
@@ -587,9 +1246,9 @@ function computeProjectedRemainingSeconds(row, now) {
587
1246
  const elapsedWallSeconds = Math.max(0, Math.floor((endMs - Date.parse(row.claimed_at)) / 1000));
588
1247
  return Math.max(0, row.planned_duration_seconds - elapsedWallSeconds);
589
1248
  }
590
- function buildTaskLifeForceRuntime(task, userId, now = new Date()) {
1249
+ function buildTaskLifeForceRuntime(task, userId, now = new Date(), lifeForceProfile = ensureLifeForceProfile(userId)) {
591
1250
  const range = buildDayRange(now);
592
- const profile = resolveTaskActionProfile(task);
1251
+ const profile = resolveTaskActionProfile(task, lifeForceProfile);
593
1252
  const todayRunSeconds = readTaskRunRows(range, userId)
594
1253
  .filter((row) => row.task_id === task.id)
595
1254
  .reduce((sum, row) => sum + overlapSeconds(range, row, now), 0);
@@ -624,14 +1283,15 @@ function readTaskRunWindowsByTaskId(userId, range, now) {
624
1283
  }
625
1284
  return windows;
626
1285
  }
627
- function buildWorkAdjustmentContributions(userId, range) {
1286
+ function buildWorkAdjustmentContributions(userId, range, lifeForceProfile) {
628
1287
  return readTodayAdjustmentRows(userId, range)
629
1288
  .filter((row) => row.entity_type === "task")
630
1289
  .map((row) => {
631
- const expectedDurationSeconds = resolveTaskExpectedDurationSeconds(row.planned_duration_seconds);
632
- const totalAp = (DEFAULT_TASK_TOTAL_AP / expectedDurationSeconds) *
633
- row.applied_delta_minutes *
634
- 60;
1290
+ const profile = resolveTaskActionProfile({
1291
+ id: row.entity_id,
1292
+ plannedDurationSeconds: row.planned_duration_seconds
1293
+ }, lifeForceProfile);
1294
+ const totalAp = rateToTotalAp(profile.sustainRateApPerHour, row.applied_delta_minutes * 60);
635
1295
  return {
636
1296
  entityType: "task",
637
1297
  entityId: row.entity_id,
@@ -651,7 +1311,7 @@ function buildWorkAdjustmentContributions(userId, range) {
651
1311
  };
652
1312
  });
653
1313
  }
654
- function buildTaskRunContributions(userId, range, now) {
1314
+ function buildTaskRunContributions(userId, range, now, lifeForceProfile) {
655
1315
  const contributions = [];
656
1316
  const totalsByTaskId = new Map();
657
1317
  const activeDrains = [];
@@ -663,8 +1323,8 @@ function buildTaskRunContributions(userId, range, now) {
663
1323
  const profile = resolveTaskActionProfile({
664
1324
  id: row.task_id,
665
1325
  plannedDurationSeconds: row.task_expected_duration_seconds ?? row.planned_duration_seconds
666
- });
667
- const totalAp = (seconds / 3600) * profile.sustainRateApPerHour;
1326
+ }, lifeForceProfile);
1327
+ const totalAp = rateToTotalAp(profile.sustainRateApPerHour, seconds);
668
1328
  const startsAt = new Date(Math.max(range.startMs, Date.parse(row.claimed_at))).toISOString();
669
1329
  const endsAt = new Date(Math.min(range.endMs, terminalRunMs(row, now))).toISOString();
670
1330
  const contribution = {
@@ -695,8 +1355,9 @@ function buildTaskRunContributions(userId, range, now) {
695
1355
  }
696
1356
  return { contributions, totalsByTaskId, activeDrains };
697
1357
  }
698
- function buildNoteContributions(userId, range, now) {
1358
+ function buildNoteContributions(userId, range, now, lifeForceProfile) {
699
1359
  try {
1360
+ const noteProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "note_quick"), lifeForceProfile);
700
1361
  const taskRunWindowsByTaskId = readTaskRunWindowsByTaskId(userId, range, now);
701
1362
  const rows = getDatabase()
702
1363
  .prepare(`SELECT
@@ -734,7 +1395,7 @@ function buildNoteContributions(userId, range, now) {
734
1395
  entityId: row.id,
735
1396
  eventKind: "note_created",
736
1397
  sourceKind: "note",
737
- totalAp: 1,
1398
+ totalAp: noteProfile.totalCostAp,
738
1399
  rateApPerHour: null,
739
1400
  title: row.title || "Note",
740
1401
  why: "Standalone capture takes a small impulse of activation and focus.",
@@ -747,8 +1408,9 @@ function buildNoteContributions(userId, range, now) {
747
1408
  return [];
748
1409
  }
749
1410
  }
750
- function buildHabitContributions(userId, range) {
1411
+ function buildHabitContributions(userId, range, lifeForceProfile) {
751
1412
  try {
1413
+ const habitProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "habit_default"), lifeForceProfile);
752
1414
  const rows = getDatabase()
753
1415
  .prepare(`SELECT
754
1416
  habits.id,
@@ -774,7 +1436,7 @@ function buildHabitContributions(userId, range) {
774
1436
  entityId: row.id,
775
1437
  eventKind: "habit_check_in",
776
1438
  sourceKind: "habit",
777
- totalAp: 3,
1439
+ totalAp: habitProfile.totalCostAp,
778
1440
  rateApPerHour: null,
779
1441
  title: row.title,
780
1442
  why: "Habit execution still costs activation even when the action is short.",
@@ -787,7 +1449,7 @@ function buildHabitContributions(userId, range) {
787
1449
  return [];
788
1450
  }
789
1451
  }
790
- function buildWorkoutContributions(userId, range, now) {
1452
+ function buildWorkoutContributions(userId, range, now, lifeForceProfile) {
791
1453
  let rows = [];
792
1454
  try {
793
1455
  rows = getDatabase()
@@ -803,6 +1465,7 @@ function buildWorkoutContributions(userId, range, now) {
803
1465
  }
804
1466
  const contributions = [];
805
1467
  const activeDrains = [];
1468
+ const workoutProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "workout_default"), lifeForceProfile);
806
1469
  for (const row of rows) {
807
1470
  const startMs = Math.max(range.startMs, Date.parse(row.started_at));
808
1471
  const endMs = Math.min(range.endMs, Date.parse(row.ended_at));
@@ -813,13 +1476,13 @@ function buildWorkoutContributions(userId, range, now) {
813
1476
  const effortMultiplier = row.subjective_effort
814
1477
  ? clamp(row.subjective_effort / 6, 0.8, 1.6)
815
1478
  : 1;
816
- const rateApPerHour = 24 * effortMultiplier;
1479
+ const rateApPerHour = Number((workoutProfile.sustainRateApPerHour * effortMultiplier).toFixed(4));
817
1480
  const contribution = {
818
1481
  entityType: "workout_session",
819
1482
  entityId: row.id,
820
1483
  eventKind: "workout_session",
821
1484
  sourceKind: "workout",
822
- totalAp: (seconds / 3600) * rateApPerHour,
1485
+ totalAp: rateToTotalAp(rateApPerHour, seconds),
823
1486
  rateApPerHour,
824
1487
  title: row.workout_type,
825
1488
  why: "Workout sessions consume real physical capacity and should affect current load.",
@@ -828,48 +1491,530 @@ function buildWorkoutContributions(userId, range, now) {
828
1491
  role: "secondary"
829
1492
  };
830
1493
  contributions.push(contribution);
831
- if (Date.parse(row.ended_at) > now.getTime()) {
1494
+ if (Date.parse(row.started_at) <= now.getTime() && Date.parse(row.ended_at) > now.getTime()) {
832
1495
  activeDrains.push({ ...contribution, totalAp: 0 });
833
1496
  }
834
1497
  }
835
1498
  return { contributions, activeDrains };
836
1499
  }
837
- function buildCalendarDrains(userId, now) {
838
- const nowIsoValue = now.toISOString();
1500
+ function buildWakeImpulseContributions(userId, range, lifeForceProfile) {
1501
+ const primarySleep = readPrimarySleepSessionForDate(userId, new Date(range.startMs));
1502
+ if (!primarySleep) {
1503
+ return [];
1504
+ }
1505
+ const endedAtMs = Date.parse(primarySleep.ended_at);
1506
+ if (endedAtMs < range.startMs || endedAtMs >= range.endMs) {
1507
+ return [];
1508
+ }
1509
+ const wakeProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "wake_start"), lifeForceProfile);
1510
+ return [
1511
+ {
1512
+ entityType: "sleep_session",
1513
+ entityId: primarySleep.id,
1514
+ eventKind: "wake_start",
1515
+ sourceKind: "wake",
1516
+ totalAp: wakeProfile.totalCostAp,
1517
+ rateApPerHour: null,
1518
+ title: "Get out of bed",
1519
+ why: "Starting the day takes real activation and should count as an Action Point impulse.",
1520
+ startsAt: primarySleep.ended_at,
1521
+ endsAt: primarySleep.ended_at,
1522
+ role: "background"
1523
+ }
1524
+ ];
1525
+ }
1526
+ function buildMovementTripProfile(trip) {
1527
+ const baseProfile = seededActionProfiles().find((entry) => entry.profileKey === "movement_trip_default");
1528
+ const expectedMet = trip.expected_met ?? 2;
1529
+ const baseRateApPerHour = clamp(expectedMet * 4, 4, 22);
1530
+ const lowerTitle = `${trip.activity_type} ${trip.travel_mode}`.toLowerCase();
1531
+ const vigor = lowerTitle.includes("walk") || lowerTitle.includes("run") || lowerTitle.includes("bike")
1532
+ ? 0.65
1533
+ : lowerTitle.includes("drive") || lowerTitle.includes("train")
1534
+ ? 0.2
1535
+ : 0.45;
1536
+ const focus = lowerTitle.includes("drive") ? 0.35 : 0.15;
1537
+ const flow = 1 - vigor - focus;
1538
+ return actionProfileSchema.parse({
1539
+ ...baseProfile,
1540
+ id: `${baseProfile.id}_${trip.travel_mode}_${trip.activity_type || "travel"}`,
1541
+ profileKey: `${baseProfile.profileKey}_${trip.travel_mode}_${trip.activity_type || "travel"}`,
1542
+ title: trip.activity_type?.trim() || trip.travel_mode || "Movement trip",
1543
+ sustainRateApPerHour: Number(baseRateApPerHour.toFixed(4)),
1544
+ totalCostAp: Number(baseRateApPerHour.toFixed(4)),
1545
+ demandWeights: {
1546
+ activation: 0.05,
1547
+ focus,
1548
+ vigor,
1549
+ composure: 0,
1550
+ flow: Math.max(0.05, Number(flow.toFixed(3)))
1551
+ }
1552
+ });
1553
+ }
1554
+ function buildMovementTripContributions(userId, range, now, lifeForceProfile) {
1555
+ let rows = [];
839
1556
  try {
840
- const rows = getDatabase()
841
- .prepare(`SELECT calendar_events.id, calendar_events.title, calendar_events.start_at, calendar_events.end_at
842
- FROM calendar_events
843
- INNER JOIN calendar_event_links
844
- ON calendar_event_links.forge_event_id = calendar_events.id
1557
+ rows = getDatabase()
1558
+ .prepare(`SELECT
1559
+ id,
1560
+ label,
1561
+ status,
1562
+ travel_mode,
1563
+ activity_type,
1564
+ started_at,
1565
+ ended_at,
1566
+ moving_seconds,
1567
+ idle_seconds,
1568
+ expected_met,
1569
+ distance_meters
1570
+ FROM movement_trips
1571
+ WHERE user_id = ?
1572
+ AND started_at < ?
1573
+ AND ended_at >= ?`)
1574
+ .all(userId, range.to, range.from);
1575
+ }
1576
+ catch {
1577
+ rows = [];
1578
+ }
1579
+ const contributions = [];
1580
+ const activeDrains = [];
1581
+ for (const row of rows) {
1582
+ const seconds = Math.max(0, Math.floor((Math.min(range.endMs, Date.parse(row.ended_at)) -
1583
+ Math.max(range.startMs, Date.parse(row.started_at))) /
1584
+ 1000));
1585
+ if (seconds <= 0) {
1586
+ continue;
1587
+ }
1588
+ const profile = buildEffectiveProfile(buildMovementTripProfile(row), lifeForceProfile);
1589
+ const contribution = {
1590
+ entityType: "movement_trip",
1591
+ entityId: row.id,
1592
+ eventKind: "movement_trip",
1593
+ sourceKind: "movement",
1594
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, seconds),
1595
+ rateApPerHour: profile.sustainRateApPerHour,
1596
+ title: row.label || row.activity_type || row.travel_mode || "Movement trip",
1597
+ why: "Movement and commuting consume current capacity through physical effort, attention, and switching overhead.",
1598
+ startsAt: new Date(Math.max(range.startMs, Date.parse(row.started_at))).toISOString(),
1599
+ endsAt: new Date(Math.min(range.endMs, Date.parse(row.ended_at))).toISOString(),
1600
+ role: "secondary"
1601
+ };
1602
+ contributions.push(contribution);
1603
+ if (Date.parse(row.started_at) <= now.getTime() && Date.parse(row.ended_at) > now.getTime()) {
1604
+ activeDrains.push({ ...contribution, totalAp: 0 });
1605
+ }
1606
+ }
1607
+ return { contributions, activeDrains };
1608
+ }
1609
+ function readTaskTimeboxLifeForceRows(userId, range) {
1610
+ try {
1611
+ return getDatabase()
1612
+ .prepare(`SELECT
1613
+ task_timeboxes.id,
1614
+ task_timeboxes.task_id,
1615
+ task_timeboxes.linked_task_run_id,
1616
+ task_timeboxes.status,
1617
+ task_timeboxes.source,
1618
+ task_timeboxes.title,
1619
+ task_timeboxes.starts_at,
1620
+ task_timeboxes.ends_at,
1621
+ tasks.planned_duration_seconds AS task_planned_duration_seconds
1622
+ FROM task_timeboxes
1623
+ INNER JOIN tasks ON tasks.id = task_timeboxes.task_id
845
1624
  INNER JOIN entity_owners
846
- ON entity_owners.entity_type = calendar_event_links.entity_type
847
- AND entity_owners.entity_id = calendar_event_links.entity_id
1625
+ ON entity_owners.entity_type = 'task_timebox'
1626
+ AND entity_owners.entity_id = task_timeboxes.id
848
1627
  AND entity_owners.role = 'owner'
849
1628
  WHERE entity_owners.user_id = ?
850
- AND calendar_events.deleted_at IS NULL
851
- AND calendar_events.start_at <= ?
852
- AND calendar_events.end_at > ?
853
- GROUP BY calendar_events.id, calendar_events.title, calendar_events.start_at, calendar_events.end_at`)
854
- .all(userId, nowIsoValue, nowIsoValue);
855
- return rows.map((row) => ({
856
- entityType: "calendar_event",
857
- entityId: row.id,
858
- eventKind: "calendar_context",
859
- sourceKind: "calendar",
860
- totalAp: 0,
861
- rateApPerHour: 12,
862
- title: row.title,
863
- why: "Calendar context occupies mental and social capacity even before task work is logged.",
864
- startsAt: row.start_at,
865
- endsAt: row.end_at,
866
- role: "background"
867
- }));
1629
+ AND task_timeboxes.ends_at > ?
1630
+ AND task_timeboxes.starts_at < ?
1631
+ ORDER BY task_timeboxes.starts_at ASC`)
1632
+ .all(userId, range.from, range.to);
1633
+ }
1634
+ catch {
1635
+ return [];
1636
+ }
1637
+ }
1638
+ function readWorkBlockTemplateLifeForceRows(userId) {
1639
+ try {
1640
+ return getDatabase()
1641
+ .prepare(`SELECT
1642
+ work_block_templates.id,
1643
+ work_block_templates.title,
1644
+ work_block_templates.kind,
1645
+ work_block_templates.color,
1646
+ work_block_templates.weekdays_json,
1647
+ work_block_templates.start_minute,
1648
+ work_block_templates.end_minute,
1649
+ work_block_templates.starts_on,
1650
+ work_block_templates.ends_on,
1651
+ work_block_templates.blocking_state,
1652
+ work_block_templates.created_at,
1653
+ work_block_templates.updated_at
1654
+ FROM work_block_templates
1655
+ INNER JOIN entity_owners
1656
+ ON entity_owners.entity_type = 'work_block_template'
1657
+ AND entity_owners.entity_id = work_block_templates.id
1658
+ AND entity_owners.role = 'owner'
1659
+ WHERE entity_owners.user_id = ?`)
1660
+ .all(userId);
1661
+ }
1662
+ catch {
1663
+ return [];
1664
+ }
1665
+ }
1666
+ function parseWeekdaysJson(raw) {
1667
+ try {
1668
+ const parsed = JSON.parse(raw);
1669
+ return Array.isArray(parsed)
1670
+ ? parsed.filter((entry) => Number.isInteger(entry))
1671
+ : [];
868
1672
  }
869
1673
  catch {
870
1674
  return [];
871
1675
  }
872
1676
  }
1677
+ function deriveTodayWorkBlocks(userId, range) {
1678
+ const dayDate = new Date(range.startMs);
1679
+ const dayKey = range.dateKey;
1680
+ return readWorkBlockTemplateLifeForceRows(userId)
1681
+ .filter((template) => {
1682
+ if (template.starts_on && dayKey < template.starts_on) {
1683
+ return false;
1684
+ }
1685
+ if (template.ends_on && dayKey > template.ends_on) {
1686
+ return false;
1687
+ }
1688
+ return parseWeekdaysJson(template.weekdays_json).includes(dayDate.getUTCDay());
1689
+ })
1690
+ .map((template) => {
1691
+ const startAt = new Date(range.startMs + template.start_minute * 60_000).toISOString();
1692
+ const endAt = new Date(range.startMs + template.end_minute * 60_000).toISOString();
1693
+ return {
1694
+ ...template,
1695
+ instance_id: `wbinst_${template.id}_${dayKey}`,
1696
+ start_at: startAt,
1697
+ end_at: endAt
1698
+ };
1699
+ });
1700
+ }
1701
+ function buildWorkBlockProfile(input) {
1702
+ const storedProfile = readEntityActionProfile("work_block_template", input.templateId, {
1703
+ profileKey: `work_block_template_${input.templateId}`,
1704
+ title: input.title,
1705
+ entityType: "work_block_template"
1706
+ });
1707
+ if (storedProfile) {
1708
+ return storedProfile;
1709
+ }
1710
+ const startMinute = new Date(input.startAt).getUTCHours() * 60 + new Date(input.startAt).getUTCMinutes();
1711
+ const endMinute = new Date(input.endAt).getUTCHours() * 60 + new Date(input.endAt).getUTCMinutes();
1712
+ return buildWorkBlockTemplateActionProfile({
1713
+ templateId: input.templateId,
1714
+ title: input.title,
1715
+ kind: input.kind,
1716
+ startMinute,
1717
+ endMinute
1718
+ });
1719
+ }
1720
+ function buildTimeboxAndWorkBlockDrains(userId, range, now, lifeForceProfile, activeTaskRunTaskIds, actualSourceWindows) {
1721
+ const actualContributions = [];
1722
+ const plannedDrains = [];
1723
+ const activeDrains = [];
1724
+ const timeboxWindows = [];
1725
+ const workBlockWindows = [];
1726
+ const timeboxes = readTaskTimeboxLifeForceRows(userId, range);
1727
+ for (const row of timeboxes) {
1728
+ if (row.linked_task_run_id || row.status === "cancelled") {
1729
+ continue;
1730
+ }
1731
+ timeboxWindows.push({
1732
+ startAt: row.starts_at,
1733
+ endAt: row.ends_at
1734
+ });
1735
+ const storedProfile = readEntityActionProfile("task_timebox", row.id, {
1736
+ profileKey: `task_timebox_${row.id}`,
1737
+ title: row.title,
1738
+ entityType: "task_timebox"
1739
+ });
1740
+ const profile = lifeForceProfile
1741
+ ? buildEffectiveProfile(storedProfile ??
1742
+ buildTaskTimeboxActionProfile({
1743
+ timeboxId: row.id,
1744
+ title: row.title,
1745
+ taskId: row.task_id,
1746
+ taskPlannedDurationSeconds: row.task_planned_duration_seconds,
1747
+ startsAt: row.starts_at,
1748
+ endsAt: row.ends_at
1749
+ }), lifeForceProfile)
1750
+ : storedProfile ??
1751
+ buildTaskTimeboxActionProfile({
1752
+ timeboxId: row.id,
1753
+ title: row.title,
1754
+ taskId: row.task_id,
1755
+ taskPlannedDurationSeconds: row.task_planned_duration_seconds,
1756
+ startsAt: row.starts_at,
1757
+ endsAt: row.ends_at
1758
+ });
1759
+ const elapsedWindow = {
1760
+ startAt: row.starts_at,
1761
+ endAt: new Date(Math.min(now.getTime(), Date.parse(row.ends_at))).toISOString()
1762
+ };
1763
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, actualSourceWindows);
1764
+ if (elapsedSeconds > 0) {
1765
+ actualContributions.push({
1766
+ entityType: "task_timebox",
1767
+ entityId: row.id,
1768
+ eventKind: "task_timebox_actual",
1769
+ sourceKind: "task_timebox",
1770
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, elapsedSeconds),
1771
+ rateApPerHour: profile.sustainRateApPerHour,
1772
+ title: row.title,
1773
+ why: "Elapsed timeboxes count toward today's Action Point spend when no richer timed source covered that window.",
1774
+ startsAt: row.starts_at,
1775
+ endsAt: elapsedWindow.endAt,
1776
+ role: "background"
1777
+ });
1778
+ }
1779
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(row.starts_at));
1780
+ const remainingEndMs = Math.min(range.endMs, Date.parse(row.ends_at));
1781
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1782
+ if (remainingSeconds > 0) {
1783
+ plannedDrains.push({
1784
+ entityType: "task_timebox",
1785
+ entityId: row.id,
1786
+ eventKind: "task_timebox_plan",
1787
+ sourceKind: "task_timebox",
1788
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, remainingSeconds),
1789
+ rateApPerHour: profile.sustainRateApPerHour,
1790
+ title: row.title,
1791
+ why: "Planned task timeboxes forecast how much Action Point throughput is still booked today.",
1792
+ startsAt: row.starts_at,
1793
+ endsAt: row.ends_at,
1794
+ role: "secondary"
1795
+ });
1796
+ }
1797
+ if (Date.parse(row.starts_at) <= now.getTime() &&
1798
+ Date.parse(row.ends_at) > now.getTime() &&
1799
+ !activeTaskRunTaskIds.has(row.task_id) &&
1800
+ !actualSourceWindows.some((window) => overlapsWindow(row.starts_at, row.ends_at, window.startAt, window.endAt))) {
1801
+ activeDrains.push({
1802
+ entityType: "task_timebox",
1803
+ entityId: row.id,
1804
+ eventKind: "task_timebox_context",
1805
+ sourceKind: "task_timebox",
1806
+ totalAp: 0,
1807
+ rateApPerHour: profile.sustainRateApPerHour,
1808
+ title: row.title,
1809
+ why: "An active timebox still occupies current capacity even before live work logging starts.",
1810
+ startsAt: row.starts_at,
1811
+ endsAt: row.ends_at,
1812
+ role: "background"
1813
+ });
1814
+ }
1815
+ }
1816
+ const workBlocks = deriveTodayWorkBlocks(userId, range);
1817
+ for (const block of workBlocks) {
1818
+ workBlockWindows.push({
1819
+ startAt: block.start_at,
1820
+ endAt: block.end_at
1821
+ });
1822
+ const overlapsTimebox = timeboxes.some((timebox) => timebox.status !== "cancelled" &&
1823
+ overlapsWindow(block.start_at, block.end_at, timebox.starts_at, timebox.ends_at));
1824
+ const profile = buildEffectiveProfile(buildWorkBlockProfile({
1825
+ templateId: block.id,
1826
+ title: block.title,
1827
+ kind: block.kind,
1828
+ startAt: block.start_at,
1829
+ endAt: block.end_at
1830
+ }), lifeForceProfile);
1831
+ const elapsedWindow = {
1832
+ startAt: block.start_at,
1833
+ endAt: new Date(Math.min(now.getTime(), Date.parse(block.end_at))).toISOString()
1834
+ };
1835
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, [
1836
+ ...actualSourceWindows,
1837
+ ...timeboxWindows
1838
+ ]);
1839
+ if (elapsedSeconds > 0 && !overlapsTimebox) {
1840
+ actualContributions.push({
1841
+ entityType: "work_block",
1842
+ entityId: block.instance_id,
1843
+ eventKind: "work_block_actual",
1844
+ sourceKind: "work_block",
1845
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, elapsedSeconds),
1846
+ rateApPerHour: profile.sustainRateApPerHour,
1847
+ title: block.title,
1848
+ why: "Elapsed work blocks count toward today's spend when no specific task run or timebox covered that work window.",
1849
+ startsAt: block.start_at,
1850
+ endsAt: elapsedWindow.endAt,
1851
+ role: "background",
1852
+ metadata: {
1853
+ templateId: block.id,
1854
+ kind: block.kind
1855
+ }
1856
+ });
1857
+ }
1858
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(block.start_at));
1859
+ const remainingEndMs = Math.min(range.endMs, Date.parse(block.end_at));
1860
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1861
+ if (remainingSeconds > 0 && !overlapsTimebox) {
1862
+ plannedDrains.push({
1863
+ entityType: "work_block",
1864
+ entityId: block.instance_id,
1865
+ eventKind: "work_block_plan",
1866
+ sourceKind: "work_block",
1867
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, remainingSeconds),
1868
+ rateApPerHour: profile.sustainRateApPerHour,
1869
+ title: block.title,
1870
+ why: "Work blocks act as planning containers and forecast background load when no richer task plan exists.",
1871
+ startsAt: block.start_at,
1872
+ endsAt: block.end_at,
1873
+ role: "background",
1874
+ metadata: {
1875
+ templateId: block.id,
1876
+ kind: block.kind
1877
+ }
1878
+ });
1879
+ }
1880
+ if (Date.parse(block.start_at) <= now.getTime() &&
1881
+ Date.parse(block.end_at) > now.getTime() &&
1882
+ !overlapsTimebox &&
1883
+ activeTaskRunTaskIds.size === 0 &&
1884
+ !actualSourceWindows.some((window) => overlapsWindow(block.start_at, block.end_at, window.startAt, window.endAt))) {
1885
+ activeDrains.push({
1886
+ entityType: "work_block",
1887
+ entityId: block.instance_id,
1888
+ eventKind: "work_block_context",
1889
+ sourceKind: "work_block",
1890
+ totalAp: 0,
1891
+ rateApPerHour: profile.sustainRateApPerHour,
1892
+ title: block.title,
1893
+ why: "The current work block still claims capacity as a container for focused effort.",
1894
+ startsAt: block.start_at,
1895
+ endsAt: block.end_at,
1896
+ role: "background",
1897
+ metadata: {
1898
+ templateId: block.id,
1899
+ kind: block.kind
1900
+ }
1901
+ });
1902
+ }
1903
+ }
1904
+ return {
1905
+ actualContributions,
1906
+ plannedDrains,
1907
+ activeDrains,
1908
+ timeboxWindows,
1909
+ workBlockWindows
1910
+ };
1911
+ }
1912
+ function buildCalendarDrains(userId, now, range, lifeForceProfile, blockingWindows) {
1913
+ const actualContributions = [];
1914
+ const nowIsoValue = now.toISOString();
1915
+ const activeDrains = [];
1916
+ const plannedDrains = [];
1917
+ try {
1918
+ const rows = getDatabase()
1919
+ .prepare(`SELECT
1920
+ forge_events.id,
1921
+ forge_events.title,
1922
+ forge_events.start_at,
1923
+ forge_events.end_at,
1924
+ forge_events.availability,
1925
+ forge_events.event_type,
1926
+ COUNT(forge_event_links.id) AS link_count
1927
+ FROM forge_events
1928
+ LEFT JOIN forge_event_links
1929
+ ON forge_event_links.forge_event_id = forge_events.id
1930
+ WHERE forge_events.deleted_at IS NULL
1931
+ AND forge_events.end_at > ?
1932
+ AND forge_events.start_at < ?
1933
+ GROUP BY
1934
+ forge_events.id,
1935
+ forge_events.title,
1936
+ forge_events.start_at,
1937
+ forge_events.end_at,
1938
+ forge_events.availability,
1939
+ forge_events.event_type`)
1940
+ .all(range.from, range.to);
1941
+ for (const row of rows) {
1942
+ const calendarProfile = buildEffectiveProfile(readEntityActionProfile("calendar_event", row.id, {
1943
+ profileKey: `calendar_event_${row.id}`,
1944
+ title: row.title,
1945
+ entityType: "calendar_event"
1946
+ }) ??
1947
+ buildCalendarEventActionProfile({
1948
+ eventId: row.id,
1949
+ title: row.title,
1950
+ eventType: row.event_type,
1951
+ availability: row.availability,
1952
+ startAt: row.start_at,
1953
+ endAt: row.end_at
1954
+ }), lifeForceProfile);
1955
+ const overlapsBlockingWindow = blockingWindows.some((window) => overlapsWindow(row.start_at, row.end_at, window.startAt, window.endAt));
1956
+ const elapsedWindow = {
1957
+ startAt: row.start_at,
1958
+ endAt: new Date(Math.min(now.getTime(), Date.parse(row.end_at))).toISOString()
1959
+ };
1960
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, blockingWindows);
1961
+ if (elapsedSeconds > 0 && row.link_count === 0) {
1962
+ actualContributions.push({
1963
+ entityType: "calendar_event",
1964
+ entityId: row.id,
1965
+ eventKind: "calendar_event_actual",
1966
+ sourceKind: "calendar",
1967
+ totalAp: rateToTotalAp(calendarProfile.sustainRateApPerHour, elapsedSeconds),
1968
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
1969
+ title: row.title,
1970
+ why: "Busy calendar events debit today's AP when they were real containers and nothing richer occupied the same window.",
1971
+ startsAt: row.start_at,
1972
+ endsAt: elapsedWindow.endAt,
1973
+ role: "background"
1974
+ });
1975
+ }
1976
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(row.start_at));
1977
+ const remainingEndMs = Math.min(range.endMs, Date.parse(row.end_at));
1978
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1979
+ if (remainingSeconds > 0 && row.link_count === 0) {
1980
+ plannedDrains.push({
1981
+ entityType: "calendar_event",
1982
+ entityId: row.id,
1983
+ eventKind: "calendar_event_plan",
1984
+ sourceKind: "calendar",
1985
+ totalAp: rateToTotalAp(calendarProfile.sustainRateApPerHour, remainingSeconds),
1986
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
1987
+ title: row.title,
1988
+ why: "Busy calendar events reserve attention and social bandwidth even before deeper work is linked to them.",
1989
+ startsAt: row.start_at,
1990
+ endsAt: row.end_at,
1991
+ role: "background"
1992
+ });
1993
+ }
1994
+ if (row.start_at <= nowIsoValue &&
1995
+ row.end_at > nowIsoValue &&
1996
+ !overlapsBlockingWindow) {
1997
+ activeDrains.push({
1998
+ entityType: "calendar_event",
1999
+ entityId: row.id,
2000
+ eventKind: "calendar_context",
2001
+ sourceKind: "calendar",
2002
+ totalAp: 0,
2003
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
2004
+ title: row.title,
2005
+ why: "Calendar context occupies mental and social capacity even before task work is logged.",
2006
+ startsAt: row.start_at,
2007
+ endsAt: row.end_at,
2008
+ role: "background"
2009
+ });
2010
+ }
2011
+ }
2012
+ }
2013
+ catch {
2014
+ return { actualContributions, activeDrains, plannedDrains };
2015
+ }
2016
+ return { actualContributions, activeDrains, plannedDrains };
2017
+ }
873
2018
  function syncApLedger(userId, range, contributions) {
874
2019
  runInTransaction(() => {
875
2020
  const database = getDatabase();
@@ -1010,17 +2155,41 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1010
2155
  const profile = ensureLifeForceProfile(user.id);
1011
2156
  const snapshot = getOrCreateDaySnapshot(user.id, now);
1012
2157
  const range = buildDayRange(now);
1013
- const taskRuns = buildTaskRunContributions(user.id, range, now);
1014
- const notes = buildNoteContributions(user.id, range, now);
1015
- const habits = buildHabitContributions(user.id, range);
1016
- const workouts = buildWorkoutContributions(user.id, range, now);
1017
- const adjustments = buildWorkAdjustmentContributions(user.id, range);
2158
+ const taskRuns = buildTaskRunContributions(user.id, range, now, profile);
2159
+ const notes = buildNoteContributions(user.id, range, now, profile);
2160
+ const habits = buildHabitContributions(user.id, range, profile);
2161
+ const workouts = buildWorkoutContributions(user.id, range, now, profile);
2162
+ const movement = buildMovementTripContributions(user.id, range, now, profile);
2163
+ const wakeImpulses = buildWakeImpulseContributions(user.id, range, profile);
2164
+ const adjustments = buildWorkAdjustmentContributions(user.id, range, profile);
2165
+ const actualSourceWindows = [
2166
+ ...taskRuns.contributions,
2167
+ ...workouts.contributions,
2168
+ ...movement.contributions
2169
+ ]
2170
+ .filter((entry) => Boolean(entry.startsAt && entry.endsAt))
2171
+ .map((entry) => ({
2172
+ startAt: entry.startsAt,
2173
+ endAt: entry.endsAt
2174
+ }));
2175
+ const activeTaskRunTaskIds = new Set(taskRuns.activeDrains.map((entry) => entry.entityId));
2176
+ const plannedContainers = buildTimeboxAndWorkBlockDrains(user.id, range, now, profile, activeTaskRunTaskIds, actualSourceWindows);
2177
+ const calendarBlockingWindows = [
2178
+ ...actualSourceWindows,
2179
+ ...plannedContainers.timeboxWindows,
2180
+ ...plannedContainers.workBlockWindows
2181
+ ];
2182
+ const calendarDrains = buildCalendarDrains(user.id, now, range, profile, calendarBlockingWindows);
1018
2183
  const contributions = [
1019
2184
  ...taskRuns.contributions,
1020
2185
  ...adjustments,
1021
2186
  ...notes,
1022
2187
  ...habits,
1023
- ...workouts.contributions
2188
+ ...workouts.contributions,
2189
+ ...movement.contributions,
2190
+ ...wakeImpulses,
2191
+ ...plannedContainers.actualContributions,
2192
+ ...calendarDrains.actualContributions
1024
2193
  ];
1025
2194
  const seededProfilesByKey = new Map(seededActionProfiles().map((entry) => [entry.profileKey, entry]));
1026
2195
  const taskDurationRows = getDatabase()
@@ -1034,22 +2203,60 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1034
2203
  profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, resolveTaskActionProfile({
1035
2204
  id: contribution.entityId,
1036
2205
  plannedDurationSeconds: taskDurationById.get(contribution.entityId) ?? null
1037
- }));
2206
+ }, profile));
1038
2207
  continue;
1039
2208
  }
1040
2209
  if (contribution.entityType === "note") {
1041
- profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, seededProfilesByKey.get("note_quick") ?? null);
2210
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("note_quick") ?? seededActionProfiles()[0], profile));
1042
2211
  continue;
1043
2212
  }
1044
2213
  if (contribution.entityType === "habit") {
1045
- profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, seededProfilesByKey.get("habit_default") ?? null);
2214
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("habit_default") ?? seededActionProfiles()[0], profile));
1046
2215
  continue;
1047
2216
  }
1048
2217
  if (contribution.entityType === "workout_session") {
1049
- profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, seededProfilesByKey.get("workout_default") ?? null);
2218
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("workout_default") ?? seededActionProfiles()[0], profile));
2219
+ continue;
2220
+ }
2221
+ if (contribution.entityType === "movement_trip") {
2222
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("movement_trip_default") ?? seededActionProfiles()[0], profile));
2223
+ continue;
2224
+ }
2225
+ if (contribution.entityType === "task_timebox") {
2226
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("task_timebox", contribution.entityId, {
2227
+ profileKey: `task_timebox_${contribution.entityId}`,
2228
+ title: contribution.title,
2229
+ entityType: "task_timebox"
2230
+ }) ??
2231
+ (seededProfilesByKey.get("task_timebox_default") ?? seededActionProfiles()[0]), profile));
2232
+ continue;
2233
+ }
2234
+ if (contribution.entityType === "work_block") {
2235
+ const templateId = typeof contribution.metadata?.templateId === "string"
2236
+ ? contribution.metadata.templateId
2237
+ : contribution.entityId;
2238
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("work_block_template", templateId, {
2239
+ profileKey: `work_block_template_${templateId}`,
2240
+ title: contribution.title,
2241
+ entityType: "work_block_template"
2242
+ }) ??
2243
+ (seededProfilesByKey.get("work_block_main") ?? seededActionProfiles()[0]), profile));
2244
+ continue;
2245
+ }
2246
+ if (contribution.entityType === "calendar_event") {
2247
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("calendar_event", contribution.entityId, {
2248
+ profileKey: `calendar_event_${contribution.entityId}`,
2249
+ title: contribution.title,
2250
+ entityType: "calendar_event"
2251
+ }) ??
2252
+ (seededProfilesByKey.get("calendar_event_default") ?? seededActionProfiles()[0]), profile));
2253
+ continue;
2254
+ }
2255
+ if (contribution.eventKind === "wake_start") {
2256
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("wake_start") ?? seededActionProfiles()[0], profile));
1050
2257
  }
1051
2258
  }
1052
- const adjustmentApByTaskId = readTodayAdjustmentApByTaskId(user.id, range);
2259
+ const adjustmentApByTaskId = readTodayAdjustmentApByTaskId(user.id, range, profile);
1053
2260
  for (const [taskId, adjustmentAp] of adjustmentApByTaskId.entries()) {
1054
2261
  const existing = taskRuns.totalsByTaskId.get(taskId) ?? { todayAp: 0, totalAp: 0 };
1055
2262
  existing.todayAp += adjustmentAp;
@@ -1066,12 +2273,21 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1066
2273
  const currentCurve = parseCurvePoints(snapshot.points_json);
1067
2274
  const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();
1068
2275
  const instantCapacityApPerHour = interpolateCurveRate(currentCurve, minuteOfDay);
2276
+ const activeSourceWindows = [
2277
+ ...taskRuns.activeDrains,
2278
+ ...workouts.activeDrains,
2279
+ ...movement.activeDrains,
2280
+ ...plannedContainers.activeDrains
2281
+ ].map((entry) => ({
2282
+ startAt: entry.startsAt ?? now.toISOString(),
2283
+ endAt: entry.endsAt ?? now.toISOString()
2284
+ }));
1069
2285
  const activeDrains = [
1070
2286
  ...taskRuns.activeDrains,
1071
2287
  ...workouts.activeDrains,
1072
- ...(taskRuns.activeDrains.length === 0 && workouts.activeDrains.length === 0
1073
- ? buildCalendarDrains(user.id, now)
1074
- : [])
2288
+ ...movement.activeDrains,
2289
+ ...plannedContainers.activeDrains,
2290
+ ...calendarDrains.activeDrains
1075
2291
  ]
1076
2292
  .sort((left, right) => (right.rateApPerHour ?? 0) - (left.rateApPerHour ?? 0))
1077
2293
  .map((entry, index) => ({
@@ -1086,6 +2302,23 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1086
2302
  startedAt: entry.startsAt,
1087
2303
  endsAt: entry.endsAt
1088
2304
  }));
2305
+ const plannedDrains = [
2306
+ ...plannedContainers.plannedDrains,
2307
+ ...calendarDrains.plannedDrains
2308
+ ]
2309
+ .sort((left, right) => Date.parse(left.startsAt ?? now.toISOString()) - Date.parse(right.startsAt ?? now.toISOString()))
2310
+ .map((entry) => ({
2311
+ id: `${entry.sourceKind}:${entry.entityId}`,
2312
+ sourceType: entry.entityType,
2313
+ sourceId: entry.entityId,
2314
+ title: entry.title,
2315
+ role: entry.role,
2316
+ apPerHour: Number((entry.rateApPerHour ?? 0).toFixed(2)),
2317
+ instantAp: Number((entry.totalAp ?? 0).toFixed(2)),
2318
+ why: entry.why,
2319
+ startedAt: entry.startsAt,
2320
+ endsAt: entry.endsAt
2321
+ }));
1089
2322
  const sortedRates = activeDrains
1090
2323
  .map((entry) => entry.apPerHour)
1091
2324
  .sort((left, right) => right - left);
@@ -1099,7 +2332,8 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1099
2332
  const instantFreeApPerHour = Math.max(0, rawInstantFreeApPerHour);
1100
2333
  const overloadApPerHour = Math.max(0, Number((-rawInstantFreeApPerHour).toFixed(2)));
1101
2334
  const remainingAp = Number((snapshot.daily_budget_ap - spentTodayAp).toFixed(2));
1102
- const forecastAp = Number((spentTodayAp + currentDrainApPerHour * 2).toFixed(2));
2335
+ const plannedRemainingAp = Number(plannedDrains.reduce((sum, entry) => sum + entry.instantAp, 0).toFixed(2));
2336
+ const forecastAp = Number((spentTodayAp + plannedRemainingAp).toFixed(2));
1103
2337
  const targetBandMinAp = Number((snapshot.daily_budget_ap * 0.85).toFixed(2));
1104
2338
  const targetBandMaxAp = Number(snapshot.daily_budget_ap.toFixed(2));
1105
2339
  const workTime = computeWorkTime(now);
@@ -1132,6 +2366,7 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1132
2366
  spentTodayAp: Number(spentTodayAp.toFixed(2)),
1133
2367
  remainingAp,
1134
2368
  forecastAp,
2369
+ plannedRemainingAp,
1135
2370
  targetBandMinAp,
1136
2371
  targetBandMaxAp,
1137
2372
  instantCapacityApPerHour: Number(instantCapacityApPerHour.toFixed(2)),
@@ -1148,6 +2383,7 @@ export function buildLifeForcePayload(now = new Date(), userIds) {
1148
2383
  locked: point.minuteOfDay <= minuteOfDay
1149
2384
  })),
1150
2385
  activeDrains,
2386
+ plannedDrains,
1151
2387
  warnings: buildWarnings({
1152
2388
  spentTodayAp,
1153
2389
  dailyBudgetAp: snapshot.daily_budget_ap,