forge-openclaw-plugin 0.2.26 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -3
- package/dist/assets/{board-ta0rUHOf.js → board-DPFvZf-D.js} +2 -2
- package/dist/assets/{board-ta0rUHOf.js.map → board-DPFvZf-D.js.map} +1 -1
- package/dist/assets/index-Auw3JrdE.css +1 -0
- package/dist/assets/index-D1H7myQH.js +85 -0
- package/dist/assets/index-D1H7myQH.js.map +1 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
- package/dist/assets/{motion-fBKPB6yw.js → motion-Bvwc85ch.js} +2 -2
- package/dist/assets/{motion-fBKPB6yw.js.map → motion-Bvwc85ch.js.map} +1 -1
- package/dist/assets/{table-C-IGTQni.js → table-FJQTJvUR.js} +2 -2
- package/dist/assets/{table-C-IGTQni.js.map → table-FJQTJvUR.js.map} +1 -1
- package/dist/assets/{ui-DInOpaYF.js → ui-GXFcgvSw.js} +2 -2
- package/dist/assets/{ui-DInOpaYF.js.map → ui-GXFcgvSw.js.map} +1 -1
- package/dist/assets/vendor-Cwf49UMz.js +1247 -0
- package/dist/assets/vendor-Cwf49UMz.js.map +1 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/local-runtime.js +16 -0
- package/dist/openclaw/routes.d.ts +27 -0
- package/dist/openclaw/routes.js +16 -12
- package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
- package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
- package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
- package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
- package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/dist/server/server/src/app.js +1900 -91
- package/dist/server/server/src/connectors/box-registry.js +44 -9
- package/dist/server/server/src/data-management-types.js +107 -0
- package/dist/server/server/src/db.js +68 -4
- package/dist/server/server/src/demo-data.js +2 -2
- package/dist/server/server/src/health.js +702 -18
- package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
- package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
- package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
- package/dist/server/server/src/managers/runtime.js +9 -0
- package/dist/server/server/src/movement.js +1971 -112
- package/dist/server/server/src/openapi.js +1390 -105
- package/dist/server/server/src/psyche-types.js +9 -1
- package/dist/server/server/src/repositories/activity-events.js +8 -0
- package/dist/server/server/src/repositories/ai-connectors.js +522 -74
- package/dist/server/server/src/repositories/calendar.js +151 -0
- package/dist/server/server/src/repositories/habits.js +37 -1
- package/dist/server/server/src/repositories/model-settings.js +13 -3
- package/dist/server/server/src/repositories/notes.js +3 -0
- package/dist/server/server/src/repositories/settings.js +380 -18
- package/dist/server/server/src/repositories/tasks.js +170 -10
- package/dist/server/server/src/runtime-data-root.js +82 -0
- package/dist/server/server/src/screen-time.js +802 -0
- package/dist/server/server/src/services/data-management.js +788 -0
- package/dist/server/server/src/services/entity-crud.js +205 -2
- package/dist/server/server/src/services/knowledge-graph.js +1455 -0
- package/dist/server/server/src/services/life-force-model.js +217 -0
- package/dist/server/server/src/services/life-force.js +2506 -0
- package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
- package/dist/server/server/src/types.js +307 -14
- package/dist/server/server/src/web.js +228 -13
- package/dist/server/src/components/customization/utility-widgets.js +136 -27
- package/dist/server/src/components/ui/info-tooltip.js +25 -0
- package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
- package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
- package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
- package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
- package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
- package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
- package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
- package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
- package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
- package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
- package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
- package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
- package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
- package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
- package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
- package/dist/server/src/lib/api.js +280 -21
- package/dist/server/src/lib/data-management-types.js +1 -0
- package/dist/server/src/lib/entity-visuals.js +279 -0
- package/dist/server/src/lib/knowledge-graph-types.js +276 -0
- package/dist/server/src/lib/knowledge-graph.js +470 -0
- package/dist/server/src/lib/schemas.js +4 -0
- package/dist/server/src/lib/snapshot-normalizer.js +45 -1
- package/dist/server/src/lib/workbench/contracts.js +229 -0
- package/dist/server/src/lib/workbench/nodes.js +200 -0
- package/dist/server/src/lib/workbench/registry.js +52 -5
- package/dist/server/src/lib/workbench/runtime.js +254 -38
- package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/server/migrations/038_data_management_settings.sql +11 -0
- package/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/server/migrations/040_screen_time_domain.sql +89 -0
- package/server/migrations/041_companion_source_states.sql +21 -0
- package/server/migrations/042_movement_boxes.sql +47 -0
- package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/skills/forge-openclaw/SKILL.md +41 -11
- package/skills/forge-openclaw/entity_conversation_playbooks.md +448 -34
- package/skills/forge-openclaw/psyche_entity_playbooks.md +170 -17
- package/dist/assets/index-Ro0ZF_az.css +0 -1
- package/dist/assets/index-ytlpSj23.js +0 -79
- package/dist/assets/index-ytlpSj23.js.map +0 -1
- package/dist/assets/vendor-lE3tZJcC.js +0 -876
- package/dist/assets/vendor-lE3tZJcC.js.map +0 -1
|
@@ -0,0 +1,2506 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { getDefaultUser, getUserById } from "../repositories/users.js";
|
|
4
|
+
import { actionProfileSchema, fatigueSignalCreateSchema, lifeForcePayloadSchema, lifeForceProfilePatchSchema, lifeForceTemplateUpdateSchema } from "../types.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
|
+
import { computeWorkTime } from "./work-time.js";
|
|
7
|
+
const DEFAULT_TEMPLATE_POINTS = [
|
|
8
|
+
{ minuteOfDay: 0, rateApPerHour: 0 },
|
|
9
|
+
{ minuteOfDay: 7 * 60, rateApPerHour: 0 },
|
|
10
|
+
{ minuteOfDay: 8 * 60, rateApPerHour: 8 },
|
|
11
|
+
{ minuteOfDay: 10 * 60, rateApPerHour: 13 },
|
|
12
|
+
{ minuteOfDay: 13 * 60, rateApPerHour: 9 },
|
|
13
|
+
{ minuteOfDay: 14 * 60, rateApPerHour: 11 },
|
|
14
|
+
{ minuteOfDay: 19 * 60, rateApPerHour: 7 },
|
|
15
|
+
{ minuteOfDay: 23 * 60, rateApPerHour: 0 },
|
|
16
|
+
{ minuteOfDay: 24 * 60, rateApPerHour: 0 }
|
|
17
|
+
];
|
|
18
|
+
const LIFE_FORCE_STAT_LABELS = {
|
|
19
|
+
life_force: "Life Force",
|
|
20
|
+
activation: "Activation",
|
|
21
|
+
focus: "Focus",
|
|
22
|
+
vigor: "Vigor",
|
|
23
|
+
composure: "Composure",
|
|
24
|
+
flow: "Flow"
|
|
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
|
+
}
|
|
237
|
+
function nowIso() {
|
|
238
|
+
return new Date().toISOString();
|
|
239
|
+
}
|
|
240
|
+
function toDateKey(date) {
|
|
241
|
+
return date.toISOString().slice(0, 10);
|
|
242
|
+
}
|
|
243
|
+
function startOfUtcDay(date) {
|
|
244
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
245
|
+
}
|
|
246
|
+
function buildDayRange(date) {
|
|
247
|
+
const start = startOfUtcDay(date);
|
|
248
|
+
const end = new Date(start);
|
|
249
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
250
|
+
return {
|
|
251
|
+
startMs: start.getTime(),
|
|
252
|
+
endMs: end.getTime(),
|
|
253
|
+
dateKey: toDateKey(start),
|
|
254
|
+
from: start.toISOString(),
|
|
255
|
+
to: end.toISOString()
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function parseCurvePoints(raw) {
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(raw);
|
|
261
|
+
if (!Array.isArray(parsed)) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
return parsed
|
|
265
|
+
.filter((value) => !!value &&
|
|
266
|
+
typeof value === "object" &&
|
|
267
|
+
typeof value.minuteOfDay === "number" &&
|
|
268
|
+
typeof value.rateApPerHour === "number")
|
|
269
|
+
.map((point) => ({
|
|
270
|
+
minuteOfDay: Math.round(point.minuteOfDay),
|
|
271
|
+
rateApPerHour: point.rateApPerHour,
|
|
272
|
+
locked: point.locked
|
|
273
|
+
}))
|
|
274
|
+
.sort((left, right) => left.minuteOfDay - right.minuteOfDay);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function defaultTemplatePoints() {
|
|
281
|
+
return normalizeCurveToBudget(DEFAULT_TEMPLATE_POINTS, LIFE_FORCE_BASELINE_DAILY_AP);
|
|
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
|
+
}
|
|
402
|
+
function seededActionProfiles() {
|
|
403
|
+
const now = nowIso();
|
|
404
|
+
const taskDefault = buildDefaultTaskActionProfile({});
|
|
405
|
+
return [
|
|
406
|
+
taskDefault,
|
|
407
|
+
actionProfileSchema.parse({
|
|
408
|
+
id: "profile_note_quick",
|
|
409
|
+
profileKey: "note_quick",
|
|
410
|
+
title: "Quick note",
|
|
411
|
+
entityType: "note",
|
|
412
|
+
mode: "impulse",
|
|
413
|
+
startupAp: 1,
|
|
414
|
+
totalCostAp: 1,
|
|
415
|
+
expectedDurationSeconds: null,
|
|
416
|
+
sustainRateApPerHour: 0,
|
|
417
|
+
demandWeights: {
|
|
418
|
+
activation: 0.15,
|
|
419
|
+
focus: 0.5,
|
|
420
|
+
vigor: 0,
|
|
421
|
+
composure: 0,
|
|
422
|
+
flow: 0.35
|
|
423
|
+
},
|
|
424
|
+
doubleCountPolicy: "primary_only",
|
|
425
|
+
sourceMethod: "seeded",
|
|
426
|
+
costBand: "tiny",
|
|
427
|
+
recoveryEffect: 0,
|
|
428
|
+
metadata: {},
|
|
429
|
+
createdAt: now,
|
|
430
|
+
updatedAt: now
|
|
431
|
+
}),
|
|
432
|
+
actionProfileSchema.parse({
|
|
433
|
+
id: "profile_habit_default",
|
|
434
|
+
profileKey: "habit_default",
|
|
435
|
+
title: "Habit check-in",
|
|
436
|
+
entityType: "habit",
|
|
437
|
+
mode: "impulse",
|
|
438
|
+
startupAp: 3,
|
|
439
|
+
totalCostAp: 3,
|
|
440
|
+
expectedDurationSeconds: null,
|
|
441
|
+
sustainRateApPerHour: 0,
|
|
442
|
+
demandWeights: {
|
|
443
|
+
activation: 0.4,
|
|
444
|
+
focus: 0.1,
|
|
445
|
+
vigor: 0.15,
|
|
446
|
+
composure: 0.05,
|
|
447
|
+
flow: 0.3
|
|
448
|
+
},
|
|
449
|
+
doubleCountPolicy: "primary_only",
|
|
450
|
+
sourceMethod: "seeded",
|
|
451
|
+
costBand: "light",
|
|
452
|
+
recoveryEffect: 0,
|
|
453
|
+
metadata: {},
|
|
454
|
+
createdAt: now,
|
|
455
|
+
updatedAt: now
|
|
456
|
+
}),
|
|
457
|
+
actionProfileSchema.parse({
|
|
458
|
+
id: "profile_workout_default",
|
|
459
|
+
profileKey: "workout_default",
|
|
460
|
+
title: "Workout",
|
|
461
|
+
entityType: "workout_session",
|
|
462
|
+
mode: "rate",
|
|
463
|
+
startupAp: 1,
|
|
464
|
+
totalCostAp: 24,
|
|
465
|
+
expectedDurationSeconds: 3_600,
|
|
466
|
+
sustainRateApPerHour: 24,
|
|
467
|
+
demandWeights: {
|
|
468
|
+
activation: 0.1,
|
|
469
|
+
focus: 0.05,
|
|
470
|
+
vigor: 0.75,
|
|
471
|
+
composure: 0,
|
|
472
|
+
flow: 0.1
|
|
473
|
+
},
|
|
474
|
+
doubleCountPolicy: "primary_only",
|
|
475
|
+
sourceMethod: "seeded",
|
|
476
|
+
costBand: "standard",
|
|
477
|
+
recoveryEffect: 0,
|
|
478
|
+
metadata: {},
|
|
479
|
+
createdAt: now,
|
|
480
|
+
updatedAt: now
|
|
481
|
+
}),
|
|
482
|
+
actionProfileSchema.parse({
|
|
483
|
+
id: "profile_calendar_default",
|
|
484
|
+
profileKey: "calendar_event_default",
|
|
485
|
+
title: "Calendar event",
|
|
486
|
+
entityType: "calendar_event",
|
|
487
|
+
mode: "container",
|
|
488
|
+
startupAp: 0,
|
|
489
|
+
totalCostAp: 12,
|
|
490
|
+
expectedDurationSeconds: 3_600,
|
|
491
|
+
sustainRateApPerHour: 12,
|
|
492
|
+
demandWeights: {
|
|
493
|
+
activation: 0.05,
|
|
494
|
+
focus: 0.35,
|
|
495
|
+
vigor: 0.05,
|
|
496
|
+
composure: 0.35,
|
|
497
|
+
flow: 0.2
|
|
498
|
+
},
|
|
499
|
+
doubleCountPolicy: "container_only",
|
|
500
|
+
sourceMethod: "seeded",
|
|
501
|
+
costBand: "light",
|
|
502
|
+
recoveryEffect: 0,
|
|
503
|
+
metadata: {},
|
|
504
|
+
createdAt: now,
|
|
505
|
+
updatedAt: now
|
|
506
|
+
}),
|
|
507
|
+
actionProfileSchema.parse({
|
|
508
|
+
id: "profile_recovery_break",
|
|
509
|
+
profileKey: "recovery_break",
|
|
510
|
+
title: "Recovery break",
|
|
511
|
+
entityType: "system",
|
|
512
|
+
mode: "recovery",
|
|
513
|
+
startupAp: 0,
|
|
514
|
+
totalCostAp: 0,
|
|
515
|
+
expectedDurationSeconds: null,
|
|
516
|
+
sustainRateApPerHour: 0,
|
|
517
|
+
demandWeights: {
|
|
518
|
+
activation: 0,
|
|
519
|
+
focus: 0,
|
|
520
|
+
vigor: 0,
|
|
521
|
+
composure: 0,
|
|
522
|
+
flow: 0
|
|
523
|
+
},
|
|
524
|
+
doubleCountPolicy: "primary_only",
|
|
525
|
+
sourceMethod: "seeded",
|
|
526
|
+
costBand: "tiny",
|
|
527
|
+
recoveryEffect: 6,
|
|
528
|
+
metadata: {},
|
|
529
|
+
createdAt: now,
|
|
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
|
|
731
|
+
})
|
|
732
|
+
];
|
|
733
|
+
}
|
|
734
|
+
function mapTemplateProfileRow(row) {
|
|
735
|
+
return actionProfileSchema.parse({
|
|
736
|
+
id: row.id,
|
|
737
|
+
profileKey: row.profile_key,
|
|
738
|
+
entityType: row.entity_type,
|
|
739
|
+
title: row.title,
|
|
740
|
+
createdAt: row.created_at,
|
|
741
|
+
updatedAt: row.updated_at,
|
|
742
|
+
...JSON.parse(row.profile_json)
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function mapEntityProfileRow(row, fallback) {
|
|
746
|
+
return actionProfileSchema.parse({
|
|
747
|
+
id: row.id,
|
|
748
|
+
profileKey: fallback.profileKey,
|
|
749
|
+
title: fallback.title,
|
|
750
|
+
entityType: fallback.entityType,
|
|
751
|
+
createdAt: row.created_at,
|
|
752
|
+
updatedAt: row.updated_at,
|
|
753
|
+
...JSON.parse(row.profile_json)
|
|
754
|
+
});
|
|
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
|
+
}
|
|
767
|
+
function ensureActionProfileTemplates() {
|
|
768
|
+
const database = getDatabase();
|
|
769
|
+
const insert = database.prepare(`INSERT OR IGNORE INTO action_profile_templates (
|
|
770
|
+
id,
|
|
771
|
+
profile_key,
|
|
772
|
+
entity_type,
|
|
773
|
+
title,
|
|
774
|
+
profile_json,
|
|
775
|
+
created_at,
|
|
776
|
+
updated_at
|
|
777
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
778
|
+
for (const profile of seededActionProfiles()) {
|
|
779
|
+
insert.run(profile.id, profile.profileKey, profile.entityType, profile.title, JSON.stringify({
|
|
780
|
+
mode: profile.mode,
|
|
781
|
+
startupAp: profile.startupAp,
|
|
782
|
+
totalCostAp: profile.totalCostAp,
|
|
783
|
+
expectedDurationSeconds: profile.expectedDurationSeconds,
|
|
784
|
+
sustainRateApPerHour: profile.sustainRateApPerHour,
|
|
785
|
+
demandWeights: profile.demandWeights,
|
|
786
|
+
doubleCountPolicy: profile.doubleCountPolicy,
|
|
787
|
+
sourceMethod: profile.sourceMethod,
|
|
788
|
+
costBand: profile.costBand,
|
|
789
|
+
recoveryEffect: profile.recoveryEffect,
|
|
790
|
+
metadata: profile.metadata
|
|
791
|
+
}), profile.createdAt, profile.updatedAt);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
export function upsertTaskActionProfile(input) {
|
|
795
|
+
const now = nowIso();
|
|
796
|
+
const profile = buildDefaultTaskActionProfile({
|
|
797
|
+
id: `profile_task_${input.taskId}`,
|
|
798
|
+
profileKey: `task_${input.taskId}`,
|
|
799
|
+
title: input.title || "Task",
|
|
800
|
+
expectedDurationSeconds: input.plannedDurationSeconds,
|
|
801
|
+
totalCostAp: input.totalCostAp ?? resolveBandTotalCostAp(input.actionCostBand ?? "standard"),
|
|
802
|
+
costBand: input.actionCostBand ?? "standard",
|
|
803
|
+
sourceMethod: "manual"
|
|
804
|
+
});
|
|
805
|
+
getDatabase()
|
|
806
|
+
.prepare(`INSERT INTO entity_action_profiles (
|
|
807
|
+
id,
|
|
808
|
+
entity_type,
|
|
809
|
+
entity_id,
|
|
810
|
+
profile_json,
|
|
811
|
+
created_at,
|
|
812
|
+
updated_at
|
|
813
|
+
) VALUES (?, 'task', ?, ?, ?, ?)
|
|
814
|
+
ON CONFLICT(entity_type, entity_id) DO UPDATE SET
|
|
815
|
+
profile_json = excluded.profile_json,
|
|
816
|
+
updated_at = excluded.updated_at`)
|
|
817
|
+
.run(profile.id, input.taskId, JSON.stringify({
|
|
818
|
+
profileKey: profile.profileKey,
|
|
819
|
+
title: profile.title,
|
|
820
|
+
entityType: profile.entityType,
|
|
821
|
+
mode: profile.mode,
|
|
822
|
+
startupAp: profile.startupAp,
|
|
823
|
+
totalCostAp: profile.totalCostAp,
|
|
824
|
+
expectedDurationSeconds: profile.expectedDurationSeconds,
|
|
825
|
+
sustainRateApPerHour: profile.sustainRateApPerHour,
|
|
826
|
+
demandWeights: profile.demandWeights,
|
|
827
|
+
doubleCountPolicy: profile.doubleCountPolicy,
|
|
828
|
+
sourceMethod: profile.sourceMethod,
|
|
829
|
+
costBand: profile.costBand,
|
|
830
|
+
recoveryEffect: profile.recoveryEffect,
|
|
831
|
+
metadata: profile.metadata
|
|
832
|
+
}), now, now);
|
|
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
|
+
}
|
|
908
|
+
function ensureLifeForceProfile(userId) {
|
|
909
|
+
const database = getDatabase();
|
|
910
|
+
const existing = database
|
|
911
|
+
.prepare(`SELECT *
|
|
912
|
+
FROM life_force_profiles
|
|
913
|
+
WHERE user_id = ?`)
|
|
914
|
+
.get(userId);
|
|
915
|
+
if (existing) {
|
|
916
|
+
return existing;
|
|
917
|
+
}
|
|
918
|
+
const now = nowIso();
|
|
919
|
+
database
|
|
920
|
+
.prepare(`INSERT INTO life_force_profiles (
|
|
921
|
+
user_id,
|
|
922
|
+
base_daily_ap,
|
|
923
|
+
readiness_multiplier,
|
|
924
|
+
life_force_level,
|
|
925
|
+
activation_level,
|
|
926
|
+
focus_level,
|
|
927
|
+
vigor_level,
|
|
928
|
+
composure_level,
|
|
929
|
+
flow_level,
|
|
930
|
+
created_at,
|
|
931
|
+
updated_at
|
|
932
|
+
) VALUES (?, 200, 1.0, 1, 1, 1, 1, 1, 1, ?, ?)`)
|
|
933
|
+
.run(userId, now, now);
|
|
934
|
+
return database
|
|
935
|
+
.prepare(`SELECT *
|
|
936
|
+
FROM life_force_profiles
|
|
937
|
+
WHERE user_id = ?`)
|
|
938
|
+
.get(userId);
|
|
939
|
+
}
|
|
940
|
+
function ensureWeekdayTemplates(userId) {
|
|
941
|
+
const database = getDatabase();
|
|
942
|
+
const insert = database.prepare(`INSERT OR IGNORE INTO life_force_weekday_templates (
|
|
943
|
+
id,
|
|
944
|
+
user_id,
|
|
945
|
+
weekday,
|
|
946
|
+
baseline_daily_ap,
|
|
947
|
+
points_json,
|
|
948
|
+
created_at,
|
|
949
|
+
updated_at
|
|
950
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
951
|
+
const now = nowIso();
|
|
952
|
+
const defaultPoints = JSON.stringify(defaultTemplatePoints());
|
|
953
|
+
for (let weekday = 0; weekday < 7; weekday += 1) {
|
|
954
|
+
insert.run(`lf_template_${userId}_${weekday}`, userId, weekday, LIFE_FORCE_BASELINE_DAILY_AP, defaultPoints, now, now);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function readWeekdayTemplate(userId, weekday) {
|
|
958
|
+
ensureWeekdayTemplates(userId);
|
|
959
|
+
return getDatabase()
|
|
960
|
+
.prepare(`SELECT *
|
|
961
|
+
FROM life_force_weekday_templates
|
|
962
|
+
WHERE user_id = ? AND weekday = ?`)
|
|
963
|
+
.get(userId, weekday);
|
|
964
|
+
}
|
|
965
|
+
function readTaskRunRows(range, userId) {
|
|
966
|
+
return getDatabase()
|
|
967
|
+
.prepare(`SELECT
|
|
968
|
+
task_runs.id,
|
|
969
|
+
task_runs.task_id,
|
|
970
|
+
task_runs.actor,
|
|
971
|
+
task_runs.status,
|
|
972
|
+
task_runs.is_current,
|
|
973
|
+
task_runs.claimed_at,
|
|
974
|
+
task_runs.heartbeat_at,
|
|
975
|
+
task_runs.lease_expires_at,
|
|
976
|
+
task_runs.completed_at,
|
|
977
|
+
task_runs.released_at,
|
|
978
|
+
task_runs.timed_out_at,
|
|
979
|
+
task_runs.updated_at,
|
|
980
|
+
tasks.title AS task_title,
|
|
981
|
+
task_runs.planned_duration_seconds,
|
|
982
|
+
tasks.planned_duration_seconds AS task_expected_duration_seconds
|
|
983
|
+
FROM task_runs
|
|
984
|
+
INNER JOIN tasks ON tasks.id = task_runs.task_id
|
|
985
|
+
INNER JOIN entity_owners
|
|
986
|
+
ON entity_owners.entity_type = 'task'
|
|
987
|
+
AND entity_owners.entity_id = tasks.id
|
|
988
|
+
AND entity_owners.role = 'owner'
|
|
989
|
+
WHERE entity_owners.user_id = ?
|
|
990
|
+
AND task_runs.claimed_at < ?
|
|
991
|
+
AND COALESCE(task_runs.completed_at, task_runs.released_at, task_runs.timed_out_at, task_runs.updated_at, task_runs.lease_expires_at, task_runs.heartbeat_at) >= ?`)
|
|
992
|
+
.all(userId, range.to, range.from);
|
|
993
|
+
}
|
|
994
|
+
function terminalRunMs(row, now) {
|
|
995
|
+
if (row.status === "active") {
|
|
996
|
+
return Math.max(Date.parse(row.claimed_at), Math.min(now.getTime(), Date.parse(row.lease_expires_at)));
|
|
997
|
+
}
|
|
998
|
+
const terminal = row.completed_at ??
|
|
999
|
+
row.released_at ??
|
|
1000
|
+
row.timed_out_at ??
|
|
1001
|
+
row.updated_at ??
|
|
1002
|
+
row.lease_expires_at ??
|
|
1003
|
+
row.heartbeat_at;
|
|
1004
|
+
return Math.max(Date.parse(row.claimed_at), Date.parse(terminal));
|
|
1005
|
+
}
|
|
1006
|
+
function overlapSeconds(range, row, now) {
|
|
1007
|
+
const start = Math.max(range.startMs, Date.parse(row.claimed_at));
|
|
1008
|
+
const end = Math.min(range.endMs, terminalRunMs(row, now));
|
|
1009
|
+
return Math.max(0, Math.floor((end - start) / 1000));
|
|
1010
|
+
}
|
|
1011
|
+
function readStatXpByKey(userId) {
|
|
1012
|
+
const rows = getDatabase()
|
|
1013
|
+
.prepare(`SELECT stat_key, COALESCE(SUM(delta_xp), 0) AS xp
|
|
1014
|
+
FROM stat_xp_events
|
|
1015
|
+
WHERE user_id = ?
|
|
1016
|
+
GROUP BY stat_key`)
|
|
1017
|
+
.all(userId);
|
|
1018
|
+
return new Map(rows.map((row) => [row.stat_key, row.xp]));
|
|
1019
|
+
}
|
|
1020
|
+
function buildStats(profile, userId) {
|
|
1021
|
+
const xpByKey = readStatXpByKey(userId);
|
|
1022
|
+
const levelByKey = buildStatLevels(profile);
|
|
1023
|
+
return Object.keys(levelByKey).map((key) => {
|
|
1024
|
+
const level = levelByKey[key];
|
|
1025
|
+
return {
|
|
1026
|
+
key,
|
|
1027
|
+
label: LIFE_FORCE_STAT_LABELS[key],
|
|
1028
|
+
level,
|
|
1029
|
+
xp: xpByKey.get(key) ?? 0,
|
|
1030
|
+
xpToNextLevel: level * 100,
|
|
1031
|
+
costModifier: key === "life_force"
|
|
1032
|
+
? Number(computeLifeForceLevelMultiplier(level).toFixed(3))
|
|
1033
|
+
: Number(computeStatCostModifier(level).toFixed(3))
|
|
1034
|
+
};
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
function computeLifeForceMultiplier(profile) {
|
|
1038
|
+
return computeLifeForceLevelMultiplier(profile.life_force_level);
|
|
1039
|
+
}
|
|
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));
|
|
1069
|
+
}
|
|
1070
|
+
function computeFatigueDebtCarry(userId, date) {
|
|
1071
|
+
const previous = new Date(date);
|
|
1072
|
+
previous.setUTCDate(previous.getUTCDate() - 1);
|
|
1073
|
+
const previousKey = toDateKey(previous);
|
|
1074
|
+
const snapshot = getDatabase()
|
|
1075
|
+
.prepare(`SELECT daily_budget_ap
|
|
1076
|
+
FROM life_force_day_snapshots
|
|
1077
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
1078
|
+
.get(userId, previousKey);
|
|
1079
|
+
if (!snapshot) {
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
const spent = getDatabase()
|
|
1083
|
+
.prepare(`SELECT COALESCE(SUM(total_ap), 0) AS total_ap
|
|
1084
|
+
FROM ap_ledger_events
|
|
1085
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
1086
|
+
.get(userId, previousKey);
|
|
1087
|
+
return Math.max(0, Number((spent.total_ap - snapshot.daily_budget_ap).toFixed(2)));
|
|
1088
|
+
}
|
|
1089
|
+
function getOrCreateDaySnapshot(userId, date) {
|
|
1090
|
+
const range = buildDayRange(date);
|
|
1091
|
+
const existing = getDatabase()
|
|
1092
|
+
.prepare(`SELECT *
|
|
1093
|
+
FROM life_force_day_snapshots
|
|
1094
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
1095
|
+
.get(userId, range.dateKey);
|
|
1096
|
+
if (existing) {
|
|
1097
|
+
return existing;
|
|
1098
|
+
}
|
|
1099
|
+
const profile = ensureLifeForceProfile(userId);
|
|
1100
|
+
const template = readWeekdayTemplate(userId, date.getUTCDay());
|
|
1101
|
+
const sleepRecoveryMultiplier = computeSleepRecoveryMultiplier(userId, date);
|
|
1102
|
+
const fatigueDebtCarry = computeFatigueDebtCarry(userId, date);
|
|
1103
|
+
const readinessMultiplier = profile.readiness_multiplier;
|
|
1104
|
+
const dailyBudgetAp = Math.max(40, Math.round(profile.base_daily_ap *
|
|
1105
|
+
computeLifeForceMultiplier(profile) *
|
|
1106
|
+
sleepRecoveryMultiplier *
|
|
1107
|
+
readinessMultiplier) - fatigueDebtCarry);
|
|
1108
|
+
const points = normalizeCurveToBudget(parseCurvePoints(template.points_json), dailyBudgetAp);
|
|
1109
|
+
const now = nowIso();
|
|
1110
|
+
const id = `lf_day_${userId}_${range.dateKey}`;
|
|
1111
|
+
getDatabase()
|
|
1112
|
+
.prepare(`INSERT INTO life_force_day_snapshots (
|
|
1113
|
+
id,
|
|
1114
|
+
user_id,
|
|
1115
|
+
date_key,
|
|
1116
|
+
daily_budget_ap,
|
|
1117
|
+
sleep_recovery_multiplier,
|
|
1118
|
+
readiness_multiplier,
|
|
1119
|
+
fatigue_debt_carry,
|
|
1120
|
+
points_json,
|
|
1121
|
+
created_at,
|
|
1122
|
+
updated_at
|
|
1123
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1124
|
+
.run(id, userId, range.dateKey, dailyBudgetAp, sleepRecoveryMultiplier, readinessMultiplier, fatigueDebtCarry, JSON.stringify(points), now, now);
|
|
1125
|
+
return getDatabase()
|
|
1126
|
+
.prepare(`SELECT *
|
|
1127
|
+
FROM life_force_day_snapshots
|
|
1128
|
+
WHERE id = ?`)
|
|
1129
|
+
.get(id);
|
|
1130
|
+
}
|
|
1131
|
+
export function resolveTaskActionProfile(task, lifeForceProfile) {
|
|
1132
|
+
const row = readEntityActionProfileRow("task", task.id);
|
|
1133
|
+
const baseProfile = !row
|
|
1134
|
+
? buildDefaultTaskActionProfile({
|
|
1135
|
+
id: `profile_task_${task.id}`,
|
|
1136
|
+
expectedDurationSeconds: task.plannedDurationSeconds
|
|
1137
|
+
})
|
|
1138
|
+
: mapEntityProfileRow(row, {
|
|
1139
|
+
profileKey: `task_${task.id}`,
|
|
1140
|
+
title: "Task",
|
|
1141
|
+
entityType: "task"
|
|
1142
|
+
});
|
|
1143
|
+
const profile = {
|
|
1144
|
+
...baseProfile,
|
|
1145
|
+
expectedDurationSeconds: resolveTaskExpectedDurationSeconds(baseProfile.expectedDurationSeconds ?? task.plannedDurationSeconds)
|
|
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
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
function readTodayAdjustmentRows(userId, range) {
|
|
1177
|
+
return getDatabase()
|
|
1178
|
+
.prepare(`SELECT
|
|
1179
|
+
work_adjustments.id,
|
|
1180
|
+
work_adjustments.entity_type,
|
|
1181
|
+
work_adjustments.entity_id,
|
|
1182
|
+
work_adjustments.applied_delta_minutes,
|
|
1183
|
+
work_adjustments.note,
|
|
1184
|
+
work_adjustments.created_at,
|
|
1185
|
+
tasks.planned_duration_seconds
|
|
1186
|
+
FROM work_adjustments
|
|
1187
|
+
LEFT JOIN tasks
|
|
1188
|
+
ON work_adjustments.entity_type = 'task'
|
|
1189
|
+
AND tasks.id = work_adjustments.entity_id
|
|
1190
|
+
INNER JOIN entity_owners
|
|
1191
|
+
ON entity_owners.entity_type = work_adjustments.entity_type
|
|
1192
|
+
AND entity_owners.entity_id = work_adjustments.entity_id
|
|
1193
|
+
AND entity_owners.role = 'owner'
|
|
1194
|
+
WHERE entity_owners.user_id = ?
|
|
1195
|
+
AND work_adjustments.created_at >= ?
|
|
1196
|
+
AND work_adjustments.created_at < ?`)
|
|
1197
|
+
.all(userId, range.from, range.to);
|
|
1198
|
+
}
|
|
1199
|
+
function readTodayAdjustmentApByTaskId(userId, range, lifeForceProfile) {
|
|
1200
|
+
const rows = readTodayAdjustmentRows(userId, range);
|
|
1201
|
+
const totals = new Map();
|
|
1202
|
+
for (const row of rows) {
|
|
1203
|
+
if (row.entity_type !== "task") {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
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);
|
|
1211
|
+
totals.set(row.entity_id, (totals.get(row.entity_id) ?? 0) + deltaAp);
|
|
1212
|
+
}
|
|
1213
|
+
return totals;
|
|
1214
|
+
}
|
|
1215
|
+
function readTodayAdjustmentSecondsByTaskId(userId, range) {
|
|
1216
|
+
const rows = readTodayAdjustmentRows(userId, range);
|
|
1217
|
+
const totals = new Map();
|
|
1218
|
+
for (const row of rows) {
|
|
1219
|
+
if (row.entity_type !== "task") {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
totals.set(row.entity_id, (totals.get(row.entity_id) ?? 0) + row.applied_delta_minutes * 60);
|
|
1223
|
+
}
|
|
1224
|
+
return totals;
|
|
1225
|
+
}
|
|
1226
|
+
function readActiveTaskRunProjectionRows(taskId) {
|
|
1227
|
+
return getDatabase()
|
|
1228
|
+
.prepare(`SELECT
|
|
1229
|
+
id,
|
|
1230
|
+
task_id,
|
|
1231
|
+
timer_mode,
|
|
1232
|
+
planned_duration_seconds,
|
|
1233
|
+
claimed_at,
|
|
1234
|
+
lease_expires_at,
|
|
1235
|
+
status
|
|
1236
|
+
FROM task_runs
|
|
1237
|
+
WHERE task_id = ?
|
|
1238
|
+
AND status = 'active'`)
|
|
1239
|
+
.all(taskId);
|
|
1240
|
+
}
|
|
1241
|
+
function computeProjectedRemainingSeconds(row, now) {
|
|
1242
|
+
if (row.timer_mode !== "planned" || row.planned_duration_seconds === null) {
|
|
1243
|
+
return 0;
|
|
1244
|
+
}
|
|
1245
|
+
const endMs = Math.min(now.getTime(), Date.parse(row.lease_expires_at));
|
|
1246
|
+
const elapsedWallSeconds = Math.max(0, Math.floor((endMs - Date.parse(row.claimed_at)) / 1000));
|
|
1247
|
+
return Math.max(0, row.planned_duration_seconds - elapsedWallSeconds);
|
|
1248
|
+
}
|
|
1249
|
+
function buildTaskLifeForceRuntime(task, userId, now = new Date(), lifeForceProfile = ensureLifeForceProfile(userId)) {
|
|
1250
|
+
const range = buildDayRange(now);
|
|
1251
|
+
const profile = resolveTaskActionProfile(task, lifeForceProfile);
|
|
1252
|
+
const todayRunSeconds = readTaskRunRows(range, userId)
|
|
1253
|
+
.filter((row) => row.task_id === task.id)
|
|
1254
|
+
.reduce((sum, row) => sum + overlapSeconds(range, row, now), 0);
|
|
1255
|
+
const todayAdjustmentSeconds = readTodayAdjustmentSecondsByTaskId(userId, range).get(task.id) ?? 0;
|
|
1256
|
+
const todayCreditedSeconds = todayRunSeconds + todayAdjustmentSeconds;
|
|
1257
|
+
const spentTodayAp = (todayCreditedSeconds / 3600) * profile.sustainRateApPerHour;
|
|
1258
|
+
const spentTotalAp = (task.time.totalCreditedSeconds / 3600) * profile.sustainRateApPerHour;
|
|
1259
|
+
const projectedTotalSeconds = task.time.totalCreditedSeconds +
|
|
1260
|
+
readActiveTaskRunProjectionRows(task.id).reduce((sum, row) => sum + computeProjectedRemainingSeconds(row, now), 0);
|
|
1261
|
+
return {
|
|
1262
|
+
taskId: task.id,
|
|
1263
|
+
profile,
|
|
1264
|
+
todayRunSeconds,
|
|
1265
|
+
todayAdjustmentSeconds,
|
|
1266
|
+
todayCreditedSeconds,
|
|
1267
|
+
spentTodayAp,
|
|
1268
|
+
spentTotalAp,
|
|
1269
|
+
projectedTotalSeconds
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function readTaskRunWindowsByTaskId(userId, range, now) {
|
|
1273
|
+
const windows = new Map();
|
|
1274
|
+
for (const row of readTaskRunRows(range, userId)) {
|
|
1275
|
+
const startMs = Math.max(range.startMs, Date.parse(row.claimed_at));
|
|
1276
|
+
const endMs = Math.min(range.endMs, terminalRunMs(row, now));
|
|
1277
|
+
if (endMs <= startMs) {
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
const list = windows.get(row.task_id) ?? [];
|
|
1281
|
+
list.push({ startMs, endMs });
|
|
1282
|
+
windows.set(row.task_id, list);
|
|
1283
|
+
}
|
|
1284
|
+
return windows;
|
|
1285
|
+
}
|
|
1286
|
+
function buildWorkAdjustmentContributions(userId, range, lifeForceProfile) {
|
|
1287
|
+
return readTodayAdjustmentRows(userId, range)
|
|
1288
|
+
.filter((row) => row.entity_type === "task")
|
|
1289
|
+
.map((row) => {
|
|
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);
|
|
1295
|
+
return {
|
|
1296
|
+
entityType: "task",
|
|
1297
|
+
entityId: row.entity_id,
|
|
1298
|
+
eventKind: "work_adjustment",
|
|
1299
|
+
sourceKind: "work_adjustment",
|
|
1300
|
+
totalAp,
|
|
1301
|
+
rateApPerHour: null,
|
|
1302
|
+
title: row.note?.trim() || "Manual work adjustment",
|
|
1303
|
+
why: "Manual time adjustments count toward today's Action Point spend.",
|
|
1304
|
+
startsAt: row.created_at,
|
|
1305
|
+
endsAt: row.created_at,
|
|
1306
|
+
role: "background",
|
|
1307
|
+
metadata: {
|
|
1308
|
+
adjustmentId: row.id,
|
|
1309
|
+
appliedDeltaMinutes: row.applied_delta_minutes
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
function buildTaskRunContributions(userId, range, now, lifeForceProfile) {
|
|
1315
|
+
const contributions = [];
|
|
1316
|
+
const totalsByTaskId = new Map();
|
|
1317
|
+
const activeDrains = [];
|
|
1318
|
+
for (const row of readTaskRunRows(range, userId)) {
|
|
1319
|
+
const seconds = overlapSeconds(range, row, now);
|
|
1320
|
+
if (seconds <= 0) {
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
const profile = resolveTaskActionProfile({
|
|
1324
|
+
id: row.task_id,
|
|
1325
|
+
plannedDurationSeconds: row.task_expected_duration_seconds ?? row.planned_duration_seconds
|
|
1326
|
+
}, lifeForceProfile);
|
|
1327
|
+
const totalAp = rateToTotalAp(profile.sustainRateApPerHour, seconds);
|
|
1328
|
+
const startsAt = new Date(Math.max(range.startMs, Date.parse(row.claimed_at))).toISOString();
|
|
1329
|
+
const endsAt = new Date(Math.min(range.endMs, terminalRunMs(row, now))).toISOString();
|
|
1330
|
+
const contribution = {
|
|
1331
|
+
entityType: "task",
|
|
1332
|
+
entityId: row.task_id,
|
|
1333
|
+
eventKind: "task_run",
|
|
1334
|
+
sourceKind: "task_run",
|
|
1335
|
+
totalAp,
|
|
1336
|
+
rateApPerHour: profile.sustainRateApPerHour,
|
|
1337
|
+
title: row.task_title,
|
|
1338
|
+
why: "Active timed work consumes Action Points proportionally to actual time worked today.",
|
|
1339
|
+
startsAt,
|
|
1340
|
+
endsAt,
|
|
1341
|
+
role: row.is_current === 1 ? "primary" : "secondary",
|
|
1342
|
+
metadata: { taskRunId: row.id }
|
|
1343
|
+
};
|
|
1344
|
+
contributions.push(contribution);
|
|
1345
|
+
const existing = totalsByTaskId.get(row.task_id) ?? { todayAp: 0, totalAp: 0 };
|
|
1346
|
+
existing.todayAp += totalAp;
|
|
1347
|
+
existing.totalAp += totalAp;
|
|
1348
|
+
totalsByTaskId.set(row.task_id, existing);
|
|
1349
|
+
if (row.status === "active" && Date.parse(row.lease_expires_at) > now.getTime()) {
|
|
1350
|
+
activeDrains.push({
|
|
1351
|
+
...contribution,
|
|
1352
|
+
totalAp: 0
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return { contributions, totalsByTaskId, activeDrains };
|
|
1357
|
+
}
|
|
1358
|
+
function buildNoteContributions(userId, range, now, lifeForceProfile) {
|
|
1359
|
+
try {
|
|
1360
|
+
const noteProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "note_quick"), lifeForceProfile);
|
|
1361
|
+
const taskRunWindowsByTaskId = readTaskRunWindowsByTaskId(userId, range, now);
|
|
1362
|
+
const rows = getDatabase()
|
|
1363
|
+
.prepare(`SELECT
|
|
1364
|
+
notes.id,
|
|
1365
|
+
notes.title,
|
|
1366
|
+
notes.created_at,
|
|
1367
|
+
GROUP_CONCAT(
|
|
1368
|
+
CASE
|
|
1369
|
+
WHEN note_links.entity_type = 'task' THEN note_links.entity_id
|
|
1370
|
+
ELSE NULL
|
|
1371
|
+
END
|
|
1372
|
+
) AS linked_task_ids
|
|
1373
|
+
FROM notes
|
|
1374
|
+
LEFT JOIN note_links ON note_links.note_id = notes.id
|
|
1375
|
+
INNER JOIN entity_owners
|
|
1376
|
+
ON entity_owners.entity_type = 'note'
|
|
1377
|
+
AND entity_owners.entity_id = notes.id
|
|
1378
|
+
AND entity_owners.role = 'owner'
|
|
1379
|
+
WHERE entity_owners.user_id = ?
|
|
1380
|
+
AND notes.created_at >= ?
|
|
1381
|
+
AND notes.created_at < ?
|
|
1382
|
+
GROUP BY notes.id, notes.title, notes.created_at`)
|
|
1383
|
+
.all(userId, range.from, range.to);
|
|
1384
|
+
return rows
|
|
1385
|
+
.filter((row) => {
|
|
1386
|
+
const createdAtMs = Date.parse(row.created_at);
|
|
1387
|
+
const linkedTaskIds = (row.linked_task_ids ?? "")
|
|
1388
|
+
.split(",")
|
|
1389
|
+
.map((entry) => entry.trim())
|
|
1390
|
+
.filter(Boolean);
|
|
1391
|
+
return !linkedTaskIds.some((taskId) => (taskRunWindowsByTaskId.get(taskId) ?? []).some((window) => createdAtMs >= window.startMs && createdAtMs <= window.endMs));
|
|
1392
|
+
})
|
|
1393
|
+
.map((row) => ({
|
|
1394
|
+
entityType: "note",
|
|
1395
|
+
entityId: row.id,
|
|
1396
|
+
eventKind: "note_created",
|
|
1397
|
+
sourceKind: "note",
|
|
1398
|
+
totalAp: noteProfile.totalCostAp,
|
|
1399
|
+
rateApPerHour: null,
|
|
1400
|
+
title: row.title || "Note",
|
|
1401
|
+
why: "Standalone capture takes a small impulse of activation and focus.",
|
|
1402
|
+
startsAt: row.created_at,
|
|
1403
|
+
endsAt: row.created_at,
|
|
1404
|
+
role: "background"
|
|
1405
|
+
}));
|
|
1406
|
+
}
|
|
1407
|
+
catch {
|
|
1408
|
+
return [];
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function buildHabitContributions(userId, range, lifeForceProfile) {
|
|
1412
|
+
try {
|
|
1413
|
+
const habitProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "habit_default"), lifeForceProfile);
|
|
1414
|
+
const rows = getDatabase()
|
|
1415
|
+
.prepare(`SELECT
|
|
1416
|
+
habits.id,
|
|
1417
|
+
habits.title,
|
|
1418
|
+
habit_check_ins.created_at,
|
|
1419
|
+
health_workout_sessions.id AS generated_workout_id
|
|
1420
|
+
FROM habit_check_ins
|
|
1421
|
+
INNER JOIN habits ON habits.id = habit_check_ins.habit_id
|
|
1422
|
+
LEFT JOIN health_workout_sessions
|
|
1423
|
+
ON health_workout_sessions.generated_from_check_in_id = habit_check_ins.id
|
|
1424
|
+
INNER JOIN entity_owners
|
|
1425
|
+
ON entity_owners.entity_type = 'habit'
|
|
1426
|
+
AND entity_owners.entity_id = habits.id
|
|
1427
|
+
AND entity_owners.role = 'owner'
|
|
1428
|
+
WHERE entity_owners.user_id = ?
|
|
1429
|
+
AND habit_check_ins.created_at >= ?
|
|
1430
|
+
AND habit_check_ins.created_at < ?`)
|
|
1431
|
+
.all(userId, range.from, range.to);
|
|
1432
|
+
return rows
|
|
1433
|
+
.filter((row) => row.generated_workout_id === null)
|
|
1434
|
+
.map((row) => ({
|
|
1435
|
+
entityType: "habit",
|
|
1436
|
+
entityId: row.id,
|
|
1437
|
+
eventKind: "habit_check_in",
|
|
1438
|
+
sourceKind: "habit",
|
|
1439
|
+
totalAp: habitProfile.totalCostAp,
|
|
1440
|
+
rateApPerHour: null,
|
|
1441
|
+
title: row.title,
|
|
1442
|
+
why: "Habit execution still costs activation even when the action is short.",
|
|
1443
|
+
startsAt: row.created_at,
|
|
1444
|
+
endsAt: row.created_at,
|
|
1445
|
+
role: "background"
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return [];
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
function buildWorkoutContributions(userId, range, now, lifeForceProfile) {
|
|
1453
|
+
let rows = [];
|
|
1454
|
+
try {
|
|
1455
|
+
rows = getDatabase()
|
|
1456
|
+
.prepare(`SELECT id, workout_type, started_at, ended_at, duration_seconds, subjective_effort
|
|
1457
|
+
FROM health_workout_sessions
|
|
1458
|
+
WHERE user_id = ?
|
|
1459
|
+
AND started_at < ?
|
|
1460
|
+
AND ended_at >= ?`)
|
|
1461
|
+
.all(userId, range.to, range.from);
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
rows = [];
|
|
1465
|
+
}
|
|
1466
|
+
const contributions = [];
|
|
1467
|
+
const activeDrains = [];
|
|
1468
|
+
const workoutProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "workout_default"), lifeForceProfile);
|
|
1469
|
+
for (const row of rows) {
|
|
1470
|
+
const startMs = Math.max(range.startMs, Date.parse(row.started_at));
|
|
1471
|
+
const endMs = Math.min(range.endMs, Date.parse(row.ended_at));
|
|
1472
|
+
const seconds = Math.max(0, Math.floor((endMs - startMs) / 1000));
|
|
1473
|
+
if (seconds <= 0) {
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
const effortMultiplier = row.subjective_effort
|
|
1477
|
+
? clamp(row.subjective_effort / 6, 0.8, 1.6)
|
|
1478
|
+
: 1;
|
|
1479
|
+
const rateApPerHour = Number((workoutProfile.sustainRateApPerHour * effortMultiplier).toFixed(4));
|
|
1480
|
+
const contribution = {
|
|
1481
|
+
entityType: "workout_session",
|
|
1482
|
+
entityId: row.id,
|
|
1483
|
+
eventKind: "workout_session",
|
|
1484
|
+
sourceKind: "workout",
|
|
1485
|
+
totalAp: rateToTotalAp(rateApPerHour, seconds),
|
|
1486
|
+
rateApPerHour,
|
|
1487
|
+
title: row.workout_type,
|
|
1488
|
+
why: "Workout sessions consume real physical capacity and should affect current load.",
|
|
1489
|
+
startsAt: new Date(startMs).toISOString(),
|
|
1490
|
+
endsAt: new Date(endMs).toISOString(),
|
|
1491
|
+
role: "secondary"
|
|
1492
|
+
};
|
|
1493
|
+
contributions.push(contribution);
|
|
1494
|
+
if (Date.parse(row.started_at) <= now.getTime() && Date.parse(row.ended_at) > now.getTime()) {
|
|
1495
|
+
activeDrains.push({ ...contribution, totalAp: 0 });
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return { contributions, activeDrains };
|
|
1499
|
+
}
|
|
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 = [];
|
|
1556
|
+
try {
|
|
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
|
|
1624
|
+
INNER JOIN entity_owners
|
|
1625
|
+
ON entity_owners.entity_type = 'task_timebox'
|
|
1626
|
+
AND entity_owners.entity_id = task_timeboxes.id
|
|
1627
|
+
AND entity_owners.role = 'owner'
|
|
1628
|
+
WHERE entity_owners.user_id = ?
|
|
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
|
+
: [];
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
return [];
|
|
1675
|
+
}
|
|
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
|
+
}
|
|
2018
|
+
function syncApLedger(userId, range, contributions) {
|
|
2019
|
+
runInTransaction(() => {
|
|
2020
|
+
const database = getDatabase();
|
|
2021
|
+
database
|
|
2022
|
+
.prepare(`DELETE FROM ap_ledger_events
|
|
2023
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
2024
|
+
.run(userId, range.dateKey);
|
|
2025
|
+
const insert = database.prepare(`INSERT INTO ap_ledger_events (
|
|
2026
|
+
id,
|
|
2027
|
+
user_id,
|
|
2028
|
+
date_key,
|
|
2029
|
+
entity_type,
|
|
2030
|
+
entity_id,
|
|
2031
|
+
event_kind,
|
|
2032
|
+
source_kind,
|
|
2033
|
+
starts_at,
|
|
2034
|
+
ends_at,
|
|
2035
|
+
total_ap,
|
|
2036
|
+
rate_ap_per_hour,
|
|
2037
|
+
metadata_json,
|
|
2038
|
+
created_at
|
|
2039
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
2040
|
+
const createdAt = nowIso();
|
|
2041
|
+
for (const contribution of contributions) {
|
|
2042
|
+
insert.run(`ap_${randomUUID().replaceAll("-", "").slice(0, 12)}`, userId, range.dateKey, contribution.entityType, contribution.entityId, contribution.eventKind, contribution.sourceKind, contribution.startsAt, contribution.endsAt, Number(contribution.totalAp.toFixed(4)), contribution.rateApPerHour, JSON.stringify({
|
|
2043
|
+
title: contribution.title,
|
|
2044
|
+
why: contribution.why,
|
|
2045
|
+
role: contribution.role,
|
|
2046
|
+
...(contribution.metadata ?? {})
|
|
2047
|
+
}), createdAt);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
function syncStatXpEvents(userId, dateKey, contributions) {
|
|
2052
|
+
const totals = new Map();
|
|
2053
|
+
for (const contribution of contributions) {
|
|
2054
|
+
if (contribution.totalAp <= 0) {
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
const weights = contribution.profile?.demandWeights ?? {
|
|
2058
|
+
activation: 0.2,
|
|
2059
|
+
focus: 0.25,
|
|
2060
|
+
vigor: 0.2,
|
|
2061
|
+
composure: 0.15,
|
|
2062
|
+
flow: 0.2
|
|
2063
|
+
};
|
|
2064
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
2065
|
+
totals.set(key, Number(((totals.get(key) ?? 0) + contribution.totalAp * weight).toFixed(4)));
|
|
2066
|
+
}
|
|
2067
|
+
totals.set("life_force", Number(((totals.get("life_force") ?? 0) + contribution.totalAp * 0.35).toFixed(4)));
|
|
2068
|
+
}
|
|
2069
|
+
runInTransaction(() => {
|
|
2070
|
+
const database = getDatabase();
|
|
2071
|
+
database
|
|
2072
|
+
.prepare(`DELETE FROM stat_xp_events
|
|
2073
|
+
WHERE user_id = ?
|
|
2074
|
+
AND json_extract(metadata_json, '$.dateKey') = ?`)
|
|
2075
|
+
.run(userId, dateKey);
|
|
2076
|
+
const insert = database.prepare(`INSERT INTO stat_xp_events (
|
|
2077
|
+
id,
|
|
2078
|
+
user_id,
|
|
2079
|
+
stat_key,
|
|
2080
|
+
delta_xp,
|
|
2081
|
+
entity_type,
|
|
2082
|
+
entity_id,
|
|
2083
|
+
metadata_json,
|
|
2084
|
+
created_at
|
|
2085
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
2086
|
+
const createdAt = nowIso();
|
|
2087
|
+
for (const [statKey, deltaXp] of totals.entries()) {
|
|
2088
|
+
insert.run(`statxp_${userId}_${dateKey}_${statKey}`, userId, statKey, deltaXp, "system", dateKey, JSON.stringify({
|
|
2089
|
+
source: "life_force_daily_rollup",
|
|
2090
|
+
dateKey
|
|
2091
|
+
}), createdAt);
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
function readTodayLedger(userId, dateKey) {
|
|
2096
|
+
return getDatabase()
|
|
2097
|
+
.prepare(`SELECT *
|
|
2098
|
+
FROM ap_ledger_events
|
|
2099
|
+
WHERE user_id = ? AND date_key = ?
|
|
2100
|
+
ORDER BY created_at ASC`)
|
|
2101
|
+
.all(userId, dateKey);
|
|
2102
|
+
}
|
|
2103
|
+
function readTodayFatigueSignals(userId, dateKey) {
|
|
2104
|
+
return getDatabase()
|
|
2105
|
+
.prepare(`SELECT signal_type, delta
|
|
2106
|
+
FROM fatigue_signals
|
|
2107
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
2108
|
+
.all(userId, dateKey);
|
|
2109
|
+
}
|
|
2110
|
+
function buildWarnings(input) {
|
|
2111
|
+
const warnings = [];
|
|
2112
|
+
if (input.isOverloadedNow) {
|
|
2113
|
+
warnings.push({
|
|
2114
|
+
id: "lf_overload",
|
|
2115
|
+
tone: "danger",
|
|
2116
|
+
title: "You are overloaded right now",
|
|
2117
|
+
detail: "Current concurrent work is draining more than the available instant capacity."
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
if (input.spentTodayAp > input.dailyBudgetAp) {
|
|
2121
|
+
warnings.push({
|
|
2122
|
+
id: "lf_overspent",
|
|
2123
|
+
tone: "warning",
|
|
2124
|
+
title: "Daily AP is in debt",
|
|
2125
|
+
detail: "Today has already exceeded the calibrated Action Point budget."
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
if (input.topTaskIdsNeedingSplit.length > 0) {
|
|
2129
|
+
warnings.push({
|
|
2130
|
+
id: "lf_split",
|
|
2131
|
+
tone: "info",
|
|
2132
|
+
title: "A task wants to be split",
|
|
2133
|
+
detail: "One or more tasks have grown beyond a healthy expected duration."
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
if (warnings.length === 0) {
|
|
2137
|
+
warnings.push({
|
|
2138
|
+
id: "lf_stable",
|
|
2139
|
+
tone: "success",
|
|
2140
|
+
title: "Life Force is stable",
|
|
2141
|
+
detail: "Today is still inside a healthy capacity band."
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
return warnings;
|
|
2145
|
+
}
|
|
2146
|
+
export function resolveLifeForceUser(userIds) {
|
|
2147
|
+
if (userIds && userIds.length > 0) {
|
|
2148
|
+
return getUserById(userIds[0]) ?? getDefaultUser();
|
|
2149
|
+
}
|
|
2150
|
+
return getDefaultUser();
|
|
2151
|
+
}
|
|
2152
|
+
export function buildLifeForcePayload(now = new Date(), userIds) {
|
|
2153
|
+
ensureActionProfileTemplates();
|
|
2154
|
+
const user = resolveLifeForceUser(userIds);
|
|
2155
|
+
const profile = ensureLifeForceProfile(user.id);
|
|
2156
|
+
const snapshot = getOrCreateDaySnapshot(user.id, now);
|
|
2157
|
+
const range = buildDayRange(now);
|
|
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);
|
|
2183
|
+
const contributions = [
|
|
2184
|
+
...taskRuns.contributions,
|
|
2185
|
+
...adjustments,
|
|
2186
|
+
...notes,
|
|
2187
|
+
...habits,
|
|
2188
|
+
...workouts.contributions,
|
|
2189
|
+
...movement.contributions,
|
|
2190
|
+
...wakeImpulses,
|
|
2191
|
+
...plannedContainers.actualContributions,
|
|
2192
|
+
...calendarDrains.actualContributions
|
|
2193
|
+
];
|
|
2194
|
+
const seededProfilesByKey = new Map(seededActionProfiles().map((entry) => [entry.profileKey, entry]));
|
|
2195
|
+
const taskDurationRows = getDatabase()
|
|
2196
|
+
.prepare(`SELECT id, planned_duration_seconds
|
|
2197
|
+
FROM tasks`)
|
|
2198
|
+
.all();
|
|
2199
|
+
const taskDurationById = new Map(taskDurationRows.map((row) => [row.id, row.planned_duration_seconds]));
|
|
2200
|
+
const profileLookup = new Map();
|
|
2201
|
+
for (const contribution of contributions) {
|
|
2202
|
+
if (contribution.entityType === "task") {
|
|
2203
|
+
profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, resolveTaskActionProfile({
|
|
2204
|
+
id: contribution.entityId,
|
|
2205
|
+
plannedDurationSeconds: taskDurationById.get(contribution.entityId) ?? null
|
|
2206
|
+
}, profile));
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
if (contribution.entityType === "note") {
|
|
2210
|
+
profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("note_quick") ?? seededActionProfiles()[0], profile));
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
if (contribution.entityType === "habit") {
|
|
2214
|
+
profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("habit_default") ?? seededActionProfiles()[0], profile));
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
if (contribution.entityType === "workout_session") {
|
|
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));
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
const adjustmentApByTaskId = readTodayAdjustmentApByTaskId(user.id, range, profile);
|
|
2260
|
+
for (const [taskId, adjustmentAp] of adjustmentApByTaskId.entries()) {
|
|
2261
|
+
const existing = taskRuns.totalsByTaskId.get(taskId) ?? { todayAp: 0, totalAp: 0 };
|
|
2262
|
+
existing.todayAp += adjustmentAp;
|
|
2263
|
+
existing.totalAp += adjustmentAp;
|
|
2264
|
+
taskRuns.totalsByTaskId.set(taskId, existing);
|
|
2265
|
+
}
|
|
2266
|
+
syncApLedger(user.id, range, contributions);
|
|
2267
|
+
syncStatXpEvents(user.id, range.dateKey, contributions.map((contribution) => ({
|
|
2268
|
+
...contribution,
|
|
2269
|
+
profile: profileLookup.get(`${contribution.entityType}:${contribution.entityId}`) ?? null
|
|
2270
|
+
})));
|
|
2271
|
+
const ledger = readTodayLedger(user.id, range.dateKey);
|
|
2272
|
+
const spentTodayAp = ledger.reduce((sum, row) => sum + row.total_ap, 0);
|
|
2273
|
+
const currentCurve = parseCurvePoints(snapshot.points_json);
|
|
2274
|
+
const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
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
|
+
}));
|
|
2285
|
+
const activeDrains = [
|
|
2286
|
+
...taskRuns.activeDrains,
|
|
2287
|
+
...workouts.activeDrains,
|
|
2288
|
+
...movement.activeDrains,
|
|
2289
|
+
...plannedContainers.activeDrains,
|
|
2290
|
+
...calendarDrains.activeDrains
|
|
2291
|
+
]
|
|
2292
|
+
.sort((left, right) => (right.rateApPerHour ?? 0) - (left.rateApPerHour ?? 0))
|
|
2293
|
+
.map((entry, index) => ({
|
|
2294
|
+
id: `${entry.sourceKind}:${entry.entityId}`,
|
|
2295
|
+
sourceType: entry.entityType,
|
|
2296
|
+
sourceId: entry.entityId,
|
|
2297
|
+
title: entry.title,
|
|
2298
|
+
role: index === 0 ? "primary" : entry.role,
|
|
2299
|
+
apPerHour: Number((entry.rateApPerHour ?? 0).toFixed(2)),
|
|
2300
|
+
instantAp: Number((entry.totalAp ?? 0).toFixed(2)),
|
|
2301
|
+
why: entry.why,
|
|
2302
|
+
startedAt: entry.startsAt,
|
|
2303
|
+
endsAt: entry.endsAt
|
|
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
|
+
}));
|
|
2322
|
+
const sortedRates = activeDrains
|
|
2323
|
+
.map((entry) => entry.apPerHour)
|
|
2324
|
+
.sort((left, right) => right - left);
|
|
2325
|
+
const currentDrainApPerHour = (sortedRates[0] ?? 0) +
|
|
2326
|
+
(sortedRates[1] ?? 0) * 0.6 +
|
|
2327
|
+
(sortedRates[2] ?? 0) * 0.35 +
|
|
2328
|
+
sortedRates.slice(3).reduce((sum, value) => sum + value * 0.2, 0);
|
|
2329
|
+
const fatigueFromSignals = readTodayFatigueSignals(user.id, range.dateKey).reduce((sum, signal) => sum + signal.delta, 0);
|
|
2330
|
+
const fatigueBufferApPerHour = Math.max(0, Number((fatigueFromSignals + Math.max(0, activeDrains.length - 1) * 1.5).toFixed(2)));
|
|
2331
|
+
const rawInstantFreeApPerHour = Number((instantCapacityApPerHour - currentDrainApPerHour - fatigueBufferApPerHour).toFixed(2));
|
|
2332
|
+
const instantFreeApPerHour = Math.max(0, rawInstantFreeApPerHour);
|
|
2333
|
+
const overloadApPerHour = Math.max(0, Number((-rawInstantFreeApPerHour).toFixed(2)));
|
|
2334
|
+
const remainingAp = Number((snapshot.daily_budget_ap - spentTodayAp).toFixed(2));
|
|
2335
|
+
const plannedRemainingAp = Number(plannedDrains.reduce((sum, entry) => sum + entry.instantAp, 0).toFixed(2));
|
|
2336
|
+
const forecastAp = Number((spentTodayAp + plannedRemainingAp).toFixed(2));
|
|
2337
|
+
const targetBandMinAp = Number((snapshot.daily_budget_ap * 0.85).toFixed(2));
|
|
2338
|
+
const targetBandMaxAp = Number(snapshot.daily_budget_ap.toFixed(2));
|
|
2339
|
+
const workTime = computeWorkTime(now);
|
|
2340
|
+
const topTaskIdsNeedingSplit = getDatabase()
|
|
2341
|
+
.prepare(`SELECT tasks.id, tasks.planned_duration_seconds
|
|
2342
|
+
FROM tasks
|
|
2343
|
+
INNER JOIN entity_owners
|
|
2344
|
+
ON entity_owners.entity_type = 'task'
|
|
2345
|
+
AND entity_owners.entity_id = tasks.id
|
|
2346
|
+
AND entity_owners.role = 'owner'
|
|
2347
|
+
WHERE entity_owners.user_id = ?`)
|
|
2348
|
+
.all(user.id)
|
|
2349
|
+
.map((row) => row)
|
|
2350
|
+
.filter((row) => {
|
|
2351
|
+
const time = workTime.taskSummaries.get(row.id);
|
|
2352
|
+
return buildTaskSplitSuggestion({
|
|
2353
|
+
plannedDurationSeconds: row.planned_duration_seconds,
|
|
2354
|
+
totalTrackedSeconds: time?.totalCreditedSeconds ?? 0,
|
|
2355
|
+
projectedTotalSeconds: (time?.totalCreditedSeconds ?? 0) +
|
|
2356
|
+
readActiveTaskRunProjectionRows(row.id).reduce((sum, activeRow) => sum + computeProjectedRemainingSeconds(activeRow, now), 0)
|
|
2357
|
+
}).shouldSplit;
|
|
2358
|
+
})
|
|
2359
|
+
.map((row) => row.id)
|
|
2360
|
+
.slice(0, 3);
|
|
2361
|
+
return lifeForcePayloadSchema.parse({
|
|
2362
|
+
userId: user.id,
|
|
2363
|
+
dateKey: range.dateKey,
|
|
2364
|
+
baselineDailyAp: profile.base_daily_ap,
|
|
2365
|
+
dailyBudgetAp: Number(snapshot.daily_budget_ap.toFixed(2)),
|
|
2366
|
+
spentTodayAp: Number(spentTodayAp.toFixed(2)),
|
|
2367
|
+
remainingAp,
|
|
2368
|
+
forecastAp,
|
|
2369
|
+
plannedRemainingAp,
|
|
2370
|
+
targetBandMinAp,
|
|
2371
|
+
targetBandMaxAp,
|
|
2372
|
+
instantCapacityApPerHour: Number(instantCapacityApPerHour.toFixed(2)),
|
|
2373
|
+
instantFreeApPerHour,
|
|
2374
|
+
overloadApPerHour,
|
|
2375
|
+
currentDrainApPerHour: Number(currentDrainApPerHour.toFixed(2)),
|
|
2376
|
+
fatigueBufferApPerHour,
|
|
2377
|
+
sleepRecoveryMultiplier: snapshot.sleep_recovery_multiplier,
|
|
2378
|
+
readinessMultiplier: snapshot.readiness_multiplier,
|
|
2379
|
+
fatigueDebtCarry: snapshot.fatigue_debt_carry,
|
|
2380
|
+
stats: buildStats(profile, user.id),
|
|
2381
|
+
currentCurve: currentCurve.map((point) => ({
|
|
2382
|
+
...point,
|
|
2383
|
+
locked: point.minuteOfDay <= minuteOfDay
|
|
2384
|
+
})),
|
|
2385
|
+
activeDrains,
|
|
2386
|
+
plannedDrains,
|
|
2387
|
+
warnings: buildWarnings({
|
|
2388
|
+
spentTodayAp,
|
|
2389
|
+
dailyBudgetAp: snapshot.daily_budget_ap,
|
|
2390
|
+
isOverloadedNow: rawInstantFreeApPerHour < 0,
|
|
2391
|
+
topTaskIdsNeedingSplit
|
|
2392
|
+
}),
|
|
2393
|
+
recommendations: [
|
|
2394
|
+
instantFreeApPerHour <= 0
|
|
2395
|
+
? "Reduce overlap or take a recovery action before starting something new."
|
|
2396
|
+
: instantCapacityApPerHour > currentDrainApPerHour + 4
|
|
2397
|
+
? "This is a good moment for deep work."
|
|
2398
|
+
: "Favor lower-friction admin or recovery until headroom increases."
|
|
2399
|
+
],
|
|
2400
|
+
topTaskIdsNeedingSplit,
|
|
2401
|
+
updatedAt: now.toISOString()
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
export function buildTaskLifeForceFields(task, userId) {
|
|
2405
|
+
const effectiveUserId = userId ?? task.userId ?? getDefaultUser().id;
|
|
2406
|
+
const runtime = buildTaskLifeForceRuntime(task, effectiveUserId);
|
|
2407
|
+
return {
|
|
2408
|
+
actionPointSummary: buildTaskActionPointSummary({
|
|
2409
|
+
plannedDurationSeconds: task.plannedDurationSeconds,
|
|
2410
|
+
totalCostAp: runtime.profile.totalCostAp,
|
|
2411
|
+
spentTodayAp: runtime.spentTodayAp,
|
|
2412
|
+
spentTotalAp: runtime.spentTotalAp
|
|
2413
|
+
}),
|
|
2414
|
+
splitSuggestion: buildTaskSplitSuggestion({
|
|
2415
|
+
plannedDurationSeconds: task.plannedDurationSeconds,
|
|
2416
|
+
totalTrackedSeconds: task.time.totalCreditedSeconds,
|
|
2417
|
+
projectedTotalSeconds: runtime.projectedTotalSeconds
|
|
2418
|
+
})
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
export function getTaskCompletionRequirement(task, userId) {
|
|
2422
|
+
const effectiveUserId = userId ?? task.userId ?? getDefaultUser().id;
|
|
2423
|
+
const runtime = buildTaskLifeForceRuntime(task, effectiveUserId);
|
|
2424
|
+
return {
|
|
2425
|
+
todayCreditedSeconds: runtime.todayCreditedSeconds,
|
|
2426
|
+
requiresWorkLog: runtime.todayCreditedSeconds <= 0
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
export function updateLifeForceProfile(userId, patch) {
|
|
2430
|
+
const parsed = lifeForceProfilePatchSchema.parse(patch);
|
|
2431
|
+
const current = ensureLifeForceProfile(userId);
|
|
2432
|
+
const next = {
|
|
2433
|
+
base_daily_ap: parsed.baseDailyAp ?? current.base_daily_ap,
|
|
2434
|
+
readiness_multiplier: parsed.readinessMultiplier ?? current.readiness_multiplier,
|
|
2435
|
+
life_force_level: parsed.stats?.life_force ?? current.life_force_level,
|
|
2436
|
+
activation_level: parsed.stats?.activation ?? current.activation_level,
|
|
2437
|
+
focus_level: parsed.stats?.focus ?? current.focus_level,
|
|
2438
|
+
vigor_level: parsed.stats?.vigor ?? current.vigor_level,
|
|
2439
|
+
composure_level: parsed.stats?.composure ?? current.composure_level,
|
|
2440
|
+
flow_level: parsed.stats?.flow ?? current.flow_level
|
|
2441
|
+
};
|
|
2442
|
+
getDatabase()
|
|
2443
|
+
.prepare(`UPDATE life_force_profiles
|
|
2444
|
+
SET base_daily_ap = ?,
|
|
2445
|
+
readiness_multiplier = ?,
|
|
2446
|
+
life_force_level = ?,
|
|
2447
|
+
activation_level = ?,
|
|
2448
|
+
focus_level = ?,
|
|
2449
|
+
vigor_level = ?,
|
|
2450
|
+
composure_level = ?,
|
|
2451
|
+
flow_level = ?,
|
|
2452
|
+
updated_at = ?
|
|
2453
|
+
WHERE user_id = ?`)
|
|
2454
|
+
.run(next.base_daily_ap, next.readiness_multiplier, next.life_force_level, next.activation_level, next.focus_level, next.vigor_level, next.composure_level, next.flow_level, nowIso(), userId);
|
|
2455
|
+
const todayKey = toDateKey(new Date());
|
|
2456
|
+
getDatabase()
|
|
2457
|
+
.prepare(`DELETE FROM life_force_day_snapshots
|
|
2458
|
+
WHERE user_id = ? AND date_key = ?`)
|
|
2459
|
+
.run(userId, todayKey);
|
|
2460
|
+
return buildLifeForcePayload(new Date(), [userId]);
|
|
2461
|
+
}
|
|
2462
|
+
export function updateLifeForceTemplate(userId, weekday, input) {
|
|
2463
|
+
const parsed = lifeForceTemplateUpdateSchema.parse(input);
|
|
2464
|
+
ensureWeekdayTemplates(userId);
|
|
2465
|
+
const normalized = normalizeCurveToBudget([...parsed.points].sort((left, right) => left.minuteOfDay - right.minuteOfDay), LIFE_FORCE_BASELINE_DAILY_AP);
|
|
2466
|
+
getDatabase()
|
|
2467
|
+
.prepare(`UPDATE life_force_weekday_templates
|
|
2468
|
+
SET points_json = ?, updated_at = ?
|
|
2469
|
+
WHERE user_id = ? AND weekday = ?`)
|
|
2470
|
+
.run(JSON.stringify(normalized), nowIso(), userId, weekday);
|
|
2471
|
+
return normalized;
|
|
2472
|
+
}
|
|
2473
|
+
export function createFatigueSignal(userId, input) {
|
|
2474
|
+
const parsed = fatigueSignalCreateSchema.parse(input);
|
|
2475
|
+
const observedAt = parsed.observedAt ?? nowIso();
|
|
2476
|
+
const dateKey = observedAt.slice(0, 10);
|
|
2477
|
+
const delta = parsed.signalType === "tired" ? 4 : -4;
|
|
2478
|
+
getDatabase()
|
|
2479
|
+
.prepare(`INSERT INTO fatigue_signals (
|
|
2480
|
+
id,
|
|
2481
|
+
user_id,
|
|
2482
|
+
date_key,
|
|
2483
|
+
signal_type,
|
|
2484
|
+
observed_at,
|
|
2485
|
+
note,
|
|
2486
|
+
delta,
|
|
2487
|
+
created_at
|
|
2488
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2489
|
+
.run(`fatigue_${randomUUID().replaceAll("-", "").slice(0, 12)}`, userId, dateKey, parsed.signalType, observedAt, parsed.note ?? "", delta, nowIso());
|
|
2490
|
+
return buildLifeForcePayload(new Date(observedAt), [userId]);
|
|
2491
|
+
}
|
|
2492
|
+
export function listLifeForceTemplates(userId) {
|
|
2493
|
+
ensureWeekdayTemplates(userId);
|
|
2494
|
+
return getDatabase()
|
|
2495
|
+
.prepare(`SELECT *
|
|
2496
|
+
FROM life_force_weekday_templates
|
|
2497
|
+
WHERE user_id = ?
|
|
2498
|
+
ORDER BY weekday ASC`)
|
|
2499
|
+
.all(userId)
|
|
2500
|
+
.map((row) => row)
|
|
2501
|
+
.map((row) => ({
|
|
2502
|
+
weekday: row.weekday,
|
|
2503
|
+
baselineDailyAp: row.baseline_daily_ap,
|
|
2504
|
+
points: parseCurvePoints(row.points_json)
|
|
2505
|
+
}));
|
|
2506
|
+
}
|