Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions analytics/macros/activity_origin.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
138 changes: 138 additions & 0 deletions drizzle/0174_kaya_activity_origin.sql
Original file line number Diff line number Diff line change
@@ -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;
$$;
4 changes: 2 additions & 2 deletions drizzle/_views/00_activity_origin.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
10 changes: 10 additions & 0 deletions packages/mobile/app-tests/(tabs)/activities.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ActivitiesScreen />);
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" }),
Expand Down
20 changes: 15 additions & 5 deletions packages/mobile/app/(tabs)/activities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
Expand Down Expand Up @@ -460,7 +462,10 @@ export default function ActivitiesScreen() {
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<ActivityTypeIcon activityType={activity.activityType} size={40} />
<ActivityTypeIcon
activityType={activity.primaryActivityType ?? activity.activityType}
size={40}
/>
Comment thread
Asherlc marked this conversation as resolved.
Resolved
</View>
<View style={styles.cardMain} pointerEvents="box-none">
<View
Expand Down Expand Up @@ -488,9 +493,14 @@ export default function ActivitiesScreen() {
</Text>
) : null}
{activity.name !== null &&
activity.name !== formatActivityTypeLabel(activity.activityType) ? (
activity.name !==
formatActivityTypeLabel(
activity.primaryActivityType ?? activity.activityType,
) ? (
<Text style={styles.typePill}>
{formatActivityTypeLabel(activity.activityType)}
{formatActivityTypeLabel(
activity.primaryActivityType ?? activity.activityType,
)}
</Text>
) : null}
</View>
Expand Down
Loading
Loading