forge-openclaw-plugin 0.2.47 → 0.2.48
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 -5
- package/dist/assets/{index-BejDHw1R.js → index-Bv9FWWsZ.js} +44 -44
- package/dist/assets/index-Bv9FWWsZ.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/openclaw/api-client.js +15 -1
- package/dist/openclaw/tools.js +1 -1
- package/dist/server/server/src/app.js +20 -11
- package/dist/server/server/src/health-workout-adapters.js +465 -0
- package/dist/server/server/src/health.js +134 -9
- package/dist/server/server/src/openapi.js +14 -0
- package/dist/server/server/src/repositories/habits.js +62 -25
- package/dist/server/server/src/types.js +9 -6
- package/dist/server/server/src/watch-mobile.js +33 -21
- package/dist/server/src/lib/date-keys.js +21 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/forge-openclaw/SKILL.md +3 -1
- package/skills/forge-openclaw/entity_conversation_playbooks.md +26 -8
- package/skills/forge-openclaw/psyche_entity_playbooks.md +3 -0
- package/dist/assets/index-BejDHw1R.js.map +0 -1
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
4
|
import { HttpError } from "./errors.js";
|
|
5
|
+
import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
|
|
5
6
|
import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
|
|
6
7
|
import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
|
|
7
8
|
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
@@ -235,6 +236,11 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
235
236
|
averageHeartRate: z.number().nonnegative().nullable().optional(),
|
|
236
237
|
maxHeartRate: z.number().nonnegative().nullable().optional(),
|
|
237
238
|
sourceDevice: z.string().trim().default("Apple Health"),
|
|
239
|
+
sourceSystem: z.string().trim().min(1).default("apple_health"),
|
|
240
|
+
sourceBundleIdentifier: z.string().trim().optional(),
|
|
241
|
+
sourceProductType: z.string().trim().optional(),
|
|
242
|
+
activity: workoutActivityDescriptorSchema.optional(),
|
|
243
|
+
details: workoutDetailsSchema.optional(),
|
|
238
244
|
links: z.array(healthLinkSchema).default([]),
|
|
239
245
|
annotations: workoutAnnotationSchema.partial().default({})
|
|
240
246
|
}))
|
|
@@ -766,6 +772,15 @@ function mapSleepRawLog(row) {
|
|
|
766
772
|
};
|
|
767
773
|
}
|
|
768
774
|
function mapWorkoutSession(row) {
|
|
775
|
+
const provenance = safeJsonParse(row.provenance_json, {});
|
|
776
|
+
const derived = safeJsonParse(row.derived_json, {});
|
|
777
|
+
const presentation = buildWorkoutSessionPresentation({
|
|
778
|
+
source: row.source,
|
|
779
|
+
sourceType: row.source_type,
|
|
780
|
+
workoutType: row.workout_type,
|
|
781
|
+
provenance,
|
|
782
|
+
derived
|
|
783
|
+
});
|
|
769
784
|
return {
|
|
770
785
|
id: row.id,
|
|
771
786
|
externalUid: row.external_uid,
|
|
@@ -773,7 +788,15 @@ function mapWorkoutSession(row) {
|
|
|
773
788
|
userId: row.user_id,
|
|
774
789
|
source: row.source,
|
|
775
790
|
sourceType: row.source_type,
|
|
776
|
-
|
|
791
|
+
sourceSystem: presentation.sourceSystem,
|
|
792
|
+
sourceBundleIdentifier: presentation.sourceBundleIdentifier,
|
|
793
|
+
sourceProductType: presentation.sourceProductType,
|
|
794
|
+
workoutType: presentation.workoutType,
|
|
795
|
+
workoutTypeLabel: presentation.workoutTypeLabel,
|
|
796
|
+
activityFamily: presentation.activityFamily,
|
|
797
|
+
activityFamilyLabel: presentation.activityFamilyLabel,
|
|
798
|
+
activity: presentation.activity,
|
|
799
|
+
details: presentation.details,
|
|
777
800
|
sourceDevice: row.source_device,
|
|
778
801
|
startedAt: row.started_at,
|
|
779
802
|
endedAt: row.ended_at,
|
|
@@ -794,8 +817,8 @@ function mapWorkoutSession(row) {
|
|
|
794
817
|
links: safeJsonParse(row.links_json, []),
|
|
795
818
|
tags: safeJsonParse(row.tags_json, []),
|
|
796
819
|
annotations: safeJsonParse(row.annotations_json, {}),
|
|
797
|
-
provenance
|
|
798
|
-
derived
|
|
820
|
+
provenance,
|
|
821
|
+
derived,
|
|
799
822
|
generatedFromHabitId: row.generated_from_habit_id,
|
|
800
823
|
generatedFromCheckInId: row.generated_from_check_in_id,
|
|
801
824
|
reconciliationStatus: row.reconciliation_status,
|
|
@@ -1904,10 +1927,20 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
1904
1927
|
.get(pairing.user_id, input.externalUid);
|
|
1905
1928
|
const annotations = workoutAnnotationSchema.parse(input.annotations ?? {});
|
|
1906
1929
|
const now = nowIso();
|
|
1930
|
+
const basePersistenceSeed = buildWorkoutSessionPersistenceSeed({
|
|
1931
|
+
source: "apple_health",
|
|
1932
|
+
sourceType: "healthkit",
|
|
1933
|
+
workoutType: input.workoutType,
|
|
1934
|
+
sourceSystem: input.sourceSystem,
|
|
1935
|
+
sourceBundleIdentifier: input.sourceBundleIdentifier,
|
|
1936
|
+
sourceProductType: input.sourceProductType,
|
|
1937
|
+
activity: input.activity,
|
|
1938
|
+
details: input.details
|
|
1939
|
+
});
|
|
1907
1940
|
const matchedGenerated = existing ??
|
|
1908
1941
|
findMatchingGeneratedWorkout({
|
|
1909
1942
|
userId: pairing.user_id,
|
|
1910
|
-
workoutType:
|
|
1943
|
+
workoutType: basePersistenceSeed.activity.canonicalKey,
|
|
1911
1944
|
startedAt: input.startedAt,
|
|
1912
1945
|
endedAt: input.endedAt
|
|
1913
1946
|
});
|
|
@@ -1915,6 +1948,26 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
1915
1948
|
const existingLinks = safeJsonParse(matchedGenerated.links_json, []);
|
|
1916
1949
|
const existingTags = safeJsonParse(matchedGenerated.tags_json, []);
|
|
1917
1950
|
const existingAnnotations = safeJsonParse(matchedGenerated.annotations_json, {});
|
|
1951
|
+
const existingProvenance = safeJsonParse(matchedGenerated.provenance_json, {});
|
|
1952
|
+
const existingDerived = safeJsonParse(matchedGenerated.derived_json, {});
|
|
1953
|
+
const persistenceSeed = buildWorkoutSessionPersistenceSeed({
|
|
1954
|
+
source: "apple_health",
|
|
1955
|
+
sourceType: matchedGenerated.generated_from_habit_id
|
|
1956
|
+
? "reconciled"
|
|
1957
|
+
: "healthkit",
|
|
1958
|
+
workoutType: input.workoutType,
|
|
1959
|
+
sourceSystem: input.sourceSystem,
|
|
1960
|
+
sourceBundleIdentifier: input.sourceBundleIdentifier ??
|
|
1961
|
+
(typeof existingProvenance.sourceBundleIdentifier === "string"
|
|
1962
|
+
? existingProvenance.sourceBundleIdentifier
|
|
1963
|
+
: null),
|
|
1964
|
+
sourceProductType: input.sourceProductType ??
|
|
1965
|
+
(typeof existingProvenance.sourceProductType === "string"
|
|
1966
|
+
? existingProvenance.sourceProductType
|
|
1967
|
+
: null),
|
|
1968
|
+
activity: input.activity ?? existingDerived.activity ?? existingProvenance.activity,
|
|
1969
|
+
details: input.details ?? existingDerived.details ?? existingProvenance.details
|
|
1970
|
+
});
|
|
1918
1971
|
const mergedLinks = mergeHealthLinks(existingLinks, input.links, annotations.links);
|
|
1919
1972
|
const mergedTags = mergeStringLists(existingTags, annotations.tags);
|
|
1920
1973
|
const nextSubjectiveEffort = matchedGenerated.subjective_effort ?? annotations.subjectiveEffort ?? null;
|
|
@@ -1945,12 +1998,21 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
1945
1998
|
reconciliation_status = ?, updated_at = ?
|
|
1946
1999
|
WHERE id = ?`)
|
|
1947
2000
|
.run(input.externalUid, pairing.id, matchedGenerated.generated_from_habit_id ? "reconciled" : "healthkit", input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, nextSubjectiveEffort, nextMoodBefore, nextMoodAfter, nextMeaningText, nextPlannedContext, nextSocialContext, JSON.stringify(mergedLinks), JSON.stringify(mergedTags), JSON.stringify(nextAnnotations), JSON.stringify({
|
|
2001
|
+
...existingProvenance,
|
|
1948
2002
|
importedVia: "ios_companion",
|
|
1949
2003
|
pairingSessionId: pairing.id,
|
|
2004
|
+
sourceSystem: persistenceSeed.sourceSystem,
|
|
2005
|
+
sourceBundleIdentifier: persistenceSeed.sourceBundleIdentifier,
|
|
2006
|
+
sourceProductType: persistenceSeed.sourceProductType,
|
|
2007
|
+
activity: persistenceSeed.activity,
|
|
2008
|
+
details: persistenceSeed.details,
|
|
1950
2009
|
mergedWithGenerated: matchedGenerated.generated_from_habit_id !== null,
|
|
1951
2010
|
priorSource: matchedGenerated.source,
|
|
1952
2011
|
updatedAt: now
|
|
1953
2012
|
}), JSON.stringify({
|
|
2013
|
+
...existingDerived,
|
|
2014
|
+
activity: persistenceSeed.activity,
|
|
2015
|
+
details: persistenceSeed.details,
|
|
1954
2016
|
paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
|
|
1955
2017
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
1956
2018
|
: null
|
|
@@ -1972,11 +2034,18 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
1972
2034
|
provenance_json, derived_json, reconciliation_status, created_at, updated_at
|
|
1973
2035
|
)
|
|
1974
2036
|
VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
|
|
1975
|
-
.run(id, input.externalUid, pairing.id, pairing.user_id,
|
|
2037
|
+
.run(id, input.externalUid, pairing.id, pairing.user_id, basePersistenceSeed.activity.canonicalKey, input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(input.links), JSON.stringify(annotations.tags), JSON.stringify(annotations), JSON.stringify({
|
|
1976
2038
|
importedVia: "ios_companion",
|
|
1977
2039
|
pairingSessionId: pairing.id,
|
|
2040
|
+
sourceSystem: basePersistenceSeed.sourceSystem,
|
|
2041
|
+
sourceBundleIdentifier: basePersistenceSeed.sourceBundleIdentifier,
|
|
2042
|
+
sourceProductType: basePersistenceSeed.sourceProductType,
|
|
2043
|
+
activity: basePersistenceSeed.activity,
|
|
2044
|
+
details: basePersistenceSeed.details,
|
|
1978
2045
|
createdAt: now
|
|
1979
2046
|
}), JSON.stringify({
|
|
2047
|
+
activity: basePersistenceSeed.activity,
|
|
2048
|
+
details: basePersistenceSeed.details,
|
|
1980
2049
|
paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
|
|
1981
2050
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
1982
2051
|
: null
|
|
@@ -2814,6 +2883,8 @@ export function getFitnessViewData(userIds) {
|
|
|
2814
2883
|
habitGeneratedSessionCount: recent.filter((session) => session.sourceType === "habit_generated").length,
|
|
2815
2884
|
reconciledSessionCount: recent.filter((session) => session.reconciliationStatus === "merged").length,
|
|
2816
2885
|
topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
|
|
2886
|
+
topWorkoutTypeLabel: recent.find((session) => session.workoutType === orderedWorkoutTypes[0]?.[0])
|
|
2887
|
+
?.workoutTypeLabel ?? null,
|
|
2817
2888
|
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
|
|
2818
2889
|
},
|
|
2819
2890
|
weeklyTrend: weekly
|
|
@@ -2821,12 +2892,21 @@ export function getFitnessViewData(userIds) {
|
|
|
2821
2892
|
id: session.id,
|
|
2822
2893
|
dateKey: dayKey(session.startedAt),
|
|
2823
2894
|
workoutType: session.workoutType,
|
|
2895
|
+
workoutTypeLabel: session.workoutTypeLabel,
|
|
2896
|
+
activityFamily: session.activityFamily,
|
|
2897
|
+
activityFamilyLabel: session.activityFamilyLabel,
|
|
2824
2898
|
durationMinutes: Math.round(session.durationSeconds / 60),
|
|
2825
2899
|
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
|
|
2826
2900
|
}))
|
|
2827
2901
|
.reverse(),
|
|
2828
2902
|
typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
|
|
2829
2903
|
workoutType,
|
|
2904
|
+
workoutTypeLabel: recent.find((session) => session.workoutType === workoutType)?.workoutTypeLabel ??
|
|
2905
|
+
workoutType,
|
|
2906
|
+
activityFamily: recent.find((session) => session.workoutType === workoutType)?.activityFamily ??
|
|
2907
|
+
"other",
|
|
2908
|
+
activityFamilyLabel: recent.find((session) => session.workoutType === workoutType)
|
|
2909
|
+
?.activityFamilyLabel ?? "Other",
|
|
2830
2910
|
sessionCount: metrics.sessionCount,
|
|
2831
2911
|
totalMinutes: metrics.totalMinutes,
|
|
2832
2912
|
energyKcal: metrics.energyKcal
|
|
@@ -3180,6 +3260,11 @@ export function createWorkoutSession(input, activity) {
|
|
|
3180
3260
|
tags: parsed.tags,
|
|
3181
3261
|
links: parsed.links
|
|
3182
3262
|
};
|
|
3263
|
+
const persistenceSeed = buildWorkoutSessionPersistenceSeed({
|
|
3264
|
+
source: parsed.source,
|
|
3265
|
+
sourceType: parsed.sourceType,
|
|
3266
|
+
workoutType: parsed.workoutType
|
|
3267
|
+
});
|
|
3183
3268
|
getDatabase()
|
|
3184
3269
|
.prepare(`INSERT INTO health_workout_sessions (
|
|
3185
3270
|
id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
|
|
@@ -3189,16 +3274,21 @@ export function createWorkoutSession(input, activity) {
|
|
|
3189
3274
|
provenance_json, derived_json, reconciliation_status, created_at, updated_at
|
|
3190
3275
|
)
|
|
3191
3276
|
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
|
|
3192
|
-
.run(id, externalUid, userId, parsed.source, parsed.sourceType,
|
|
3277
|
+
.run(id, externalUid, userId, parsed.source, parsed.sourceType, persistenceSeed.activity.canonicalKey, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, durationSeconds, parsed.activeEnergyKcal ?? null, parsed.totalEnergyKcal ?? null, parsed.distanceMeters ?? null, parsed.stepCount ?? null, parsed.exerciseMinutes ?? null, parsed.averageHeartRate ?? null, parsed.maxHeartRate ?? null, parsed.subjectiveEffort ?? null, parsed.moodBefore, parsed.moodAfter, parsed.meaningText, parsed.plannedContext, parsed.socialContext, JSON.stringify(parsed.links), JSON.stringify(parsed.tags), JSON.stringify(annotations), JSON.stringify({
|
|
3193
3278
|
manualEntry: true,
|
|
3194
3279
|
entryMode: "local",
|
|
3195
3280
|
source: parsed.source,
|
|
3196
3281
|
sourceType: parsed.sourceType,
|
|
3282
|
+
sourceSystem: persistenceSeed.sourceSystem,
|
|
3283
|
+
activity: persistenceSeed.activity,
|
|
3284
|
+
details: persistenceSeed.details,
|
|
3197
3285
|
sourceDevice: parsed.sourceDevice,
|
|
3198
3286
|
actor: activity?.actor ?? null,
|
|
3199
3287
|
createdAt: now,
|
|
3200
3288
|
...parsed.provenance
|
|
3201
3289
|
}), JSON.stringify({
|
|
3290
|
+
activity: persistenceSeed.activity,
|
|
3291
|
+
details: persistenceSeed.details,
|
|
3202
3292
|
paceMetersPerMinute: parsed.distanceMeters && parsed.exerciseMinutes
|
|
3203
3293
|
? Number((parsed.distanceMeters / parsed.exerciseMinutes).toFixed(2))
|
|
3204
3294
|
: null
|
|
@@ -3268,8 +3358,25 @@ export function updateWorkoutSession(workoutId, patch, activity) {
|
|
|
3268
3358
|
links
|
|
3269
3359
|
};
|
|
3270
3360
|
const currentProvenance = safeJsonParse(current.provenance_json, {});
|
|
3361
|
+
const currentDerived = safeJsonParse(current.derived_json, {});
|
|
3271
3362
|
const nextExerciseMinutes = parsed.exerciseMinutes ?? current.exercise_minutes;
|
|
3272
3363
|
const nextDistanceMeters = parsed.distanceMeters ?? current.distance_meters;
|
|
3364
|
+
const persistenceSeed = buildWorkoutSessionPersistenceSeed({
|
|
3365
|
+
source: parsed.source ?? current.source,
|
|
3366
|
+
sourceType: parsed.sourceType ?? current.source_type,
|
|
3367
|
+
workoutType: parsed.workoutType ?? current.workout_type,
|
|
3368
|
+
sourceSystem: typeof currentProvenance.sourceSystem === "string"
|
|
3369
|
+
? currentProvenance.sourceSystem
|
|
3370
|
+
: null,
|
|
3371
|
+
sourceBundleIdentifier: typeof currentProvenance.sourceBundleIdentifier === "string"
|
|
3372
|
+
? currentProvenance.sourceBundleIdentifier
|
|
3373
|
+
: null,
|
|
3374
|
+
sourceProductType: typeof currentProvenance.sourceProductType === "string"
|
|
3375
|
+
? currentProvenance.sourceProductType
|
|
3376
|
+
: null,
|
|
3377
|
+
activity: currentDerived.activity ?? currentProvenance.activity,
|
|
3378
|
+
details: currentDerived.details ?? currentProvenance.details
|
|
3379
|
+
});
|
|
3273
3380
|
getDatabase()
|
|
3274
3381
|
.prepare(`UPDATE health_workout_sessions
|
|
3275
3382
|
SET external_uid = ?, source = ?, source_type = ?, workout_type = ?, source_device = ?,
|
|
@@ -3279,12 +3386,20 @@ export function updateWorkoutSession(workoutId, patch, activity) {
|
|
|
3279
3386
|
social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?,
|
|
3280
3387
|
derived_json = ?, updated_at = ?
|
|
3281
3388
|
WHERE id = ?`)
|
|
3282
|
-
.run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type,
|
|
3389
|
+
.run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, persistenceSeed.activity.canonicalKey, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, durationSeconds, parsed.activeEnergyKcal ?? current.active_energy_kcal, parsed.totalEnergyKcal ?? current.total_energy_kcal, nextDistanceMeters, parsed.stepCount ?? current.step_count, nextExerciseMinutes, parsed.averageHeartRate ?? current.average_heart_rate, parsed.maxHeartRate ?? current.max_heart_rate, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(links), JSON.stringify(tags), JSON.stringify(annotations), JSON.stringify({
|
|
3283
3390
|
...currentProvenance,
|
|
3284
3391
|
...(parsed.provenance ?? {}),
|
|
3392
|
+
sourceSystem: persistenceSeed.sourceSystem,
|
|
3393
|
+
sourceBundleIdentifier: persistenceSeed.sourceBundleIdentifier,
|
|
3394
|
+
sourceProductType: persistenceSeed.sourceProductType,
|
|
3395
|
+
activity: persistenceSeed.activity,
|
|
3396
|
+
details: persistenceSeed.details,
|
|
3285
3397
|
updatedAt: now,
|
|
3286
3398
|
updatedByActor: activity?.actor ?? null
|
|
3287
3399
|
}), JSON.stringify({
|
|
3400
|
+
...currentDerived,
|
|
3401
|
+
activity: persistenceSeed.activity,
|
|
3402
|
+
details: persistenceSeed.details,
|
|
3288
3403
|
paceMetersPerMinute: nextDistanceMeters && nextExerciseMinutes
|
|
3289
3404
|
? Number((nextDistanceMeters / nextExerciseMinutes).toFixed(2))
|
|
3290
3405
|
: null
|
|
@@ -3441,6 +3556,11 @@ export function createGeneratedWorkoutFromHabit(args) {
|
|
|
3441
3556
|
}
|
|
3442
3557
|
const now = nowIso();
|
|
3443
3558
|
const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
3559
|
+
const persistenceSeed = buildWorkoutSessionPersistenceSeed({
|
|
3560
|
+
source: "forge_habit",
|
|
3561
|
+
sourceType: "habit_generated",
|
|
3562
|
+
workoutType: template.workoutType
|
|
3563
|
+
});
|
|
3444
3564
|
getDatabase()
|
|
3445
3565
|
.prepare(`INSERT INTO health_workout_sessions (
|
|
3446
3566
|
id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
|
|
@@ -3448,7 +3568,7 @@ export function createGeneratedWorkoutFromHabit(args) {
|
|
|
3448
3568
|
derived_json, generated_from_habit_id, generated_from_check_in_id, reconciliation_status, created_at, updated_at
|
|
3449
3569
|
)
|
|
3450
3570
|
VALUES (?, ?, NULL, ?, 'forge_habit', 'habit_generated', ?, 'Habit automation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'awaiting_import_match', ?, ?)`)
|
|
3451
|
-
.run(id, `habit_${args.checkInId}`, args.userId,
|
|
3571
|
+
.run(id, `habit_${args.checkInId}`, args.userId, persistenceSeed.activity.canonicalKey, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
|
|
3452
3572
|
...(template.links ?? []),
|
|
3453
3573
|
...(args.linkedEntities ?? [])
|
|
3454
3574
|
]), JSON.stringify(template.tags), JSON.stringify({
|
|
@@ -3457,8 +3577,13 @@ export function createGeneratedWorkoutFromHabit(args) {
|
|
|
3457
3577
|
}), JSON.stringify({
|
|
3458
3578
|
generatedFrom: "habit_completion",
|
|
3459
3579
|
habitId: args.habitId,
|
|
3460
|
-
checkInId: args.checkInId
|
|
3580
|
+
checkInId: args.checkInId,
|
|
3581
|
+
sourceSystem: persistenceSeed.sourceSystem,
|
|
3582
|
+
activity: persistenceSeed.activity,
|
|
3583
|
+
details: persistenceSeed.details
|
|
3461
3584
|
}), JSON.stringify({
|
|
3585
|
+
activity: persistenceSeed.activity,
|
|
3586
|
+
details: persistenceSeed.details,
|
|
3462
3587
|
xpReward: template.xpReward
|
|
3463
3588
|
}), args.habitId, args.checkInId, now, now);
|
|
3464
3589
|
recordActivityEvent({
|
|
@@ -4055,6 +4055,12 @@ export function buildOpenApiDocument() {
|
|
|
4055
4055
|
"userId",
|
|
4056
4056
|
"source",
|
|
4057
4057
|
"sourceType",
|
|
4058
|
+
"sourceSystem",
|
|
4059
|
+
"workoutTypeLabel",
|
|
4060
|
+
"activityFamily",
|
|
4061
|
+
"activityFamilyLabel",
|
|
4062
|
+
"activity",
|
|
4063
|
+
"details",
|
|
4058
4064
|
"workoutType",
|
|
4059
4065
|
"sourceDevice",
|
|
4060
4066
|
"startedAt",
|
|
@@ -4091,7 +4097,15 @@ export function buildOpenApiDocument() {
|
|
|
4091
4097
|
userId: { type: "string" },
|
|
4092
4098
|
source: { type: "string" },
|
|
4093
4099
|
sourceType: { type: "string" },
|
|
4100
|
+
sourceSystem: { type: "string" },
|
|
4101
|
+
sourceBundleIdentifier: nullable({ type: "string" }),
|
|
4102
|
+
sourceProductType: nullable({ type: "string" }),
|
|
4094
4103
|
workoutType: { type: "string" },
|
|
4104
|
+
workoutTypeLabel: { type: "string" },
|
|
4105
|
+
activityFamily: { type: "string" },
|
|
4106
|
+
activityFamilyLabel: { type: "string" },
|
|
4107
|
+
activity: { type: "object", additionalProperties: true },
|
|
4108
|
+
details: { type: "object", additionalProperties: true },
|
|
4095
4109
|
sourceDevice: { type: "string" },
|
|
4096
4110
|
startedAt: { type: "string", format: "date-time" },
|
|
4097
4111
|
endedAt: { type: "string", format: "date-time" },
|
|
@@ -11,8 +11,9 @@ import { recordActivityEvent } from "./activity-events.js";
|
|
|
11
11
|
import { filterDeletedEntities, filterDeletedIds, isEntityDeleted } from "./deleted-entities.js";
|
|
12
12
|
import { recordHabitCheckInReward, reverseLatestHabitCheckInReward } from "./rewards.js";
|
|
13
13
|
import { createHabitCheckInSchema, createHabitSchema, habitCheckInSchema, habitSchema, updateHabitSchema } from "../types.js";
|
|
14
|
+
import { formatLocalDateKey } from "../../../src/lib/date-keys.js";
|
|
14
15
|
function todayKey(now = new Date()) {
|
|
15
|
-
return now
|
|
16
|
+
return formatLocalDateKey(now);
|
|
16
17
|
}
|
|
17
18
|
function parseWeekDays(raw) {
|
|
18
19
|
const parsed = JSON.parse(raw);
|
|
@@ -87,25 +88,25 @@ function calculateStreak(habit, checkIns, now = new Date()) {
|
|
|
87
88
|
statusByDate.set(checkIn.dateKey, checkIn.status);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
const isScheduledOn = (date) => habit.frequency === "daily" || habit.weekDays.includes(date.
|
|
91
|
-
const toDateKey = (date) => date
|
|
92
|
-
const
|
|
91
|
+
const isScheduledOn = (date) => habit.frequency === "daily" || habit.weekDays.includes(date.getDay());
|
|
92
|
+
const toDateKey = (date) => formatLocalDateKey(date);
|
|
93
|
+
const atLocalDayStart = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
93
94
|
const previousScheduledDate = (date) => {
|
|
94
|
-
const cursor =
|
|
95
|
+
const cursor = atLocalDayStart(date);
|
|
95
96
|
do {
|
|
96
|
-
cursor.
|
|
97
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
97
98
|
} while (!isScheduledOn(cursor));
|
|
98
99
|
return cursor;
|
|
99
100
|
};
|
|
100
|
-
const
|
|
101
|
-
const start =
|
|
102
|
-
const offset = (start.
|
|
103
|
-
start.
|
|
101
|
+
const startOfLocalWeek = (date) => {
|
|
102
|
+
const start = atLocalDayStart(date);
|
|
103
|
+
const offset = (start.getDay() + 6) % 7;
|
|
104
|
+
start.setDate(start.getDate() - offset);
|
|
104
105
|
return start;
|
|
105
106
|
};
|
|
106
|
-
const
|
|
107
|
-
const start =
|
|
108
|
-
start.
|
|
107
|
+
const previousLocalWeek = (date) => {
|
|
108
|
+
const start = startOfLocalWeek(date);
|
|
109
|
+
start.setDate(start.getDate() - 7);
|
|
109
110
|
return start;
|
|
110
111
|
};
|
|
111
112
|
const alignedStatusOn = (date) => {
|
|
@@ -113,7 +114,7 @@ function calculateStreak(habit, checkIns, now = new Date()) {
|
|
|
113
114
|
return status ? isAligned(habit, { status }) : false;
|
|
114
115
|
};
|
|
115
116
|
if (habit.frequency === "daily") {
|
|
116
|
-
const today =
|
|
117
|
+
const today = atLocalDayStart(now);
|
|
117
118
|
let cursor = isScheduledOn(today) && !statusByDate.has(toDateKey(today))
|
|
118
119
|
? previousScheduledDate(today)
|
|
119
120
|
: today;
|
|
@@ -128,21 +129,21 @@ function calculateStreak(habit, checkIns, now = new Date()) {
|
|
|
128
129
|
let count = 0;
|
|
129
130
|
for (let offset = 0; offset < 7; offset += 1) {
|
|
130
131
|
const day = new Date(weekStart);
|
|
131
|
-
day.
|
|
132
|
+
day.setDate(weekStart.getDate() + offset);
|
|
132
133
|
if (isScheduledOn(day) && alignedStatusOn(day)) {
|
|
133
134
|
count += 1;
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
return count;
|
|
137
138
|
};
|
|
138
|
-
const currentWeekStart =
|
|
139
|
+
const currentWeekStart = startOfLocalWeek(now);
|
|
139
140
|
let cursor = alignedCountForWeek(currentWeekStart) >= habit.targetCount
|
|
140
141
|
? currentWeekStart
|
|
141
|
-
:
|
|
142
|
+
: previousLocalWeek(currentWeekStart);
|
|
142
143
|
let streak = 0;
|
|
143
144
|
while (alignedCountForWeek(cursor) >= habit.targetCount) {
|
|
144
145
|
streak += 1;
|
|
145
|
-
cursor =
|
|
146
|
+
cursor = previousLocalWeek(cursor);
|
|
146
147
|
}
|
|
147
148
|
return streak;
|
|
148
149
|
}
|
|
@@ -157,7 +158,7 @@ function isHabitDueToday(habit, latestCheckIn, now = new Date()) {
|
|
|
157
158
|
if (habit.frequency === "daily") {
|
|
158
159
|
return true;
|
|
159
160
|
}
|
|
160
|
-
return habit.weekDays.includes(now.
|
|
161
|
+
return habit.weekDays.includes(now.getDay());
|
|
161
162
|
}
|
|
162
163
|
function mapHabit(row, checkIns = listCheckInsForHabit(row.id)) {
|
|
163
164
|
const latestCheckIn = checkIns[0] ?? null;
|
|
@@ -235,21 +236,28 @@ function sortHabits(habits, orderBy) {
|
|
|
235
236
|
const nextHabits = [...habits];
|
|
236
237
|
nextHabits.sort((left, right) => {
|
|
237
238
|
if (orderBy === "name") {
|
|
238
|
-
return (left.title.localeCompare(right.title, undefined, {
|
|
239
|
-
|
|
239
|
+
return (left.title.localeCompare(right.title, undefined, {
|
|
240
|
+
sensitivity: "base"
|
|
241
|
+
}) || compareDateDesc(left.createdAt, right.createdAt));
|
|
240
242
|
}
|
|
241
243
|
if (orderBy === "streak") {
|
|
242
244
|
return (right.streakCount - left.streakCount ||
|
|
243
245
|
Number(right.dueToday) - Number(left.dueToday) ||
|
|
244
|
-
left.title.localeCompare(right.title, undefined, {
|
|
246
|
+
left.title.localeCompare(right.title, undefined, {
|
|
247
|
+
sensitivity: "base"
|
|
248
|
+
}));
|
|
245
249
|
}
|
|
246
250
|
if (orderBy === "created_at") {
|
|
247
251
|
return (compareDateDesc(left.createdAt, right.createdAt) ||
|
|
248
|
-
left.title.localeCompare(right.title, undefined, {
|
|
252
|
+
left.title.localeCompare(right.title, undefined, {
|
|
253
|
+
sensitivity: "base"
|
|
254
|
+
}));
|
|
249
255
|
}
|
|
250
256
|
if (orderBy === "updated_at") {
|
|
251
257
|
return (compareDateDesc(left.updatedAt, right.updatedAt) ||
|
|
252
|
-
left.title.localeCompare(right.title, undefined, {
|
|
258
|
+
left.title.localeCompare(right.title, undefined, {
|
|
259
|
+
sensitivity: "base"
|
|
260
|
+
}));
|
|
253
261
|
}
|
|
254
262
|
return (Number(right.dueToday) - Number(left.dueToday) ||
|
|
255
263
|
compareDateAsc(left.lastCheckInAt, right.lastCheckInAt) ||
|
|
@@ -367,6 +375,32 @@ export function updateHabit(habitId, input, activity) {
|
|
|
367
375
|
return undefined;
|
|
368
376
|
}
|
|
369
377
|
const parsed = updateHabitSchema.parse(input);
|
|
378
|
+
const shouldApplyEntityPatch = parsed.title !== undefined ||
|
|
379
|
+
parsed.description !== undefined ||
|
|
380
|
+
parsed.status !== undefined ||
|
|
381
|
+
parsed.polarity !== undefined ||
|
|
382
|
+
parsed.frequency !== undefined ||
|
|
383
|
+
parsed.targetCount !== undefined ||
|
|
384
|
+
parsed.weekDays !== undefined ||
|
|
385
|
+
parsed.linkedGoalIds !== undefined ||
|
|
386
|
+
parsed.linkedProjectIds !== undefined ||
|
|
387
|
+
parsed.linkedTaskIds !== undefined ||
|
|
388
|
+
parsed.linkedValueIds !== undefined ||
|
|
389
|
+
parsed.linkedPatternIds !== undefined ||
|
|
390
|
+
parsed.linkedBehaviorIds !== undefined ||
|
|
391
|
+
parsed.linkedBeliefIds !== undefined ||
|
|
392
|
+
parsed.linkedModeIds !== undefined ||
|
|
393
|
+
parsed.linkedReportIds !== undefined ||
|
|
394
|
+
parsed.linkedBehaviorId !== undefined ||
|
|
395
|
+
parsed.rewardXp !== undefined ||
|
|
396
|
+
parsed.penaltyXp !== undefined ||
|
|
397
|
+
parsed.generatedHealthEventTemplate !== undefined ||
|
|
398
|
+
parsed.userId !== undefined;
|
|
399
|
+
if (!shouldApplyEntityPatch) {
|
|
400
|
+
return parsed.checkIn
|
|
401
|
+
? createHabitCheckIn(habitId, parsed.checkIn, activity)
|
|
402
|
+
: current;
|
|
403
|
+
}
|
|
370
404
|
const nextLinkedBehaviorIds = parsed.linkedBehaviorIds !== undefined ||
|
|
371
405
|
parsed.linkedBehaviorId !== undefined
|
|
372
406
|
? normalizeLinkedBehaviorIds({
|
|
@@ -385,7 +419,7 @@ export function updateHabit(habitId, input, activity) {
|
|
|
385
419
|
validateExistingIds(parsed.linkedBeliefIds ?? current.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
|
|
386
420
|
validateExistingIds(parsed.linkedModeIds ?? current.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
|
|
387
421
|
validateExistingIds(parsed.linkedReportIds ?? current.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
|
|
388
|
-
|
|
422
|
+
const updatedHabit = runInTransaction(() => {
|
|
389
423
|
const updatedAt = new Date().toISOString();
|
|
390
424
|
getDatabase()
|
|
391
425
|
.prepare(`UPDATE habits
|
|
@@ -419,6 +453,9 @@ export function updateHabit(habitId, input, activity) {
|
|
|
419
453
|
}
|
|
420
454
|
return habit;
|
|
421
455
|
});
|
|
456
|
+
return parsed.checkIn
|
|
457
|
+
? createHabitCheckIn(habitId, parsed.checkIn, activity) ?? updatedHabit
|
|
458
|
+
: updatedHabit;
|
|
422
459
|
}
|
|
423
460
|
export function deleteHabit(habitId, activity) {
|
|
424
461
|
const current = getHabitById(habitId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { LEGACY_WORKBENCH_PORT_KINDS, WORKBENCH_PORT_KINDS, normalizeWorkbenchPortKind } from "../../src/lib/workbench/nodes.js";
|
|
3
|
+
import { formatLocalDateKey } from "../../src/lib/date-keys.js";
|
|
3
4
|
export const taskStatusSchema = z.enum([
|
|
4
5
|
"backlog",
|
|
5
6
|
"focus",
|
|
@@ -3120,6 +3121,12 @@ export const taskMutationShape = {
|
|
|
3120
3121
|
notes: z.array(nestedCreateNoteSchema).default([])
|
|
3121
3122
|
};
|
|
3122
3123
|
export const createTaskSchema = z.object(taskMutationShape);
|
|
3124
|
+
const habitCheckInWriteSchema = z.object({
|
|
3125
|
+
dateKey: dateOnlySchema.default(() => formatLocalDateKey()),
|
|
3126
|
+
status: habitCheckInStatusSchema,
|
|
3127
|
+
note: trimmedString.default(""),
|
|
3128
|
+
description: trimmedString.optional()
|
|
3129
|
+
});
|
|
3123
3130
|
const habitMutationShape = {
|
|
3124
3131
|
title: nonEmptyTrimmedString,
|
|
3125
3132
|
description: trimmedString.default(""),
|
|
@@ -3206,6 +3213,7 @@ export const updateHabitSchema = z
|
|
|
3206
3213
|
linkedBehaviorId: nonEmptyTrimmedString.nullable().optional(),
|
|
3207
3214
|
rewardXp: z.number().int().min(1).max(100).optional(),
|
|
3208
3215
|
penaltyXp: z.number().int().min(1).max(100).optional(),
|
|
3216
|
+
checkIn: habitCheckInWriteSchema.optional(),
|
|
3209
3217
|
generatedHealthEventTemplate: z
|
|
3210
3218
|
.object({
|
|
3211
3219
|
enabled: z.boolean().optional(),
|
|
@@ -3371,12 +3379,7 @@ export const taskRunFinishSchema = z.object({
|
|
|
3371
3379
|
export const taskRunFocusSchema = z.object({
|
|
3372
3380
|
actor: nonEmptyTrimmedString.optional()
|
|
3373
3381
|
});
|
|
3374
|
-
export const createHabitCheckInSchema =
|
|
3375
|
-
dateKey: dateOnlySchema.default(new Date().toISOString().slice(0, 10)),
|
|
3376
|
-
status: habitCheckInStatusSchema,
|
|
3377
|
-
note: trimmedString.default(""),
|
|
3378
|
-
description: trimmedString.optional()
|
|
3379
|
-
});
|
|
3382
|
+
export const createHabitCheckInSchema = habitCheckInWriteSchema;
|
|
3380
3383
|
export const updateSettingsSchema = z.object({
|
|
3381
3384
|
profile: z
|
|
3382
3385
|
.object({
|