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.
@@ -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
- workoutType: row.workout_type,
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: safeJsonParse(row.provenance_json, {}),
798
- derived: safeJsonParse(row.derived_json, {}),
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: input.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, input.workoutType, 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({
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, parsed.workoutType, 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({
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, parsed.workoutType ?? current.workout_type, 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({
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, template.workoutType, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
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.toISOString().slice(0, 10);
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.getUTCDay());
91
- const toDateKey = (date) => date.toISOString().slice(0, 10);
92
- const atUtcDayStart = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
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 = atUtcDayStart(date);
95
+ const cursor = atLocalDayStart(date);
95
96
  do {
96
- cursor.setUTCDate(cursor.getUTCDate() - 1);
97
+ cursor.setDate(cursor.getDate() - 1);
97
98
  } while (!isScheduledOn(cursor));
98
99
  return cursor;
99
100
  };
100
- const startOfUtcWeek = (date) => {
101
- const start = atUtcDayStart(date);
102
- const offset = (start.getUTCDay() + 6) % 7;
103
- start.setUTCDate(start.getUTCDate() - offset);
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 previousUtcWeek = (date) => {
107
- const start = startOfUtcWeek(date);
108
- start.setUTCDate(start.getUTCDate() - 7);
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 = atUtcDayStart(now);
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.setUTCDate(weekStart.getUTCDate() + offset);
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 = startOfUtcWeek(now);
139
+ const currentWeekStart = startOfLocalWeek(now);
139
140
  let cursor = alignedCountForWeek(currentWeekStart) >= habit.targetCount
140
141
  ? currentWeekStart
141
- : previousUtcWeek(currentWeekStart);
142
+ : previousLocalWeek(currentWeekStart);
142
143
  let streak = 0;
143
144
  while (alignedCountForWeek(cursor) >= habit.targetCount) {
144
145
  streak += 1;
145
- cursor = previousUtcWeek(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.getUTCDay());
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, { sensitivity: "base" }) ||
239
- compareDateDesc(left.createdAt, right.createdAt));
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, { sensitivity: "base" }));
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, { sensitivity: "base" }));
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, { sensitivity: "base" }));
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
- return runInTransaction(() => {
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 = z.object({
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({