forge-openclaw-plugin 0.2.15 → 0.2.18
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 +6 -3
- package/dist/assets/{board-C_m78kvK.js → board-2KevHCI0.js} +2 -2
- package/dist/assets/{board-C_m78kvK.js.map → board-2KevHCI0.js.map} +1 -1
- package/dist/assets/index-CDYW4WDH.js +36 -0
- package/dist/assets/index-CDYW4WDH.js.map +1 -0
- package/dist/assets/index-yroQr6YZ.css +1 -0
- package/dist/assets/{motion-CpZvZumD.js → motion-q19HPmWs.js} +2 -2
- package/dist/assets/{motion-CpZvZumD.js.map → motion-q19HPmWs.js.map} +1 -1
- package/dist/assets/{table-DtyXTw03.js → table-BDMHBY4a.js} +2 -2
- package/dist/assets/{table-DtyXTw03.js.map → table-BDMHBY4a.js.map} +1 -1
- package/dist/assets/{ui-BXbpiKyS.js → ui-CQ_AsFs8.js} +2 -2
- package/dist/assets/{ui-BXbpiKyS.js.map → ui-CQ_AsFs8.js.map} +1 -1
- package/dist/assets/{vendor-QBH6qVEe.js → vendor-5HifrnRK.js} +90 -75
- package/dist/assets/{vendor-QBH6qVEe.js.map → vendor-5HifrnRK.js.map} +1 -1
- package/dist/assets/{viz-w-IMeueL.js → viz-CQzkRnTu.js} +2 -2
- package/dist/assets/{viz-w-IMeueL.js.map → viz-CQzkRnTu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/local-runtime.js +142 -9
- package/dist/openclaw/plugin-entry-shared.js +7 -1
- package/dist/openclaw/tools.js +15 -0
- package/dist/server/app.js +129 -11
- package/dist/server/openapi.js +181 -4
- package/dist/server/repositories/habits.js +358 -0
- package/dist/server/repositories/rewards.js +62 -0
- package/dist/server/services/context.js +16 -6
- package/dist/server/services/dashboard.js +6 -3
- package/dist/server/services/entity-crud.js +23 -1
- package/dist/server/services/gamification.js +66 -18
- package/dist/server/services/insights.js +2 -1
- package/dist/server/services/reviews.js +2 -1
- package/dist/server/types.js +140 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/003_habits.sql +30 -0
- package/server/migrations/004_habit_links.sql +8 -0
- package/server/migrations/005_habit_psyche_links.sql +24 -0
- package/skills/forge-openclaw/SKILL.md +16 -2
- package/skills/forge-openclaw/cron_jobs.md +395 -0
- package/dist/assets/index-BWtLtXwb.js +0 -36
- package/dist/assets/index-BWtLtXwb.js.map +0 -1
- package/dist/assets/index-Dp5GXY_z.css +0 -1
package/dist/server/openapi.js
CHANGED
|
@@ -242,6 +242,89 @@ export function buildOpenApiDocument() {
|
|
|
242
242
|
isCurrent: { type: "boolean" }
|
|
243
243
|
}
|
|
244
244
|
};
|
|
245
|
+
const habitCheckIn = {
|
|
246
|
+
type: "object",
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
required: ["id", "habitId", "dateKey", "status", "note", "deltaXp", "createdAt", "updatedAt"],
|
|
249
|
+
properties: {
|
|
250
|
+
id: { type: "string" },
|
|
251
|
+
habitId: { type: "string" },
|
|
252
|
+
dateKey: { type: "string", format: "date" },
|
|
253
|
+
status: { type: "string", enum: ["done", "missed"] },
|
|
254
|
+
note: { type: "string" },
|
|
255
|
+
deltaXp: { type: "integer" },
|
|
256
|
+
createdAt: { type: "string", format: "date-time" },
|
|
257
|
+
updatedAt: { type: "string", format: "date-time" }
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const habit = {
|
|
261
|
+
type: "object",
|
|
262
|
+
additionalProperties: false,
|
|
263
|
+
required: [
|
|
264
|
+
"id",
|
|
265
|
+
"title",
|
|
266
|
+
"description",
|
|
267
|
+
"status",
|
|
268
|
+
"polarity",
|
|
269
|
+
"frequency",
|
|
270
|
+
"targetCount",
|
|
271
|
+
"weekDays",
|
|
272
|
+
"linkedGoalIds",
|
|
273
|
+
"linkedProjectIds",
|
|
274
|
+
"linkedTaskIds",
|
|
275
|
+
"linkedValueIds",
|
|
276
|
+
"linkedPatternIds",
|
|
277
|
+
"linkedBehaviorIds",
|
|
278
|
+
"linkedBeliefIds",
|
|
279
|
+
"linkedModeIds",
|
|
280
|
+
"linkedReportIds",
|
|
281
|
+
"linkedBehaviorId",
|
|
282
|
+
"linkedBehaviorTitle",
|
|
283
|
+
"linkedBehaviorTitles",
|
|
284
|
+
"rewardXp",
|
|
285
|
+
"penaltyXp",
|
|
286
|
+
"createdAt",
|
|
287
|
+
"updatedAt",
|
|
288
|
+
"lastCheckInAt",
|
|
289
|
+
"lastCheckInStatus",
|
|
290
|
+
"streakCount",
|
|
291
|
+
"completionRate",
|
|
292
|
+
"dueToday",
|
|
293
|
+
"checkIns"
|
|
294
|
+
],
|
|
295
|
+
properties: {
|
|
296
|
+
id: { type: "string" },
|
|
297
|
+
title: { type: "string" },
|
|
298
|
+
description: { type: "string" },
|
|
299
|
+
status: { type: "string", enum: ["active", "paused", "archived"] },
|
|
300
|
+
polarity: { type: "string", enum: ["positive", "negative"] },
|
|
301
|
+
frequency: { type: "string", enum: ["daily", "weekly"] },
|
|
302
|
+
targetCount: { type: "integer" },
|
|
303
|
+
weekDays: arrayOf({ type: "integer" }),
|
|
304
|
+
linkedGoalIds: arrayOf({ type: "string" }),
|
|
305
|
+
linkedProjectIds: arrayOf({ type: "string" }),
|
|
306
|
+
linkedTaskIds: arrayOf({ type: "string" }),
|
|
307
|
+
linkedValueIds: arrayOf({ type: "string" }),
|
|
308
|
+
linkedPatternIds: arrayOf({ type: "string" }),
|
|
309
|
+
linkedBehaviorIds: arrayOf({ type: "string" }),
|
|
310
|
+
linkedBeliefIds: arrayOf({ type: "string" }),
|
|
311
|
+
linkedModeIds: arrayOf({ type: "string" }),
|
|
312
|
+
linkedReportIds: arrayOf({ type: "string" }),
|
|
313
|
+
linkedBehaviorId: nullable({ type: "string" }),
|
|
314
|
+
linkedBehaviorTitle: nullable({ type: "string" }),
|
|
315
|
+
linkedBehaviorTitles: arrayOf({ type: "string" }),
|
|
316
|
+
rewardXp: { type: "integer" },
|
|
317
|
+
penaltyXp: { type: "integer" },
|
|
318
|
+
createdAt: { type: "string", format: "date-time" },
|
|
319
|
+
updatedAt: { type: "string", format: "date-time" },
|
|
320
|
+
lastCheckInAt: nullable({ type: "string", format: "date-time" }),
|
|
321
|
+
lastCheckInStatus: nullable({ type: "string", enum: ["done", "missed"] }),
|
|
322
|
+
streakCount: { type: "integer" },
|
|
323
|
+
completionRate: { type: "number" },
|
|
324
|
+
dueToday: { type: "boolean" },
|
|
325
|
+
checkIns: arrayOf({ $ref: "#/components/schemas/HabitCheckIn" })
|
|
326
|
+
}
|
|
327
|
+
};
|
|
245
328
|
const activityEvent = {
|
|
246
329
|
type: "object",
|
|
247
330
|
additionalProperties: false,
|
|
@@ -252,6 +335,7 @@ export function buildOpenApiDocument() {
|
|
|
252
335
|
type: "string",
|
|
253
336
|
enum: [
|
|
254
337
|
"task",
|
|
338
|
+
"habit",
|
|
255
339
|
"goal",
|
|
256
340
|
"project",
|
|
257
341
|
"domain",
|
|
@@ -378,7 +462,7 @@ export function buildOpenApiDocument() {
|
|
|
378
462
|
const dashboardPayload = {
|
|
379
463
|
type: "object",
|
|
380
464
|
additionalProperties: false,
|
|
381
|
-
required: ["stats", "goals", "projects", "tasks", "tags", "suggestedTags", "owners", "executionBuckets", "gamification", "achievements", "milestoneRewards", "recentActivity", "notesSummaryByEntity"],
|
|
465
|
+
required: ["stats", "goals", "projects", "tasks", "habits", "tags", "suggestedTags", "owners", "executionBuckets", "gamification", "achievements", "milestoneRewards", "recentActivity", "notesSummaryByEntity"],
|
|
382
466
|
properties: {
|
|
383
467
|
stats: {
|
|
384
468
|
type: "object",
|
|
@@ -397,6 +481,7 @@ export function buildOpenApiDocument() {
|
|
|
397
481
|
goals: arrayOf({ $ref: "#/components/schemas/DashboardGoal" }),
|
|
398
482
|
projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
|
|
399
483
|
tasks: arrayOf({ $ref: "#/components/schemas/Task" }),
|
|
484
|
+
habits: arrayOf({ $ref: "#/components/schemas/Habit" }),
|
|
400
485
|
tags: arrayOf({ $ref: "#/components/schemas/Tag" }),
|
|
401
486
|
suggestedTags: arrayOf({ $ref: "#/components/schemas/Tag" }),
|
|
402
487
|
owners: arrayOf({ type: "string" }),
|
|
@@ -422,7 +507,7 @@ export function buildOpenApiDocument() {
|
|
|
422
507
|
const overviewContext = {
|
|
423
508
|
type: "object",
|
|
424
509
|
additionalProperties: false,
|
|
425
|
-
required: ["generatedAt", "strategicHeader", "projects", "activeGoals", "topTasks", "recentEvidence", "achievements", "domainBalance", "neglectedGoals"],
|
|
510
|
+
required: ["generatedAt", "strategicHeader", "projects", "activeGoals", "topTasks", "dueHabits", "recentEvidence", "achievements", "domainBalance", "neglectedGoals"],
|
|
426
511
|
properties: {
|
|
427
512
|
generatedAt: { type: "string", format: "date-time" },
|
|
428
513
|
strategicHeader: {
|
|
@@ -443,6 +528,7 @@ export function buildOpenApiDocument() {
|
|
|
443
528
|
projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
|
|
444
529
|
activeGoals: arrayOf({ $ref: "#/components/schemas/DashboardGoal" }),
|
|
445
530
|
topTasks: arrayOf({ $ref: "#/components/schemas/Task" }),
|
|
531
|
+
dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
|
|
446
532
|
recentEvidence: arrayOf({ $ref: "#/components/schemas/ActivityEvent" }),
|
|
447
533
|
achievements: arrayOf({ $ref: "#/components/schemas/AchievementSignal" }),
|
|
448
534
|
domainBalance: arrayOf({
|
|
@@ -475,7 +561,7 @@ export function buildOpenApiDocument() {
|
|
|
475
561
|
const todayContext = {
|
|
476
562
|
type: "object",
|
|
477
563
|
additionalProperties: false,
|
|
478
|
-
required: ["generatedAt", "directive", "timeline", "dailyQuests", "milestoneRewards", "momentum"],
|
|
564
|
+
required: ["generatedAt", "directive", "timeline", "dueHabits", "dailyQuests", "milestoneRewards", "recentHabitRewards", "momentum"],
|
|
479
565
|
properties: {
|
|
480
566
|
generatedAt: { type: "string", format: "date-time" },
|
|
481
567
|
directive: {
|
|
@@ -499,6 +585,7 @@ export function buildOpenApiDocument() {
|
|
|
499
585
|
tasks: arrayOf({ $ref: "#/components/schemas/Task" })
|
|
500
586
|
}
|
|
501
587
|
}),
|
|
588
|
+
dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
|
|
502
589
|
dailyQuests: arrayOf({
|
|
503
590
|
type: "object",
|
|
504
591
|
additionalProperties: false,
|
|
@@ -513,6 +600,7 @@ export function buildOpenApiDocument() {
|
|
|
513
600
|
}
|
|
514
601
|
}),
|
|
515
602
|
milestoneRewards: arrayOf({ $ref: "#/components/schemas/MilestoneReward" }),
|
|
603
|
+
recentHabitRewards: arrayOf({ $ref: "#/components/schemas/RewardLedgerEvent" }),
|
|
516
604
|
momentum: {
|
|
517
605
|
type: "object",
|
|
518
606
|
additionalProperties: false,
|
|
@@ -550,7 +638,7 @@ export function buildOpenApiDocument() {
|
|
|
550
638
|
const forgeSnapshot = {
|
|
551
639
|
type: "object",
|
|
552
640
|
additionalProperties: false,
|
|
553
|
-
required: ["meta", "metrics", "dashboard", "overview", "today", "risk", "goals", "projects", "tags", "tasks", "activeTaskRuns", "activity"],
|
|
641
|
+
required: ["meta", "metrics", "dashboard", "overview", "today", "risk", "goals", "projects", "tags", "tasks", "habits", "activeTaskRuns", "activity"],
|
|
554
642
|
properties: {
|
|
555
643
|
meta: {
|
|
556
644
|
type: "object",
|
|
@@ -573,6 +661,7 @@ export function buildOpenApiDocument() {
|
|
|
573
661
|
projects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
|
|
574
662
|
tags: arrayOf({ $ref: "#/components/schemas/Tag" }),
|
|
575
663
|
tasks: arrayOf({ $ref: "#/components/schemas/Task" }),
|
|
664
|
+
habits: arrayOf({ $ref: "#/components/schemas/Habit" }),
|
|
576
665
|
activeTaskRuns: arrayOf({ $ref: "#/components/schemas/TaskRun" }),
|
|
577
666
|
activity: arrayOf({ $ref: "#/components/schemas/ActivityEvent" })
|
|
578
667
|
}
|
|
@@ -1041,6 +1130,7 @@ export function buildOpenApiDocument() {
|
|
|
1041
1130
|
"generatedAt",
|
|
1042
1131
|
"activeProjects",
|
|
1043
1132
|
"focusTasks",
|
|
1133
|
+
"dueHabits",
|
|
1044
1134
|
"currentBoard",
|
|
1045
1135
|
"recentActivity",
|
|
1046
1136
|
"recentTaskRuns",
|
|
@@ -1051,6 +1141,7 @@ export function buildOpenApiDocument() {
|
|
|
1051
1141
|
generatedAt: { type: "string", format: "date-time" },
|
|
1052
1142
|
activeProjects: arrayOf({ $ref: "#/components/schemas/ProjectSummary" }),
|
|
1053
1143
|
focusTasks: arrayOf({ $ref: "#/components/schemas/Task" }),
|
|
1144
|
+
dueHabits: arrayOf({ $ref: "#/components/schemas/Habit" }),
|
|
1054
1145
|
currentBoard: {
|
|
1055
1146
|
type: "object",
|
|
1056
1147
|
additionalProperties: false,
|
|
@@ -1993,6 +2084,8 @@ export function buildOpenApiDocument() {
|
|
|
1993
2084
|
ProjectSummary: projectSummary,
|
|
1994
2085
|
Task: task,
|
|
1995
2086
|
TaskRun: taskRun,
|
|
2087
|
+
HabitCheckIn: habitCheckIn,
|
|
2088
|
+
Habit: habit,
|
|
1996
2089
|
ActivityEvent: activityEvent,
|
|
1997
2090
|
GamificationProfile: gamificationProfile,
|
|
1998
2091
|
AchievementSignal: achievementSignal,
|
|
@@ -2718,6 +2811,90 @@ export function buildOpenApiDocument() {
|
|
|
2718
2811
|
}
|
|
2719
2812
|
}
|
|
2720
2813
|
},
|
|
2814
|
+
"/api/v1/habits": {
|
|
2815
|
+
get: {
|
|
2816
|
+
summary: "List habits with current streak and due-today state",
|
|
2817
|
+
responses: {
|
|
2818
|
+
"200": jsonResponse({
|
|
2819
|
+
type: "object",
|
|
2820
|
+
required: ["habits"],
|
|
2821
|
+
properties: {
|
|
2822
|
+
habits: arrayOf({ $ref: "#/components/schemas/Habit" })
|
|
2823
|
+
}
|
|
2824
|
+
}, "Habit collection")
|
|
2825
|
+
}
|
|
2826
|
+
},
|
|
2827
|
+
post: {
|
|
2828
|
+
summary: "Create a habit",
|
|
2829
|
+
responses: {
|
|
2830
|
+
"201": jsonResponse({
|
|
2831
|
+
type: "object",
|
|
2832
|
+
required: ["habit"],
|
|
2833
|
+
properties: {
|
|
2834
|
+
habit: { $ref: "#/components/schemas/Habit" }
|
|
2835
|
+
}
|
|
2836
|
+
}, "Created habit"),
|
|
2837
|
+
default: { $ref: "#/components/responses/Error" }
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
},
|
|
2841
|
+
"/api/v1/habits/{id}": {
|
|
2842
|
+
get: {
|
|
2843
|
+
summary: "Get a habit",
|
|
2844
|
+
responses: {
|
|
2845
|
+
"200": jsonResponse({
|
|
2846
|
+
type: "object",
|
|
2847
|
+
required: ["habit"],
|
|
2848
|
+
properties: {
|
|
2849
|
+
habit: { $ref: "#/components/schemas/Habit" }
|
|
2850
|
+
}
|
|
2851
|
+
}, "Habit"),
|
|
2852
|
+
"404": { $ref: "#/components/responses/Error" }
|
|
2853
|
+
}
|
|
2854
|
+
},
|
|
2855
|
+
patch: {
|
|
2856
|
+
summary: "Update a habit",
|
|
2857
|
+
responses: {
|
|
2858
|
+
"200": jsonResponse({
|
|
2859
|
+
type: "object",
|
|
2860
|
+
required: ["habit"],
|
|
2861
|
+
properties: {
|
|
2862
|
+
habit: { $ref: "#/components/schemas/Habit" }
|
|
2863
|
+
}
|
|
2864
|
+
}, "Updated habit"),
|
|
2865
|
+
"404": { $ref: "#/components/responses/Error" }
|
|
2866
|
+
}
|
|
2867
|
+
},
|
|
2868
|
+
delete: {
|
|
2869
|
+
summary: "Delete a habit",
|
|
2870
|
+
responses: {
|
|
2871
|
+
"200": jsonResponse({
|
|
2872
|
+
type: "object",
|
|
2873
|
+
required: ["habit"],
|
|
2874
|
+
properties: {
|
|
2875
|
+
habit: { $ref: "#/components/schemas/Habit" }
|
|
2876
|
+
}
|
|
2877
|
+
}, "Deleted habit"),
|
|
2878
|
+
"404": { $ref: "#/components/responses/Error" }
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
"/api/v1/habits/{id}/check-ins": {
|
|
2883
|
+
post: {
|
|
2884
|
+
summary: "Record a habit outcome for one day",
|
|
2885
|
+
responses: {
|
|
2886
|
+
"200": jsonResponse({
|
|
2887
|
+
type: "object",
|
|
2888
|
+
required: ["habit", "metrics"],
|
|
2889
|
+
properties: {
|
|
2890
|
+
habit: { $ref: "#/components/schemas/Habit" },
|
|
2891
|
+
metrics: { $ref: "#/components/schemas/XpMetricsPayload" }
|
|
2892
|
+
}
|
|
2893
|
+
}, "Habit check-in result"),
|
|
2894
|
+
"404": { $ref: "#/components/responses/Error" }
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
},
|
|
2721
2898
|
"/api/v1/tags": {
|
|
2722
2899
|
get: {
|
|
2723
2900
|
summary: "List tags",
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { getGoalById } from "./goals.js";
|
|
5
|
+
import { getProjectById } from "./projects.js";
|
|
6
|
+
import { getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getModeProfileById, getPsycheValueById, getTriggerReportById } from "./psyche.js";
|
|
7
|
+
import { getTaskById } from "./tasks.js";
|
|
8
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
9
|
+
import { recordHabitCheckInReward } from "./rewards.js";
|
|
10
|
+
import { createHabitCheckInSchema, createHabitSchema, habitCheckInSchema, habitSchema, updateHabitSchema } from "../types.js";
|
|
11
|
+
function todayKey(now = new Date()) {
|
|
12
|
+
return now.toISOString().slice(0, 10);
|
|
13
|
+
}
|
|
14
|
+
function parseWeekDays(raw) {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return Array.isArray(parsed) ? parsed.filter((value) => Number.isInteger(value) && value >= 0 && value <= 6) : [];
|
|
17
|
+
}
|
|
18
|
+
function parseIdList(raw) {
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
21
|
+
}
|
|
22
|
+
function uniqueIds(values) {
|
|
23
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
|
|
24
|
+
}
|
|
25
|
+
function normalizeLinkedBehaviorIds(input) {
|
|
26
|
+
const fromArray = Array.isArray(input.linkedBehaviorIds) ? input.linkedBehaviorIds : [];
|
|
27
|
+
return uniqueIds([...fromArray, input.linkedBehaviorId ?? null]);
|
|
28
|
+
}
|
|
29
|
+
function validateExistingIds(ids, getById, code, label) {
|
|
30
|
+
for (const id of ids) {
|
|
31
|
+
if (!getById(id)) {
|
|
32
|
+
throw new HttpError(404, code, `${label} ${id} does not exist`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function mapCheckIn(row) {
|
|
37
|
+
return habitCheckInSchema.parse({
|
|
38
|
+
id: row.id,
|
|
39
|
+
habitId: row.habit_id,
|
|
40
|
+
dateKey: row.date_key,
|
|
41
|
+
status: row.status,
|
|
42
|
+
note: row.note,
|
|
43
|
+
deltaXp: row.delta_xp,
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
updatedAt: row.updated_at
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function listCheckInsForHabit(habitId, limit = 14) {
|
|
49
|
+
const rows = getDatabase()
|
|
50
|
+
.prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
|
|
51
|
+
FROM habit_check_ins
|
|
52
|
+
WHERE habit_id = ?
|
|
53
|
+
ORDER BY date_key DESC, created_at DESC
|
|
54
|
+
LIMIT ?`)
|
|
55
|
+
.all(habitId, limit);
|
|
56
|
+
return rows.map(mapCheckIn);
|
|
57
|
+
}
|
|
58
|
+
function isAligned(habit, checkIn) {
|
|
59
|
+
return (habit.polarity === "positive" && checkIn.status === "done") || (habit.polarity === "negative" && checkIn.status === "missed");
|
|
60
|
+
}
|
|
61
|
+
function calculateCompletionRate(habit, checkIns) {
|
|
62
|
+
if (checkIns.length === 0) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const aligned = checkIns.filter((checkIn) => isAligned(habit, checkIn)).length;
|
|
66
|
+
return Math.round((aligned / checkIns.length) * 100);
|
|
67
|
+
}
|
|
68
|
+
function calculateStreak(habit, checkIns) {
|
|
69
|
+
let streak = 0;
|
|
70
|
+
for (const checkIn of checkIns) {
|
|
71
|
+
if (!isAligned(habit, checkIn)) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
streak += 1;
|
|
75
|
+
}
|
|
76
|
+
return streak;
|
|
77
|
+
}
|
|
78
|
+
function isHabitDueToday(habit, latestCheckIn, now = new Date()) {
|
|
79
|
+
if (habit.status !== "active") {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const key = todayKey(now);
|
|
83
|
+
if (latestCheckIn?.dateKey === key) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (habit.frequency === "daily") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return habit.weekDays.includes(now.getUTCDay());
|
|
90
|
+
}
|
|
91
|
+
function mapHabit(row, checkIns = listCheckInsForHabit(row.id)) {
|
|
92
|
+
const latestCheckIn = checkIns[0] ?? null;
|
|
93
|
+
const linkedBehaviorIds = normalizeLinkedBehaviorIds({
|
|
94
|
+
linkedBehaviorIds: parseIdList(row.linked_behavior_ids_json),
|
|
95
|
+
linkedBehaviorId: row.linked_behavior_id
|
|
96
|
+
});
|
|
97
|
+
const linkedBehaviors = linkedBehaviorIds
|
|
98
|
+
.map((behaviorId) => getBehaviorById(behaviorId))
|
|
99
|
+
.filter((behavior) => behavior !== undefined);
|
|
100
|
+
const draft = {
|
|
101
|
+
id: row.id,
|
|
102
|
+
title: row.title,
|
|
103
|
+
description: row.description,
|
|
104
|
+
status: row.status,
|
|
105
|
+
polarity: row.polarity,
|
|
106
|
+
frequency: row.frequency,
|
|
107
|
+
targetCount: row.target_count,
|
|
108
|
+
weekDays: parseWeekDays(row.week_days_json),
|
|
109
|
+
linkedGoalIds: parseIdList(row.linked_goal_ids_json),
|
|
110
|
+
linkedProjectIds: parseIdList(row.linked_project_ids_json),
|
|
111
|
+
linkedTaskIds: parseIdList(row.linked_task_ids_json),
|
|
112
|
+
linkedValueIds: parseIdList(row.linked_value_ids_json),
|
|
113
|
+
linkedPatternIds: parseIdList(row.linked_pattern_ids_json),
|
|
114
|
+
linkedBehaviorIds,
|
|
115
|
+
linkedBeliefIds: parseIdList(row.linked_belief_ids_json),
|
|
116
|
+
linkedModeIds: parseIdList(row.linked_mode_ids_json),
|
|
117
|
+
linkedReportIds: parseIdList(row.linked_report_ids_json),
|
|
118
|
+
linkedBehaviorId: linkedBehaviorIds[0] ?? null,
|
|
119
|
+
linkedBehaviorTitle: linkedBehaviors[0]?.title ?? null,
|
|
120
|
+
linkedBehaviorTitles: linkedBehaviors.map((behavior) => behavior.title),
|
|
121
|
+
rewardXp: row.reward_xp,
|
|
122
|
+
penaltyXp: row.penalty_xp,
|
|
123
|
+
createdAt: row.created_at,
|
|
124
|
+
updatedAt: row.updated_at,
|
|
125
|
+
lastCheckInAt: latestCheckIn?.createdAt ?? null,
|
|
126
|
+
lastCheckInStatus: latestCheckIn?.status ?? null,
|
|
127
|
+
streakCount: calculateStreak({ polarity: row.polarity }, checkIns),
|
|
128
|
+
completionRate: calculateCompletionRate({ polarity: row.polarity }, checkIns),
|
|
129
|
+
dueToday: false,
|
|
130
|
+
checkIns
|
|
131
|
+
};
|
|
132
|
+
draft.dueToday = isHabitDueToday({ status: draft.status, frequency: draft.frequency, weekDays: draft.weekDays }, latestCheckIn);
|
|
133
|
+
return habitSchema.parse(draft);
|
|
134
|
+
}
|
|
135
|
+
function getHabitRow(habitId) {
|
|
136
|
+
return getDatabase()
|
|
137
|
+
.prepare(`SELECT
|
|
138
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
139
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
140
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
141
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
142
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
143
|
+
FROM habits
|
|
144
|
+
WHERE id = ?`)
|
|
145
|
+
.get(habitId);
|
|
146
|
+
}
|
|
147
|
+
export function listHabits(filters = {}) {
|
|
148
|
+
const parsed = filters;
|
|
149
|
+
const whereClauses = [];
|
|
150
|
+
const params = [];
|
|
151
|
+
if (parsed.status) {
|
|
152
|
+
whereClauses.push("status = ?");
|
|
153
|
+
params.push(parsed.status);
|
|
154
|
+
}
|
|
155
|
+
if (parsed.polarity) {
|
|
156
|
+
whereClauses.push("polarity = ?");
|
|
157
|
+
params.push(parsed.polarity);
|
|
158
|
+
}
|
|
159
|
+
const limitSql = parsed.limit ? "LIMIT ?" : "";
|
|
160
|
+
if (parsed.limit) {
|
|
161
|
+
params.push(parsed.limit);
|
|
162
|
+
}
|
|
163
|
+
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
164
|
+
const rows = getDatabase()
|
|
165
|
+
.prepare(`SELECT
|
|
166
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
167
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
168
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
169
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
170
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
171
|
+
FROM habits
|
|
172
|
+
${whereSql}
|
|
173
|
+
ORDER BY
|
|
174
|
+
CASE status WHEN 'active' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
|
175
|
+
updated_at DESC
|
|
176
|
+
${limitSql}`)
|
|
177
|
+
.all(...params);
|
|
178
|
+
const habits = rows.map((row) => mapHabit(row));
|
|
179
|
+
return parsed.dueToday ? habits.filter((habit) => habit.dueToday) : habits;
|
|
180
|
+
}
|
|
181
|
+
export function getHabitById(habitId) {
|
|
182
|
+
const row = getHabitRow(habitId);
|
|
183
|
+
return row ? mapHabit(row) : undefined;
|
|
184
|
+
}
|
|
185
|
+
export function createHabit(input, activity) {
|
|
186
|
+
const parsed = createHabitSchema.parse(input);
|
|
187
|
+
const linkedBehaviorIds = normalizeLinkedBehaviorIds(parsed);
|
|
188
|
+
validateExistingIds(parsed.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
|
|
189
|
+
validateExistingIds(parsed.linkedProjectIds, getProjectById, "project_not_found", "Project");
|
|
190
|
+
validateExistingIds(parsed.linkedTaskIds, getTaskById, "task_not_found", "Task");
|
|
191
|
+
validateExistingIds(parsed.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
|
|
192
|
+
validateExistingIds(parsed.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
|
|
193
|
+
validateExistingIds(linkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
|
|
194
|
+
validateExistingIds(parsed.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
|
|
195
|
+
validateExistingIds(parsed.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
|
|
196
|
+
validateExistingIds(parsed.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
|
|
197
|
+
return runInTransaction(() => {
|
|
198
|
+
const now = new Date().toISOString();
|
|
199
|
+
const id = `habit_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
200
|
+
getDatabase()
|
|
201
|
+
.prepare(`INSERT INTO habits (
|
|
202
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
203
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
204
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
205
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
206
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
207
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
208
|
+
.run(id, parsed.title, parsed.description, parsed.status, parsed.polarity, parsed.frequency, parsed.targetCount, JSON.stringify(parsed.weekDays), JSON.stringify(parsed.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds), JSON.stringify(parsed.linkedValueIds), JSON.stringify(parsed.linkedPatternIds), JSON.stringify(linkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds), JSON.stringify(parsed.linkedReportIds), linkedBehaviorIds[0] ?? null, parsed.rewardXp, parsed.penaltyXp, now, now);
|
|
209
|
+
const habit = getHabitById(id);
|
|
210
|
+
if (activity) {
|
|
211
|
+
recordActivityEvent({
|
|
212
|
+
entityType: "habit",
|
|
213
|
+
entityId: habit.id,
|
|
214
|
+
eventType: "habit_created",
|
|
215
|
+
title: `Habit created: ${habit.title}`,
|
|
216
|
+
description: `${habit.frequency === "daily" ? "Daily" : "Weekly"} ${habit.polarity} habit added to Forge.`,
|
|
217
|
+
actor: activity.actor ?? null,
|
|
218
|
+
source: activity.source,
|
|
219
|
+
metadata: {
|
|
220
|
+
polarity: habit.polarity,
|
|
221
|
+
frequency: habit.frequency,
|
|
222
|
+
targetCount: habit.targetCount
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return habit;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
export function updateHabit(habitId, input, activity) {
|
|
230
|
+
const current = getHabitById(habitId);
|
|
231
|
+
if (!current) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const parsed = updateHabitSchema.parse(input);
|
|
235
|
+
const nextLinkedBehaviorIds = parsed.linkedBehaviorIds !== undefined || parsed.linkedBehaviorId !== undefined
|
|
236
|
+
? normalizeLinkedBehaviorIds({
|
|
237
|
+
linkedBehaviorIds: parsed.linkedBehaviorIds ?? current.linkedBehaviorIds,
|
|
238
|
+
linkedBehaviorId: parsed.linkedBehaviorId === undefined
|
|
239
|
+
? current.linkedBehaviorId
|
|
240
|
+
: parsed.linkedBehaviorId
|
|
241
|
+
})
|
|
242
|
+
: current.linkedBehaviorIds;
|
|
243
|
+
validateExistingIds(parsed.linkedGoalIds ?? current.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
|
|
244
|
+
validateExistingIds(parsed.linkedProjectIds ?? current.linkedProjectIds, getProjectById, "project_not_found", "Project");
|
|
245
|
+
validateExistingIds(parsed.linkedTaskIds ?? current.linkedTaskIds, getTaskById, "task_not_found", "Task");
|
|
246
|
+
validateExistingIds(parsed.linkedValueIds ?? current.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
|
|
247
|
+
validateExistingIds(parsed.linkedPatternIds ?? current.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
|
|
248
|
+
validateExistingIds(nextLinkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
|
|
249
|
+
validateExistingIds(parsed.linkedBeliefIds ?? current.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
|
|
250
|
+
validateExistingIds(parsed.linkedModeIds ?? current.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
|
|
251
|
+
validateExistingIds(parsed.linkedReportIds ?? current.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
|
|
252
|
+
return runInTransaction(() => {
|
|
253
|
+
const updatedAt = new Date().toISOString();
|
|
254
|
+
getDatabase()
|
|
255
|
+
.prepare(`UPDATE habits
|
|
256
|
+
SET title = ?, description = ?, status = ?, polarity = ?, frequency = ?, target_count = ?,
|
|
257
|
+
week_days_json = ?, linked_goal_ids_json = ?, linked_project_ids_json = ?, linked_task_ids_json = ?,
|
|
258
|
+
linked_value_ids_json = ?, linked_pattern_ids_json = ?, linked_behavior_ids_json = ?,
|
|
259
|
+
linked_belief_ids_json = ?, linked_mode_ids_json = ?, linked_report_ids_json = ?,
|
|
260
|
+
linked_behavior_id = ?, reward_xp = ?, penalty_xp = ?, updated_at = ?
|
|
261
|
+
WHERE id = ?`)
|
|
262
|
+
.run(parsed.title ?? current.title, parsed.description ?? current.description, parsed.status ?? current.status, parsed.polarity ?? current.polarity, parsed.frequency ?? current.frequency, parsed.targetCount ?? current.targetCount, JSON.stringify(parsed.weekDays ?? current.weekDays), JSON.stringify(parsed.linkedGoalIds ?? current.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds ?? current.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds ?? current.linkedTaskIds), JSON.stringify(parsed.linkedValueIds ?? current.linkedValueIds), JSON.stringify(parsed.linkedPatternIds ?? current.linkedPatternIds), JSON.stringify(nextLinkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds ?? current.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds ?? current.linkedModeIds), JSON.stringify(parsed.linkedReportIds ?? current.linkedReportIds), nextLinkedBehaviorIds[0] ?? null, parsed.rewardXp ?? current.rewardXp, parsed.penaltyXp ?? current.penaltyXp, updatedAt, habitId);
|
|
263
|
+
const habit = getHabitById(habitId);
|
|
264
|
+
if (activity) {
|
|
265
|
+
recordActivityEvent({
|
|
266
|
+
entityType: "habit",
|
|
267
|
+
entityId: habit.id,
|
|
268
|
+
eventType: "habit_updated",
|
|
269
|
+
title: `Habit updated: ${habit.title}`,
|
|
270
|
+
description: "Habit settings and recurrence were updated.",
|
|
271
|
+
actor: activity.actor ?? null,
|
|
272
|
+
source: activity.source,
|
|
273
|
+
metadata: {
|
|
274
|
+
polarity: habit.polarity,
|
|
275
|
+
frequency: habit.frequency,
|
|
276
|
+
targetCount: habit.targetCount
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return habit;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
export function deleteHabit(habitId, activity) {
|
|
284
|
+
const current = getHabitById(habitId);
|
|
285
|
+
if (!current) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
return runInTransaction(() => {
|
|
289
|
+
getDatabase().prepare(`DELETE FROM habits WHERE id = ?`).run(habitId);
|
|
290
|
+
if (activity) {
|
|
291
|
+
recordActivityEvent({
|
|
292
|
+
entityType: "habit",
|
|
293
|
+
entityId: current.id,
|
|
294
|
+
eventType: "habit_deleted",
|
|
295
|
+
title: `Habit deleted: ${current.title}`,
|
|
296
|
+
description: "Habit removed from Forge.",
|
|
297
|
+
actor: activity.actor ?? null,
|
|
298
|
+
source: activity.source,
|
|
299
|
+
metadata: {
|
|
300
|
+
polarity: current.polarity,
|
|
301
|
+
frequency: current.frequency
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return current;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
export function createHabitCheckIn(habitId, input, activity) {
|
|
309
|
+
const habit = getHabitById(habitId);
|
|
310
|
+
if (!habit) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const parsed = createHabitCheckInSchema.parse(input);
|
|
314
|
+
return runInTransaction(() => {
|
|
315
|
+
const existing = getDatabase()
|
|
316
|
+
.prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
|
|
317
|
+
FROM habit_check_ins
|
|
318
|
+
WHERE habit_id = ? AND date_key = ?`)
|
|
319
|
+
.get(habitId, parsed.dateKey);
|
|
320
|
+
const reward = recordHabitCheckInReward(habit, parsed.status, parsed.dateKey, activity ?? { source: "ui", actor: null });
|
|
321
|
+
const now = new Date().toISOString();
|
|
322
|
+
if (existing) {
|
|
323
|
+
getDatabase()
|
|
324
|
+
.prepare(`UPDATE habit_check_ins
|
|
325
|
+
SET status = ?, note = ?, delta_xp = ?, updated_at = ?
|
|
326
|
+
WHERE id = ?`)
|
|
327
|
+
.run(parsed.status, parsed.note, reward.deltaXp, now, existing.id);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
getDatabase()
|
|
331
|
+
.prepare(`INSERT INTO habit_check_ins (id, habit_id, date_key, status, note, delta_xp, created_at, updated_at)
|
|
332
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
333
|
+
.run(`hci_${randomUUID().replaceAll("-", "").slice(0, 10)}`, habitId, parsed.dateKey, parsed.status, parsed.note, reward.deltaXp, now, now);
|
|
334
|
+
}
|
|
335
|
+
recordActivityEvent({
|
|
336
|
+
entityType: "habit",
|
|
337
|
+
entityId: habit.id,
|
|
338
|
+
eventType: parsed.status === "done" ? "habit_done" : "habit_missed",
|
|
339
|
+
title: `${parsed.status === "done" ? "Habit completed" : "Habit missed"}: ${habit.title}`,
|
|
340
|
+
description: habit.polarity === "positive"
|
|
341
|
+
? parsed.status === "done"
|
|
342
|
+
? "Positive habit logged as completed."
|
|
343
|
+
: "Positive habit logged as missed."
|
|
344
|
+
: parsed.status === "done"
|
|
345
|
+
? "Negative habit logged as performed."
|
|
346
|
+
: "Negative habit logged as resisted.",
|
|
347
|
+
actor: activity?.actor ?? null,
|
|
348
|
+
source: activity?.source ?? "ui",
|
|
349
|
+
metadata: {
|
|
350
|
+
dateKey: parsed.dateKey,
|
|
351
|
+
status: parsed.status,
|
|
352
|
+
polarity: habit.polarity,
|
|
353
|
+
deltaXp: reward.deltaXp
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return getHabitById(habitId);
|
|
357
|
+
});
|
|
358
|
+
}
|