diff --git a/analytics/README.md b/analytics/README.md index c775d2bde1..b3ddd99415 100644 --- a/analytics/README.md +++ b/analytics/README.md @@ -921,6 +921,14 @@ scopes; a maximum-size scope must use that private-project transport. See the ## Activity recording origin +Dofek treats Kaya sync (`kaya`) and file-import (`kaya-export`) sessions as +initiating activity sources in the [origin selector](macros/activity_origin.sql). +A single Kaya session can establish the primary identity alongside non-initiating +wearables such as WHOOP and their copies. Multiple initiating records, including +Apple Watch recordings, remain unresolved under the existing policy; this does not invent transfer edges or +change sensor selection. The [shared real-engine fixtures](../src/test-fixtures/activity-origin.json) +cover the policy in PostgreSQL and ClickHouse. + The [origin selector](macros/activity_origin.sql) resolves the closest available record using supported source-app and recording-device evidence within an existing activity group. It never consults field priorities or sensor counts. Primary member diff --git a/analytics/macros/activity_origin.sql b/analytics/macros/activity_origin.sql index 4fb38da84e..56944b0318 100644 --- a/analytics/macros/activity_origin.sql +++ b/analytics/macros/activity_origin.sql @@ -99,6 +99,7 @@ origin_graphs AS ( arraySort(groupArrayIf(members.activity_id, members.provider_id = 'apple_health' AND members.source_label IN ('hang ten', 'hang ten training'))) AS hang_ten_ids, arraySort(groupArrayIf(members.activity_id, members.is_watch_recording)) AS apple_watch_ids, arraySort(groupArrayIf(members.activity_id, members.is_native_peloton_recording)) AS native_peloton_ids, + arraySort(groupArrayIf(members.activity_id, members.provider_id IN ('kaya', 'kaya-export'))) AS kaya_ids, max(members.conflicting) AS conflicting, coalesce(any(origin_group_edges.edges), CAST([], 'Array(Tuple(UUID, UUID, String, String))')) AS edges FROM origin_members AS members @@ -130,14 +131,14 @@ origin_decisions AS ( group_id, any(member_ids) AS group_member_ids, any(edges) AS group_edges, - any(arrayConcat(if(length(strong_export_ids) > 0, strong_export_ids, strong_health_ids), hang_ten_ids, apple_watch_ids, native_peloton_ids)) AS initiating_origins, + any(arrayConcat(if(length(strong_export_ids) > 0, strong_export_ids, strong_health_ids), hang_ten_ids, apple_watch_ids, native_peloton_ids, kaya_ids)) AS initiating_origins, max(conflicting) OR max(has(reached, member_id)) AS invalid, arraySort(groupArrayIf(member_id, length(arrayDistinct(arrayConcat([member_id], reached))) = length(member_ids))) AS roots FROM origin_reachability GROUP BY user_id, group_id ), origin_selections AS ( - -- Strong, Hang Ten, Apple Watch, and native Peloton bikes initiate by policy; copy edges remain evidence-only. + -- Kaya, Strong, Hang Ten, Apple Watch, and native Peloton bikes initiate by policy; copy edges remain evidence-only. SELECT *, multiIf( diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index cb28a883a5..f1670fe965 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -1,5 +1,16 @@ # Production Incident Baseline +## 2026-11-01 — Climbing activity card displays a cardio icon + +- **Symptoms / impact:** [Activity 2590bf35](https://dofek.fit/activity/2590bf35-bfea-4b57-ac9c-5c9ad96ff617) displays the lightning/cardio icon beside its Kaya climbing title. +- **Evidence / root cause:** Read-only production queries found four grouped source records: Peloton `cardio`, and Apple Health, Kaya, and WHOOP `climbing`. Postgres `fitness.v_activity` returns `canonical_type = climbing`; ClickHouse `analytics.deduped_activities FINAL` returns `canonical_type = cardio`, `provider_id = peloton`, and representative `23a84fea-8873-4a90-b195-139bb29feecf` (refreshed at 23:38:49 UTC). The ClickHouse raw activity mirror contains all four records with the same source classifications. The [dbt representative ranking](../analytics/models/read_models/deduped_activities.sql) puts sensor coverage ahead of activity specificity, whereas the [Postgres ranking](../drizzle/0168_v_activity_primary_title.sql) prefers specific activity types. The [card](../packages/web/src/components/ActivityCardContent.tsx) passes the resulting activity type to the [shared icon resolver](../packages/training/src/activity-icons.ts), which maps `cardio` to the lightning icon. No failing request or fatal application log was observed; this is an incorrect successful result. +- **Direct fix:** Following the operator's policy decision, the [Kaya origin migration](../drizzle/0174_kaya_activity_origin.sql) and [dbt selector](../analytics/macros/activity_origin.sql) recognize `kaya` and `kaya-export` as initiating sources. Previously Kaya was left incomparable with the WHOOP transfer chain, so primary identity remained unresolved. Web and mobile cards now use the resolved primary type for their icons; the mobile list's visible and accessible type labels use it too. Sensor-bearing representative selection and provenance edges retain their separate responsibilities. +- **PR review follow-up:** The [shared calendar list](../packages/server/src/repositories/activities-calendar-repository.ts) and [overview](../packages/server/src/repositories/activity-overview-repository.ts) now filter and advertise types using primary type with representative fallback. A real ClickHouse test first returned an empty Climbing list for a cardio representative with a climbing primary; it now verifies the card, overview count, dropdown types, and unresolved-origin fallback. Query cache versions change with the new semantics. +- **CI evidence / direct fixture fixes:** [CI run 41365870641](https://github.com/Asherlc/dofek/actions/runs/41365870641) failed `pnpm exec vitest run --project integration --coverage --shard=1/4` and `--shard=4/4` because old field-priority tests expected `Peloton fallback`/`Peloton title` where the new Kaya primary correctly supplies null/`Kaya title`. PostgreSQL and ClickHouse fixtures now distinguish initiating Kaya from unresolved generic sources, preserving field-priority coverage without changing production behavior. Shard 2's first failure expected a 60-minute no-sets week but observed 120 minutes: its shared relative-date fixtures overlapped on the UTC Monday boundary. The [strength regression](../packages/server/src/repositories/strength-repository.integration.test.ts) now owns an isolated database fixture; subsequent duplicate-key errors were retry fallout from that initial assertion. CI recovery awaits the next pushed run. No retries or timeout adjustments were added. +- **Local verification limitations:** Repeating the full unit/mobile suite under workstation contention produced a failure in `src/account-erasure/archive-erasure.test.ts`; the broad run was stopped, and all 25 archive tests passed in isolation. Two older calendar integration setups exceeded the workspace ClickHouse memory limit while materializing legacy fixture views, including after a workspace-only restart. The changed source-tree/filter scope passed all three tests against the real engine, alongside nine strength, eighteen PostgreSQL field-priority, and all twenty-five ClickHouse field-priority cases. The field-priority mutation fixture now owns a unique provider identity per attempt so a failed attempt cannot leave a newer priority version that changes a later attempt's initial state. All 58 focused server unit tests, full lint, and root/server typechecks passed. No memory limit or test deadline was raised. +- **Validation / remaining work:** Tests first reproduced the unresolved Kaya identity in both databases and the cardio icons on both clients. After the fix, all 24,004 unit/mobile tests pass (19 skipped), alongside 155 PostgreSQL/ClickHouse origin tests and 26 full ClickHouse read-model tests. Root/web/mobile typechecks, SQL and migration policy checks, and SQLFluff pass. All 46 local analytics models build successfully. A read-only execution of the updated selector against this activity's actual production mirror records selects Kaya member `e7635331-9681-4d11-85d9-2f34c0f77154`. The first build failed with `Database ingest does not exist` in a newly initialized workspace; the documented `pnpm setup-db` bootstrap resolved that prerequisite, and the identical build passed. Production remains unresolved until deployment and a bounded read-model refresh update the activity. No retries, waits, or resilience changes were added. Browser visual verification was unavailable because no browser was exposed through computer-use tools. +- **Retrospective / proposed improvement:** Comparing the same group in Postgres, the raw mirror, and the serving read model localized the divergence; the shared real-engine fixtures made policy parity executable. Use `diagnose-cdc`, `integration-tests-ready`, and TDD for similar investigations. Proposed runbook addition: inspect primary identity, representative source, and canonical type in each store before treating an incorrect activity icon as a rendering defect, and confirm local schema bootstrap before a full analytics build. + ## 2026-11-01 — Crimpd sign-in rejects a valid token response - **Symptoms / impact:** Connecting Crimpd fails with a generic server error, preventing account sync from web and mobile. diff --git a/drizzle/0174_kaya_activity_origin.sql b/drizzle/0174_kaya_activity_origin.sql new file mode 100644 index 0000000000..c277959f5f --- /dev/null +++ b/drizzle/0174_kaya_activity_origin.sql @@ -0,0 +1,138 @@ +-- Recording provenance and initiating-app assumptions; no metric priorities. +CREATE OR REPLACE FUNCTION fitness.resolve_activity_origin(input_members jsonb) +RETURNS TABLE ( + primary_activity_id uuid, + primary_provider_id text, + primary_source_name text, + origin_candidate_member_ids uuid[], + origin_evidence jsonb +) +LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ +WITH RECURSIVE labels AS ( + SELECT + (member ->> 'id')::uuid AS member_id, + member ->> 'providerId' AS provider_id, + member ->> 'externalId' AS external_id, + copy.provider_id AS copy_provider, + copy.external_id AS copy_external_id, + member ->> 'sourceName' AS source_name, + LOWER(TRIM(COALESCE(member ->> 'sourceName', ''))) AS source_label, + LOWER(TRIM(COALESCE(member -> 'raw' ->> 'sourceBundle', ''))) AS source_bundle, + LOWER(TRIM(COALESCE(member -> 'raw' ->> 'device_name', ''))) AS raw_device_label, + LOWER(TRIM(COALESCE(member -> 'raw' ->> 'platform', ''))) AS platform_label, + LOWER(TRIM(COALESCE(member -> 'raw' ->> 'deviceType', ''))) AS device_type_label + FROM JSONB_ARRAY_ELEMENTS(input_members) AS member + LEFT JOIN LATERAL fitness.activity_copy_identity( + member ->> 'providerId', member ->> 'externalId', member -> 'raw' + ) AS copy ON true +), hints AS ( + SELECT *, + provider_id = 'apple_health' + AND REPLACE(source_label, CHR(160), ' ') ~ '(^|[[:space:]])apple[[:space:]]+watch[[:space:]]*$' + AS is_watch_recording, + provider_id = 'peloton' AND platform_label = 'home_bike' + AND device_type_label IN ('bike', 'home_bike_plus') AS is_native_peloton_recording, + CASE + WHEN provider_id = 'apple_health' THEN + CASE source_label + WHEN 'strava' THEN 'strava' + WHEN 'garmin' THEN 'garmin' + WHEN 'garmin connect' THEN 'garmin' + WHEN 'wahoo' THEN 'wahoo' + WHEN 'zwift' THEN 'zwift' + WHEN 'peloton' THEN 'peloton' + WHEN 'trainerroad' THEN 'trainerroad' + WHEN 'whoop' THEN 'whoop' + WHEN 'strong' THEN 'strong-csv' + WHEN 'hang ten' THEN 'hang-ten' + WHEN 'hang ten training' THEN 'hang-ten' + ELSE NULL + END + WHEN provider_id = 'strava' AND source_label ~ '^garmin edge( |$)' THEN 'garmin' + WHEN provider_id = 'strava' AND source_label ~ '^wahoo elemnt( |$)' THEN 'wahoo' + WHEN provider_id = 'strava' AND source_label = 'peloton bike' THEN 'peloton' + ELSE NULL + END AS label_provider, + CASE WHEN provider_id = 'apple_health' AND source_bundle = 'com.strava.strava' THEN 'strava' END AS bundle_provider, + CASE + WHEN provider_id = 'strava' AND raw_device_label ~ '^garmin edge( |$)' THEN 'garmin' + WHEN provider_id = 'strava' AND raw_device_label ~ '^wahoo elemnt( |$)' THEN 'wahoo' + WHEN provider_id = 'strava' AND raw_device_label = 'peloton bike' THEN 'peloton' + END AS device_provider + FROM labels +), members AS ( + SELECT *, + COALESCE(copy_provider, bundle_provider, label_provider, device_provider) AS upstream_provider, + (SELECT COUNT(DISTINCT hint) > 1 FROM UNNEST(ARRAY[copy_provider, label_provider, bundle_provider, device_provider]) AS hint) + OR (is_watch_recording AND bundle_provider IS NOT NULL) + OR (provider_id = 'strava' AND source_label <> '' AND raw_device_label <> '' AND source_label <> raw_device_label) AS conflicting, + CASE WHEN copy_provider IS NOT NULL THEN 'external_id' + WHEN bundle_provider IS NOT NULL THEN 'raw.sourceBundle' + WHEN label_provider IS NOT NULL THEN 'source_name' ELSE 'raw.device_name' END AS evidence_field, + CASE WHEN copy_provider IS NOT NULL THEN copy_external_id + WHEN bundle_provider IS NOT NULL THEN source_bundle + WHEN label_provider IS NOT NULL THEN source_label ELSE raw_device_label END AS evidence_value + FROM hints +), matches AS ( + SELECT upstream.member_id AS upstream_id, downstream.member_id AS downstream_id, + downstream.evidence_field, downstream.evidence_value, + COUNT(*) OVER (PARTITION BY downstream.member_id) AS match_count + FROM members AS downstream + INNER JOIN members AS upstream + ON upstream.provider_id = downstream.upstream_provider AND upstream.member_id <> downstream.member_id + WHERE NOT downstream.conflicting + AND (downstream.copy_provider IS NULL + OR LOWER(REPLACE(upstream.external_id, '-', '')) = downstream.copy_external_id) + AND (downstream.provider_id <> 'strava' OR upstream.source_label = '' + OR upstream.source_label = COALESCE(NULLIF(downstream.source_label, ''), downstream.raw_device_label)) +), edges AS ( + SELECT upstream_id, downstream_id, evidence_field, evidence_value FROM matches WHERE match_count = 1 +), reachability(upstream_id, downstream_id) AS ( + SELECT upstream_id, downstream_id FROM edges + UNION + SELECT reached.upstream_id, edge.downstream_id + FROM reachability AS reached INNER JOIN edges AS edge ON reached.downstream_id = edge.upstream_id +), roots AS ( + SELECT member.member_id + FROM members AS member + WHERE NOT EXISTS ( + SELECT 1 FROM members AS other + WHERE other.member_id <> member.member_id + AND NOT EXISTS (SELECT 1 FROM reachability AS reached + WHERE reached.upstream_id = member.member_id AND reached.downstream_id = other.member_id) + ) +), initiating_origins AS ( + -- Kaya, Strong, Hang Ten, Apple Watch, and native Peloton bikes initiate by policy. Prefer a direct export over + -- its Apple Health copy, without inventing transfer edges to other providers. + SELECT member_id FROM members + WHERE provider_id IN ('strong-csv', 'kaya', 'kaya-export') + OR is_watch_recording + OR is_native_peloton_recording + OR (provider_id = 'apple_health' AND source_label IN ('hang ten', 'hang ten training')) + OR (provider_id = 'apple_health' AND source_label = 'strong' + AND NOT EXISTS (SELECT 1 FROM members WHERE provider_id = 'strong-csv')) +), decision AS ( + SELECT CASE + WHEN (SELECT COUNT(*) FROM initiating_origins) = 1 + AND NOT EXISTS (SELECT 1 FROM members WHERE conflicting) + AND NOT EXISTS (SELECT 1 FROM reachability WHERE upstream_id = downstream_id) + THEN (SELECT member_id FROM initiating_origins) + WHEN (SELECT COUNT(*) FROM initiating_origins) > 1 THEN NULL + WHEN (SELECT COUNT(*) FROM members) = 1 OR (COUNT(*) = 1 + AND NOT EXISTS (SELECT 1 FROM members WHERE conflicting) + AND NOT EXISTS (SELECT 1 FROM reachability WHERE upstream_id = downstream_id)) + THEN (ARRAY_AGG(member_id))[1] ELSE NULL END AS selected_id + FROM roots +) +SELECT + decision.selected_id, + selected.provider_id, + selected.source_name, + CASE WHEN decision.selected_id IS NOT NULL THEN ARRAY[decision.selected_id] + ELSE COALESCE((SELECT ARRAY_AGG(member_id ORDER BY member_id) FROM members), ARRAY[]::uuid[]) END, + COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'upstreamMemberActivityId', upstream_id, 'downstreamMemberActivityId', downstream_id, + 'field', evidence_field, 'value', evidence_value) ORDER BY upstream_id, downstream_id, evidence_field, evidence_value) + FROM edges), '[]'::jsonb) +FROM decision LEFT JOIN members AS selected ON selected.member_id = decision.selected_id; +$$; diff --git a/drizzle/_views/00_activity_origin.sql b/drizzle/_views/00_activity_origin.sql index 2317a00c18..c277959f5f 100644 --- a/drizzle/_views/00_activity_origin.sql +++ b/drizzle/_views/00_activity_origin.sql @@ -102,10 +102,10 @@ WITH RECURSIVE labels AS ( WHERE reached.upstream_id = member.member_id AND reached.downstream_id = other.member_id) ) ), initiating_origins AS ( - -- Strong, Hang Ten, Apple Watch, and native Peloton bikes initiate by policy. Prefer a direct export over + -- Kaya, Strong, Hang Ten, Apple Watch, and native Peloton bikes initiate by policy. Prefer a direct export over -- its Apple Health copy, without inventing transfer edges to other providers. SELECT member_id FROM members - WHERE provider_id = 'strong-csv' + WHERE provider_id IN ('strong-csv', 'kaya', 'kaya-export') OR is_watch_recording OR is_native_peloton_recording OR (provider_id = 'apple_health' AND source_label IN ('hang ten', 'hang ten training')) diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 279b2a9649..04984ddc94 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1219,6 +1219,13 @@ "when": 1793465090330, "tag": "0173_climbing_file_imports", "breakpoints": true + }, + { + "idx": 174, + "version": "7", + "when": 1793577267980, + "tag": "0174_kaya_activity_origin", + "breakpoints": true } ] } diff --git a/packages/mobile/app-tests/(tabs)/activities.test.tsx b/packages/mobile/app-tests/(tabs)/activities.test.tsx index e9b64cf0ae..e9e186a7be 100644 --- a/packages/mobile/app-tests/(tabs)/activities.test.tsx +++ b/packages/mobile/app-tests/(tabs)/activities.test.tsx @@ -158,6 +158,16 @@ import ActivitiesScreen from "../../app/(tabs)/activities"; afterEach(() => vi.useRealTimers()); describe("ActivitiesScreen", () => { + it("uses the primary climbing source for the icon over a cardio representative", () => { + mockQuery = activityListQuery([ + activity({ name: "Kaya climbing", activityType: "cardio", primaryActivityType: "climbing" }), + ]); + render(); + expect(screen.getByLabelText("Climbing activity")).toBeDefined(); + expect(screen.getByText("Climbing")).toBeDefined(); + expect(screen.getByRole("button", { name: /^Open Kaya climbing,.*Climbing/ })).toBeDefined(); + }); + it("uses the primary activity type for unnamed activity titles and accessible actions", () => { mockQuery = activityListQuery([ activity({ name: null, activityType: "walking", primaryActivityType: "running" }), diff --git a/packages/mobile/app/(tabs)/activities.tsx b/packages/mobile/app/(tabs)/activities.tsx index 591ebbf4f2..7735e1a46b 100644 --- a/packages/mobile/app/(tabs)/activities.tsx +++ b/packages/mobile/app/(tabs)/activities.tsx @@ -99,9 +99,11 @@ function formatActivityAccessibilityLabel( }, units: UnitConverter, ): string { - const activityTypeLabel = formatActivityTypeLabel(activity.activityType); + const activityTypeLabel = formatActivityTypeLabel( + activity.primaryActivityType ?? activity.activityType, + ); const labelParts = [ - activity.name ?? formatActivityTypeLabel(activity.primaryActivityType ?? activity.activityType), + activity.name ?? activityTypeLabel, displayRecordLocalTime(activity.startedAt, activity.localTimeContext), formatDurationMinutes(activity.durationMin), ]; @@ -460,7 +462,10 @@ export default function ActivitiesScreen() { accessibilityElementsHidden importantForAccessibility="no-hide-descendants" > - + ) : null} {activity.name !== null && - activity.name !== formatActivityTypeLabel(activity.activityType) ? ( + activity.name !== + formatActivityTypeLabel( + activity.primaryActivityType ?? activity.activityType, + ) ? ( - {formatActivityTypeLabel(activity.activityType)} + {formatActivityTypeLabel( + activity.primaryActivityType ?? activity.activityType, + )} ) : null} diff --git a/packages/mobile/components/ActivityCard.test.tsx b/packages/mobile/components/ActivityCard.test.tsx index 70ed1345df..02afc643e9 100644 --- a/packages/mobile/components/ActivityCard.test.tsx +++ b/packages/mobile/components/ActivityCard.test.tsx @@ -34,7 +34,7 @@ describe("ActivityCard", () => { ); expect(screen.getByText("Running")).toBeDefined(); - expect(screen.getByLabelText("Walking activity")).toBeDefined(); + expect(screen.getByLabelText("Running activity")).toBeDefined(); }); it("falls back to activityType when name is missing", () => { @@ -42,6 +42,18 @@ describe("ActivityCard", () => { expect(screen.getByText("Running")).toBeTruthy(); }); + it("uses the primary climbing source for the icon over a cardio representative", () => { + render( + , + ); + expect(screen.getByLabelText("Climbing activity").querySelector("SvgXml")).toBeTruthy(); + }); + it("shows avg HR when provided", () => { render(); expect(screen.getByText("153")).toBeTruthy(); diff --git a/packages/mobile/components/ActivityCard.tsx b/packages/mobile/components/ActivityCard.tsx index 329bb66c99..aad97db255 100644 --- a/packages/mobile/components/ActivityCard.tsx +++ b/packages/mobile/components/ActivityCard.tsx @@ -73,7 +73,7 @@ export function ActivityCard({ return ( - + {name ?? formatActivityTypeLabel(primaryActivityType ?? activityType)} diff --git a/packages/server/README.md b/packages/server/README.md index c45e4ab00a..73fd9082f9 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -208,6 +208,14 @@ with incomplete power coverage withheld from weekly totals. - **Redis pairing store**: Companion pairing uses Lua scripts over related Redis keys and is intended for the single-node Redis deployment used by Dofek. Redis Cluster requires every key touched by one Lua script to be in the same hash slot; supporting Cluster mode would require redesigning the pairing key names with Redis hash tags. See the Redis Cluster scaling and hash tag documentation: https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/ and https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags. - **Monitoring**: Integrated with Sentry for error tracking and Prometheus for performance metrics (`src/lib/metrics.ts`). +### Activity identity and filters + +Activity-list [filtering](src/repositories/activities-calendar-repository.ts) and +[overview filter options](src/repositories/activity-overview-repository.ts) use +the resolved primary activity type, falling back to the representative type when +primary identity is unresolved. This matches web and mobile card icons while +preserving the representative's sensor and metric responsibilities. + ### Activity map readiness contract `calendar.weekList` and activity lists use the shared diff --git a/packages/server/src/repositories/activities-calendar-repository.integration.test.ts b/packages/server/src/repositories/activities-calendar-repository.integration.test.ts index a44bd6e0ef..784da9297c 100644 --- a/packages/server/src/repositories/activities-calendar-repository.integration.test.ts +++ b/packages/server/src/repositories/activities-calendar-repository.integration.test.ts @@ -16,13 +16,17 @@ import { } from "../routers/clickhouse-integration-test-helpers.ts"; import { buildTestAnalyticsTableStatement } from "../routers/clickhouse-integration-test-models.ts"; import { ActivitiesCalendarRepository } from "./activities-calendar-repository.ts"; +import { ActivityOverviewRepository } from "./activity-overview-repository.ts"; import { ClickHouseActivitySensorStore } from "./clickhouse-activity-sensor-store.ts"; import { ClimbingActivityLogEntryRepository } from "./climbing-activity-log-entry-repository.ts"; describe("Activities list source tree hydration", () => { let context: TestContext; let repository: ActivitiesCalendarRepository; + let overviewRepository: ActivityOverviewRepository; const groupId = randomUUID(); + const climbingGroupId = randomUUID(); + const climbingMemberId = randomUUID(); const ids = Array.from({ length: 5 }, () => randomUUID()); const absentId = randomUUID(); beforeAll(async () => { @@ -31,6 +35,18 @@ describe("Activities list source tree hydration", () => { for (const provider of new Set(providers)) { await ensureProvider(context.db, provider, provider, undefined, userId); } + await ensureProvider(context.db, "kaya", "Kaya", undefined, userId); + await context.db.insert(activity).values({ + id: climbingMemberId, + groupId: climbingGroupId, + userId, + providerId: "kaya", + externalId: "primary-type-climbing", + canonicalType: "climbing", + providerType: "rock_climbing", + startedAt: new Date("2026-10-02T14:00:00Z"), + endedAt: new Date("2026-10-02T14:30:00Z"), + }); await context.db.insert(activity).values( providers.map((providerId, index) => ({ id: ids[index], @@ -111,11 +127,48 @@ describe("Activities list source tree hydration", () => { command: (options) => client.command({ ...options, query: rewrite(options.query) }), query: (options) => client.query({ ...options, query: rewrite(options.query) }), }); + await client.command({ + query: `INSERT INTO ${database}.deduped_activities + (activity_id, user_id, provider_id, canonical_type, primary_canonical_type, + provider_type, name, started_at, ended_at, local_time_source, + primary_activity_id, primary_provider_id, member_activity_ids, source_external_ids, refreshed_at) + VALUES ('${climbingGroupId}','${userId}','peloton','cardio','climbing', + 'cardio','Kaya climbing','2026-10-02 14:00:00','2026-10-02 14:30:00','unknown', + '${climbingMemberId}','kaya',['${climbingMemberId}'], + [map('providerId','kaya','externalId','primary-type-climbing','memberActivityId','${climbingMemberId}')], + '2026-10-02 14:35:00')`, + }); repository = new ActivitiesCalendarRepository(context.db, userId, "UTC", sensorStore); + overviewRepository = new ActivityOverviewRepository(context.db, userId, "UTC", sensorStore); }); afterAll(async () => { await context.cleanup(); }); + it("filters cards and overview by primary type with representative fallback", async () => { + const input = { weeks: 1, endDate: "2026-10-02", activityType: "climbing" }; + const days = await repository.getWeekList(input); + expect(days.flatMap((day) => day.activities)).toEqual([ + expect.objectContaining({ + id: climbingGroupId, + activityType: "cardio", + primaryActivityType: "climbing", + }), + ]); + expect(await overviewRepository.getActivityOverview(input)).toMatchObject({ + activityCount: 1, + totalMinutes: 30, + activityTypes: ["climbing", "cycling"], + }); + expect( + (await repository.getWeekList({ ...input, activityType: "cycling" })) + .flatMap((day) => day.activities) + .map((entry) => entry.id), + ).toEqual([groupId]); + expect(await repository.getWeekList({ ...input, activityType: "cardio" })).toEqual([]); + expect( + await overviewRepository.getActivityOverview({ ...input, activityType: "cardio" }), + ).toMatchObject({ activityCount: 0 }); + }); it("hydrates stored evidence branches, duplicate provider records, and disconnected sources", async () => { const entries = (await repository.getWeekList({ weeks: 1, endDate: "2026-10-02" })).flatMap( (day) => day.activities, diff --git a/packages/server/src/repositories/activities-calendar-repository.test.ts b/packages/server/src/repositories/activities-calendar-repository.test.ts index d3705734fa..986c569e33 100644 --- a/packages/server/src/repositories/activities-calendar-repository.test.ts +++ b/packages/server/src/repositories/activities-calendar-repository.test.ts @@ -300,7 +300,8 @@ describe("ActivitiesCalendarRepository", () => { }), makeActivityRow({ id: "run", - canonical_type: "running", + canonical_type: "cardio", + primary_canonical_type: "running", local_date: "2026-03-19", avg_power: null, }), @@ -319,13 +320,21 @@ describe("ActivitiesCalendarRepository", () => { expect(result).toEqual([ { date: "2026-03-19", - activities: [expect.objectContaining({ id: "run", activityType: "running" })], + activities: [ + expect.objectContaining({ + id: "run", + activityType: "cardio", + primaryActivityType: "running", + }), + ], }, ]); expect(sensorStore.query).toHaveBeenNthCalledWith( 1, expect.anything(), - expect.stringContaining("activity.canonical_type = {activityType:String}"), + expect.stringContaining( + "coalesce(activity.primary_canonical_type, activity.canonical_type) = {activityType:String}", + ), expect.objectContaining({ activityType: "running" }), ); const sqlObject = database.execute.mock.calls[0]?.[0]; diff --git a/packages/server/src/repositories/activities-calendar-repository.ts b/packages/server/src/repositories/activities-calendar-repository.ts index b2c2898855..34e60ad452 100644 --- a/packages/server/src/repositories/activities-calendar-repository.ts +++ b/packages/server/src/repositories/activities-calendar-repository.ts @@ -55,7 +55,7 @@ export class ActivitiesCalendarRepository extends BaseRepository { const days = input.weeks * 7; const windowStart = dateWindowStartString(input.endDate, days); const activityTypeFilter = input.activityType - ? "AND activity.canonical_type = {activityType:String}" + ? "AND coalesce(activity.primary_canonical_type, activity.canonical_type) = {activityType:String}" : ""; const queryParams = { userId: this.userId, @@ -201,6 +201,9 @@ function filterActivityRowsByType( activityType: string | undefined, ) { return activityType - ? activityRows.filter((activityRow) => activityRow.canonical_type === activityType) + ? activityRows.filter( + (activityRow) => + (activityRow.primary_canonical_type ?? activityRow.canonical_type) === activityType, + ) : activityRows; } diff --git a/packages/server/src/repositories/activity-overview-repository.test.ts b/packages/server/src/repositories/activity-overview-repository.test.ts index 4e32e34eb4..7395774bb1 100644 --- a/packages/server/src/repositories/activity-overview-repository.test.ts +++ b/packages/server/src/repositories/activity-overview-repository.test.ts @@ -53,7 +53,9 @@ describe("ActivityOverviewRepository", () => { expect(sensorStore.query).toHaveBeenNthCalledWith( 1, expect.anything(), - expect.stringContaining("activity.canonical_type = {activityType:String}"), + expect.stringContaining( + "coalesce(activity.primary_canonical_type, activity.canonical_type) = {activityType:String}", + ), expect.objectContaining({ activityIds: ["running-activity", "cycling-activity"], activityType: "running", diff --git a/packages/server/src/repositories/activity-overview-repository.ts b/packages/server/src/repositories/activity-overview-repository.ts index 432b1777bd..2fc6771389 100644 --- a/packages/server/src/repositories/activity-overview-repository.ts +++ b/packages/server/src/repositories/activity-overview-repository.ts @@ -87,7 +87,7 @@ export class ActivityOverviewRepository extends BaseRepository { ); const activityTypeFilter = input.activityType - ? "AND activity.canonical_type = {activityType:String}" + ? "AND coalesce(activity.primary_canonical_type, activity.canonical_type) = {activityType:String}" : ""; const queryParams = { userId: this.userId, @@ -191,7 +191,7 @@ export class ActivityOverviewRepository extends BaseRepository { activityTypeRowSchema, `WITH ${activityDate} AS activity_date SELECT DISTINCT - activity.canonical_type AS canonical_type + coalesce(activity.primary_canonical_type, activity.canonical_type) AS canonical_type FROM analytics.deduped_activities AS activity FINAL WHERE activity.user_id = {userId:UUID} AND activity.activity_id IN {activityIds:Array(UUID)} diff --git a/packages/server/src/repositories/strength-repository.integration.test.ts b/packages/server/src/repositories/strength-repository.integration.test.ts index 92059d5146..bec26ee187 100644 --- a/packages/server/src/repositories/strength-repository.integration.test.ts +++ b/packages/server/src/repositories/strength-repository.integration.test.ts @@ -151,20 +151,25 @@ describe("StrengthRepository activity scope", () => { expect(await repository.getVolumeOverTime(null)).toEqual([]); }); it("keeps a week with a workout but no sets as unknown set and weight volume", async () => { - await testContext.db.execute(sql`INSERT INTO fitness.activity (provider_id, user_id, external_id, canonical_type, provider_type, started_at, ended_at, name) + const isolatedContext = await setupTestDatabase(); + try { + await isolatedContext.db.execute(sql`INSERT INTO fitness.provider (id, name, user_id) + VALUES ('strength_scope_test', 'Strength Scope Test', ${TEST_USER_ID})`); + await isolatedContext.db.execute(sql`INSERT INTO fitness.activity (provider_id, user_id, external_id, canonical_type, provider_type, started_at, ended_at, name) VALUES ('strength_scope_test', ${TEST_USER_ID}, 'no-sets', 'strength', 'strength', date_trunc('week', CURRENT_TIMESTAMP) - interval '1 week', date_trunc('week', CURRENT_TIMESTAMP) - interval '1 week' + interval '1 hour', 'No sets')`); - const repository = new StrengthRepository(testContext.db, TEST_USER_ID, "UTC"); - const volume = (await repository.getVolumeOverTime(30)).map((week) => week.toDetail()); - expect(volume).toEqual( - expect.arrayContaining([ + const repository = new StrengthRepository(isolatedContext.db, TEST_USER_ID, "UTC"); + const volume = (await repository.getVolumeOverTime(30)).map((week) => week.toDetail()); + expect(volume).toEqual([ expect.objectContaining({ recordedSetCount: null, recordedVolumeKg: null, durationMinutes: 60, }), - ]), - ); + ]); + } finally { + await isolatedContext.cleanup(); + } }); it("preserves observed week labels and fits slope across calendar gaps", async () => { diff --git a/packages/server/src/routers/calendar.test.ts b/packages/server/src/routers/calendar.test.ts index 092910a782..fa8b893986 100644 --- a/packages/server/src/routers/calendar.test.ts +++ b/packages/server/src/routers/calendar.test.ts @@ -59,11 +59,11 @@ describe("calendarRouter", () => { it("versions the activity state cache contract", () => { expect(cachedQueryOptions).toHaveBeenCalledWith({ maxAge: 600_000, - keyVersion: "activity-calendar-maplibre-v9", + keyVersion: "activity-calendar-primary-type-v10", }); expect(cachedQueryOptions).toHaveBeenCalledWith({ maxAge: 600_000, - keyVersion: "activity-calendar-states-v2", + keyVersion: "activity-calendar-primary-type-v3", }); expect(cachedQueryOptions).toHaveBeenCalledWith({ maxAge: 3_600_000, diff --git a/packages/server/src/routers/calendar.ts b/packages/server/src/routers/calendar.ts index 581d3c20aa..bba02daa29 100644 --- a/packages/server/src/routers/calendar.ts +++ b/packages/server/src/routers/calendar.ts @@ -145,7 +145,7 @@ export const calendarRouter = router({ weekList: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM, - keyVersion: "activity-calendar-maplibre-v9", + keyVersion: "activity-calendar-primary-type-v10", }) .input(activityListInputSchema) .output(z.array(calendarDayActivitiesSchema)) @@ -170,7 +170,7 @@ export const calendarRouter = router({ activityOverview: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM, - keyVersion: "activity-calendar-states-v2", + keyVersion: "activity-calendar-primary-type-v3", }) .input(activityListInputSchema) .output(activityOverviewSchema) diff --git a/packages/web/src/components/ActivityCardContent.stories.tsx b/packages/web/src/components/ActivityCardContent.stories.tsx index 5e199068cd..3f6b457fc5 100644 --- a/packages/web/src/components/ActivityCardContent.stories.tsx +++ b/packages/web/src/components/ActivityCardContent.stories.tsx @@ -365,7 +365,8 @@ export const KayaProviderLogo: Story = { activity: { ...strengthActivity, name: "Kaya climbing", - activityType: "climbing", + activityType: "cardio", + primaryActivityType: "climbing", durationMin: 48, source: { origin: { diff --git a/packages/web/src/components/ActivityCardContent.test.tsx b/packages/web/src/components/ActivityCardContent.test.tsx index c801633c2a..5bdf00b707 100644 --- a/packages/web/src/components/ActivityCardContent.test.tsx +++ b/packages/web/src/components/ActivityCardContent.test.tsx @@ -59,6 +59,25 @@ function activity(overrides: Partial = {}): ActivityCardData { afterEach(cleanup); describe("ActivityCardContent", () => { + it("uses the primary climbing source for the icon over a cardio representative", () => { + render( + , + ); + expect(screen.getByRole("img", { name: "Climbing activity" })).toHaveAttribute( + "data-activity-type", + "climbing", + ); + }); + it("uses the primary activity type for an unnamed activity title and selection label", () => { render( ) : null} - +

{headingAction ?? activity.name ?? activityLabel} diff --git a/src/db/README.md b/src/db/README.md index 92c47e8893..db7461cdd7 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -118,6 +118,12 @@ describes that boundary. ## Activity recording origin +The [Kaya origin migration](../../drizzle/0174_kaya_activity_origin.sql) treats +Kaya sync and file-import sessions as initiating sources, matching the +[dbt selector](../../analytics/macros/activity_origin.sql). Primary identity stays +separate from the sensor-bearing representative, and competing initiating +records retain unresolved attribution. + The [origin function](../../drizzle/_views/00_activity_origin.sql) chooses the closest available recording from provenance within a group. The [activity view](../../drizzle/_views/01_v_activity.sql) exposes nullable primary diff --git a/src/db/activity-field-priority-dbt.integration.test.ts b/src/db/activity-field-priority-dbt.integration.test.ts index 14302e7f88..a38e6bbc65 100644 --- a/src/db/activity-field-priority-dbt.integration.test.ts +++ b/src/db/activity-field-priority-dbt.integration.test.ts @@ -369,15 +369,22 @@ describe("dbt merged activity field priorities", () => { ]); }); - it.each([null, "", " "])( - "skips a blank preferred name (%j) and uses the next named source", - async (name) => { + it.each([ + { providerId: "whoop", name: null, expectedName: "Peloton fallback" }, + { providerId: "whoop", name: "", expectedName: "Peloton fallback" }, + { providerId: "whoop", name: " ", expectedName: "Peloton fallback" }, + { providerId: "kaya", name: null, expectedName: null }, + { providerId: "kaya", name: "", expectedName: null }, + { providerId: "kaya", name: " ", expectedName: null }, + ])( + "uses $expectedName for a blank $providerId title ($name)", + async ({ providerId, name, expectedName }) => { const id = randomUUID(); await insertSources([ { id: randomUUID(), groupId: id, - providerId: "kaya", + providerId, canonicalType: "climbing", providerType: "rock_climbing", name, @@ -396,9 +403,9 @@ describe("dbt merged activity field priorities", () => { await refreshDeduped(true); expect(await mergedFields(id)).toEqual([ { - name: "Peloton fallback", + name: expectedName, notes: null, - provider_id: "kaya", + provider_id: providerId, canonical_type: "climbing", }, ]); @@ -733,6 +740,20 @@ describe("dbt merged activity field priorities", () => { it("refreshes the merged row and summary when a field priority changes", async () => { const id = randomUUID(); + const provider = `field-priority-${id}`; + await client.insert({ + table: `${database}.provider_field_priority`, + format: "JSONEachRow", + values: [ + { + provider_id: provider, + field_key: "activity.name", + priority: 10, + _peerdb_is_deleted: 0, + _peerdb_version: 1, + }, + ], + }); await insertSources([ { id: randomUUID(), @@ -755,24 +776,24 @@ describe("dbt merged activity field priorities", () => { { id: randomUUID(), groupId: id, - providerId: "kaya", + providerId: provider, canonicalType: "climbing", providerType: "rock_climbing", - name: "Kaya title", + name: "Preferred title", priority: 100, }, ]); await refreshDeduped(true); await refreshSummary(false); - expect((await mergedFields(id))[0]?.name).toBe("Kaya title"); - expect(await summaryName(id)).toEqual([{ name: "Kaya title" }]); + expect((await mergedFields(id))[0]?.name).toBe("Preferred title"); + expect(await summaryName(id)).toEqual([{ name: "Preferred title" }]); await client.insert({ table: `${database}.provider_field_priority`, format: "JSONEachRow", values: [ { - provider_id: "kaya", + provider_id: provider, field_key: "activity.name", priority: 50, _peerdb_is_deleted: 0, diff --git a/src/db/activity-field-priority.integration.test.ts b/src/db/activity-field-priority.integration.test.ts index f2730291de..bef2e7e27f 100644 --- a/src/db/activity-field-priority.integration.test.ts +++ b/src/db/activity-field-priority.integration.test.ts @@ -244,27 +244,33 @@ describe("merged activity field priorities", () => { ]); }); - it("skips a null preferred name and falls back to generic priority", async () => { - const nullNameGroupId = "10000000-0000-4000-8000-000000000120"; - await context.db.execute(sql`INSERT INTO fitness.activity_group (id, user_id) + it.each([ + { providerId: "whoop", expectedName: "Peloton fallback" }, + { providerId: "kaya", expectedName: null }, + ])( + "uses $expectedName for an unnamed $providerId source", + async ({ providerId, expectedName }) => { + const nullNameGroupId = randomUUID(); + await context.db.execute(sql`INSERT INTO fitness.activity_group (id, user_id) VALUES (${nullNameGroupId}, ${TEST_USER_ID}) ON CONFLICT DO NOTHING`); - await context.db.execute(sql`INSERT INTO fitness.activity ( + await context.db.execute(sql`INSERT INTO fitness.activity ( group_id, provider_id, user_id, external_id, canonical_type, provider_type, started_at, ended_at, name ) VALUES - (${nullNameGroupId}, 'kaya', ${TEST_USER_ID}, 'kaya-unnamed', 'climbing', + (${nullNameGroupId}, ${providerId}, ${TEST_USER_ID}, ${randomUUID()}, 'climbing', 'rock_climbing', '2026-09-03T18:00:00Z', '2026-09-03T19:00:00Z', NULL), - (${nullNameGroupId}, 'peloton', ${TEST_USER_ID}, 'peloton-named', 'cardio', + (${nullNameGroupId}, 'peloton', ${TEST_USER_ID}, ${randomUUID()}, 'cardio', 'Cardio', '2026-09-03T18:00:00Z', '2026-09-03T19:00:00Z', 'Peloton fallback') ON CONFLICT DO NOTHING`); - const rows = await executeWithSchema( - context.db, - z.object({ name: z.string().nullable(), canonical_type: z.string() }), - sql`SELECT name, canonical_type FROM fitness.v_activity WHERE id = ${nullNameGroupId}`, - ); - expect(rows).toEqual([{ name: "Peloton fallback", canonical_type: "climbing" }]); - }); + const rows = await executeWithSchema( + context.db, + z.object({ name: z.string().nullable(), canonical_type: z.string() }), + sql`SELECT name, canonical_type FROM fitness.v_activity WHERE id = ${nullNameGroupId}`, + ); + expect(rows).toEqual([{ name: expectedName, canonical_type: "climbing" }]); + }, + ); it("uses generic device priority when no field rule exists", async () => { const deviceGroupId = "10000000-0000-4000-8000-000000000130"; diff --git a/src/test-fixtures/activity-origin.json b/src/test-fixtures/activity-origin.json index bdb79dd198..43c291c9c8 100644 --- a/src/test-fixtures/activity-origin.json +++ b/src/test-fixtures/activity-origin.json @@ -1,4 +1,117 @@ [ + { + "name": "Kaya initiates alongside WHOOP and its copies", + "members": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "providerId": "kaya", + "sourceName": "Kaya", + "raw": null + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "providerId": "whoop", + "externalId": "22b39dea58e942c1acc9ec6db6b33045", + "sourceName": null, + "raw": null + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "providerId": "apple_health", + "sourceName": "WHOOP", + "raw": { "sourceBundle": "com.whoop.iphone" } + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "providerId": "peloton", + "externalId": "22b39dea58e942c1acc9ec6db6b33045", + "sourceName": null, + "raw": { "platform": "whoop", "deviceType": "whoop" } + } + ], + "expectedPrimaryMemberId": "00000000-0000-4000-8000-000000000001", + "expectedCandidateMemberIds": ["00000000-0000-4000-8000-000000000001"], + "expectedEvidence": [ + { + "upstreamMemberActivityId": "00000000-0000-4000-8000-000000000002", + "downstreamMemberActivityId": "00000000-0000-4000-8000-000000000003", + "field": "source_name", + "value": "whoop" + }, + { + "upstreamMemberActivityId": "00000000-0000-4000-8000-000000000002", + "downstreamMemberActivityId": "00000000-0000-4000-8000-000000000004", + "field": "external_id", + "value": "22b39dea58e942c1acc9ec6db6b33045" + } + ] + }, + { + "name": "Kaya export initiates alongside an independent wearable", + "members": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "providerId": "kaya-export", + "sourceName": null, + "raw": null + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "providerId": "whoop", + "sourceName": null, + "raw": null + } + ], + "expectedPrimaryMemberId": "00000000-0000-4000-8000-000000000001", + "expectedCandidateMemberIds": ["00000000-0000-4000-8000-000000000001"], + "expectedEvidence": [] + }, + { + "name": "Multiple Kaya sessions remain unresolved", + "members": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "providerId": "kaya", + "sourceName": "Kaya", + "raw": null + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "providerId": "kaya", + "sourceName": "Kaya", + "raw": null + } + ], + "expectedPrimaryMemberId": null, + "expectedCandidateMemberIds": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002" + ], + "expectedEvidence": [] + }, + { + "name": "Kaya and another initiating recording remain unresolved", + "members": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "providerId": "kaya", + "sourceName": "Kaya", + "raw": null + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "providerId": "apple_health", + "sourceName": "Apple Watch", + "raw": null + } + ], + "expectedPrimaryMemberId": null, + "expectedCandidateMemberIds": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002" + ], + "expectedEvidence": [] + }, { "name": "Personalized Apple Watch initiates alongside WHOOP and its copies", "members": [