= {}): 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": [