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
45 changes: 45 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -15226,3 +15226,48 @@ Drizzle schema and runtime Zod schemas. Findings and remediations:
[merged image](https://github.com/Asherlc/dofek/actions/runs/29945041858), and
[complete archive](https://github.com/Asherlc/dofek/actions/runs/29948084505),
and [partial active-migration restoration](https://github.com/Asherlc/dofek/actions/runs/29950485728).

## 2026-07-22 — Climbing Detail Hid Per-Climb Attempt Counts

- **Status:** Fixed locally and validated; pending review and deployment.
- **Symptoms:** Every Kaya row on activity
`734b5d3e-df2b-4ee0-888e-55ea539d913a` appeared as `Sent`, suggesting every
attempt was successful.
- **User impact:** The detail page does not distinguish an onsight from a climb
eventually sent after several attempts.
- **Evidence:** The production rows contain 20 attempts across eight eventual
sends. In particular, two V4 rows have `attempt_count` values of 7 and 6 with
Kaya ascent type `Redpoint`; the remaining rows are `Redpoint`, `Repeat`, or
`Onsight`. The Kaya importer persists `attemptCount`, but the activity-entry
repository omits it from its response
([repository](../packages/server/src/repositories/climbing-repository.ts)), and
the web and mobile detail components therefore render only the eventual
`sent` flag
([web detail](../packages/web/src/pages/ActivityDetailPage.tsx),
[mobile detail](../packages/mobile/app/activity/[id].tsx)). No Kaya import
failure for this activity appeared in the retained worker logs.
- **Root cause:** The per-activity detail API discards the canonical
`climbing_entry.attempt_count` value, so clients cannot explain that `Sent`
means the climb was eventually completed after one or more attempts.
- **Fix / mitigation:** The activity-entry response now includes the canonical
attempt count and Kaya's recorded ascent type. Web and mobile show route or
problem names when present, ascent classifications when present, and explicit
`Sent in N attempts` or `Attempted N times` wording. Kaya imports now accept
`Flash` as a successful ascent type. Missing route names keep the generic
`Boulder` or `Route` label; no color-derived identity is invented.
- **Validation:** The test-first reproduction failed because the repository
omitted `attempt_count` and ascent type, both clients rendered plain `Sent`,
and the importer rejected `Flash`. After the fix, 76 focused repository,
importer, web, and mobile tests passed. The four-test climbing router suite
also passed against real Postgres through `pnpm test:integration`; repository
lint and TypeScript checks passed, and the changed-file suite passed all 3,643
selected unit and mobile tests. The first PR CI attempt's web E2E job stopped
because the server container remained unhealthy for 44 seconds without
emitting an application log. The same image and full E2E stack became healthy
locally, and the isolated GitHub job rerun passed server startup and Cypress
without a code, timeout, or retry-policy change; all 93 PR checks then passed.
- **Remaining risk / follow-up:** The current production activity will still
report eight eventual sends because that is what its Kaya source rows record.
Dofek cannot reconstruct attempt-only climbs that the exported file omitted,
distinguish onsight from flash without a provider-supplied classification, or
recover a route/problem name when Kaya's `climb_name` field is empty.
10 changes: 10 additions & 0 deletions packages/format/src/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
formatBodyCompositionPercent,
formatCalories,
formatCaloriesMeasurement,
formatClimbingAttemptResult,
formatDateForDisplay,
formatDateLong,
formatDateMedium,
Expand Down Expand Up @@ -176,6 +177,15 @@ describe("formatDurationSeconds", () => {
});
});

describe("formatClimbingAttemptResult", () => {
it("formats sent and attempted climbs with singular and plural counts", () => {
expect(formatClimbingAttemptResult(true, 1)).toBe("Sent in 1 attempt");
expect(formatClimbingAttemptResult(true, 7)).toBe("Sent in 7 attempts");
expect(formatClimbingAttemptResult(false, 1)).toBe("Attempted 1 time");
expect(formatClimbingAttemptResult(false, 3)).toBe("Attempted 3 times");
});
});

describe("formatSleepDebt", () => {
it("returns no debt for zero", () => {
expect(formatSleepDebt(0)).toBe("No sleep debt");
Expand Down
7 changes: 7 additions & 0 deletions packages/format/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export function formatDurationSeconds(seconds: number): string {
return formatDurationMinutes(Math.round(seconds / 60));
}

export function formatClimbingAttemptResult(sent: boolean, attemptCount: number): string {
if (sent) {
return `Sent in ${attemptCount} ${attemptCount === 1 ? "attempt" : "attempts"}`;
}
return `Attempted ${attemptCount} ${attemptCount === 1 ? "time" : "times"}`;
}

/** Parse a timestamp string into a Date, returning null if invalid.
* Handles both ISO 8601 and postgres ::text format (space-separated, e.g. "2024-03-20 14:30:00+00")
* which Hermes and Safari cannot parse natively. */
Expand Down
43 changes: 32 additions & 11 deletions packages/mobile/app/activity/[id].test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,19 @@ vi.mock("../../theme", () => ({
},
}));

vi.mock("@dofek/format/format", () => ({
formatDateLong: (value: string) => (value.startsWith("2026-03-05") ? "March 5, 2026" : value),
formatDateTime: (value: string) =>
value.startsWith("2026-03-05") ? "March 5, 2026, 2:30 PM" : value,
formatDurationRange: () => "1:00:00",
formatDurationSeconds: (value: number) => `${value}s`,
formatNumber: (value: number) => String(value),
formatTimeOnly: (value: string) => value,
}));
vi.mock("@dofek/format/format", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dofek/format/format")>();
return {
...actual,
formatDateLong: (value: string) => (value.startsWith("2026-03-05") ? "March 5, 2026" : value),
formatDateTime: (value: string) =>
value.startsWith("2026-03-05") ? "March 5, 2026, 2:30 PM" : value,
formatDurationRange: () => "1:00:00",
formatDurationSeconds: (value: number) => `${value}s`,
formatNumber: (value: number) => String(value),
formatTimeOnly: (value: string) => value,
};
});

vi.mock("@dofek/format/units", () => ({}));

Expand Down Expand Up @@ -566,10 +570,24 @@ describe("ActivityDetailScreen", () => {
gradeSystem: "v_scale",
grade: "V4",
sent: true,
attemptCount: 7,
ascentType: "Redpoint",
routeName: "Blue Circuit",
locationName: "Touchstone Pacific Pipe",
sourceName: "Kaya",
},
{
id: "climb-project",
climbType: "boulder",
gradeSystem: "v_scale",
grade: "V5",
sent: false,
attemptCount: 1,
ascentType: null,
routeName: "Project",
locationName: "Touchstone Pacific Pipe",
sourceName: "Kaya",
},
],
isLoading: false,
});
Expand All @@ -581,8 +599,11 @@ describe("ActivityDetailScreen", () => {
expect(screen.getByText("Climbs")).toBeTruthy();
expect(screen.getByText("V4")).toBeTruthy();
expect(screen.getByText("Blue Circuit")).toBeTruthy();
expect(screen.getByText("Sent")).toBeTruthy();
expect(screen.getByText("Touchstone Pacific Pipe")).toBeTruthy();
expect(screen.getByText("Redpoint")).toBeTruthy();
expect(screen.getByText("Sent in 7 attempts")).toBeTruthy();
expect(screen.getByText("Project")).toBeTruthy();
expect(screen.getByText("Attempted 1 time")).toBeTruthy();
expect(screen.getAllByText("Touchstone Pacific Pipe")).toHaveLength(2);
});

it("shows Apple Health upstream app names when subsource is present", async () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/mobile/app/activity/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
formatClimbingAttemptResult,
formatDateLong,
formatDurationRange,
formatDurationSeconds,
Expand Down Expand Up @@ -358,6 +359,8 @@ interface ClimbingEntry {
climbType: "boulder" | "route";
grade: string;
sent: boolean;
attemptCount: number;
ascentType: "Flash" | "Onsight" | "Redpoint" | "Repeat" | null;
routeName: string | null;
locationName: string | null;
sourceName: string;
Expand Down Expand Up @@ -385,8 +388,9 @@ function ClimbingEntryBreakdown({ entries }: { entries: ClimbingEntry[] }) {
)}
</View>
<View style={climbingStyles.resultDetails}>
{entry.ascentType && <Text style={climbingStyles.sent}>{entry.ascentType}</Text>}
<Text style={entry.sent ? climbingStyles.sent : climbingStyles.attempted}>
{entry.sent ? "Sent" : "Attempted"}
{formatClimbingAttemptResult(entry.sent, entry.attemptCount)}
</Text>
<Text style={climbingStyles.sourceName}>{entry.sourceName}</Text>
</View>
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/repositories/climbing-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ describe("ClimbingActivityEntry", () => {
gradeSystem: "v_scale",
grade: "V4",
sent: true,
attemptCount: 7,
ascentType: "Redpoint",
routeName: "Blue Arete",
locationName: "Pacific Pipe",
sourceName: "Kaya",
Expand All @@ -107,6 +109,8 @@ describe("ClimbingActivityEntry", () => {
gradeSystem: "v_scale",
grade: "V4",
sent: true,
attemptCount: 7,
ascentType: "Redpoint",
routeName: "Blue Arete",
locationName: "Pacific Pipe",
sourceName: "Kaya",
Expand Down Expand Up @@ -360,6 +364,8 @@ describe("ClimbingRepository", () => {
grade_system: "v_scale",
grade: "v4",
sent: true,
attempt_count: 7,
ascent_type: "Redpoint",
route_name: "Blue Arete",
location_name: "Pacific Pipe",
source_name: "Kaya",
Expand All @@ -376,6 +382,8 @@ describe("ClimbingRepository", () => {
gradeSystem: "v_scale",
grade: "V4",
sent: true,
attemptCount: 7,
ascentType: "Redpoint",
routeName: "Blue Arete",
locationName: "Pacific Pipe",
sourceName: "Kaya",
Expand All @@ -390,6 +398,8 @@ describe("ClimbingRepository", () => {
const text = queryText(execute.mock.calls[0]?.[0]);
expect(text).toContain("fitness.v_activity");
expect(text).toContain("ce.activity_id = ANY(a.member_activity_ids)");
expect(text).toContain("ce.attempt_count");
expect(text).toContain("ce.raw->>'ascentType'");
expect(text).toContain("ANY(a.member_activity_ids)");
expect(text).toContain("a.user_id = ");
expect(text).toContain("ORDER BY");
Expand Down
38 changes: 24 additions & 14 deletions packages/server/src/repositories/climbing-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,32 @@ export class ClimbingSessionSummary {
}
}

const climbTypeSchema = z.enum(["boulder", "route"]);
const gradeSystemSchema = z.enum(["v_scale", "yds"]);
const ascentTypeSchema = z.enum(["Flash", "Onsight", "Redpoint", "Repeat"]);

const activityEntryRowSchema = z.object({
id: z.string(),
climb_type: climbTypeSchema,
grade_system: gradeSystemSchema,
grade: z.string(),
sent: z.boolean(),
attempt_count: z.coerce.number().int().positive(),
ascent_type: ascentTypeSchema.nullable(),
route_name: z.string().nullable(),
location_name: z.string().nullable(),
source_name: z.string(),
});
type ClimbingActivityEntryDatabaseRow = z.infer<typeof activityEntryRowSchema>;

export interface ClimbingActivityEntryRow {
id: string;
climbType: ClimbingClimbType;
gradeSystem: ClimbingGradeSystem;
grade: string;
sent: boolean;
attemptCount: number;
ascentType: ClimbingActivityEntryDatabaseRow["ascent_type"];
routeName: string | null;
locationName: string | null;
sourceName: string;
Expand All @@ -120,9 +140,6 @@ export class ClimbingActivityEntry {
}
}

const climbTypeSchema = z.enum(["boulder", "route"]);
const gradeSystemSchema = z.enum(["v_scale", "yds"]);

const progressionRowSchema = z.object({
session_date: dateStringSchema,
climb_type: climbTypeSchema,
Expand Down Expand Up @@ -153,17 +170,6 @@ const sessionSummaryRowSchema = z.object({
hardest_route_grade_sort_value: z.coerce.number().nullable(),
});

const activityEntryRowSchema = z.object({
id: z.string(),
climb_type: climbTypeSchema,
grade_system: gradeSystemSchema,
grade: z.string(),
sent: z.boolean(),
route_name: z.string().nullable(),
location_name: z.string().nullable(),
source_name: z.string(),
});

const climbingGradeSortSql = sql`
CASE
WHEN ce.grade_system = 'v_scale' AND ce.grade = 'VB' THEN -1
Expand Down Expand Up @@ -335,6 +341,8 @@ export class ClimbingRepository extends BaseRepository {
ce.grade_system,
ce.grade,
ce.sent,
ce.attempt_count,
ce.raw->>'ascentType' AS ascent_type,
ce.route_name,
ce.location_name,
ce.source_name
Expand All @@ -354,6 +362,8 @@ export class ClimbingRepository extends BaseRepository {
gradeSystem: row.grade_system,
grade: normalizedGrade(row.grade),
sent: row.sent,
attemptCount: row.attempt_count,
ascentType: row.ascent_type,
routeName: row.route_name,
locationName: row.location_name,
sourceName: row.source_name,
Expand Down
8 changes: 7 additions & 1 deletion packages/server/src/routers/climbing.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe("climbing router integration", () => {
'Warmup',
'Touchstone Pacific Pipe',
'Kaya',
'{}'::jsonb
'{"ascentType":"Redpoint"}'::jsonb
),
(
${climbingActivityId},
Expand Down Expand Up @@ -238,18 +238,24 @@ describe("climbing router integration", () => {
grade: "V2",
routeName: "Warmup",
sent: true,
attemptCount: 2,
ascentType: "Redpoint",
}),
expect.objectContaining({
climbType: "boulder",
grade: "V4",
routeName: "Blue Circuit",
sent: true,
attemptCount: 3,
ascentType: null,
}),
expect.objectContaining({
climbType: "boulder",
grade: "V5",
routeName: "Project",
sent: false,
attemptCount: 4,
ascentType: null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
]);
});
Expand Down
21 changes: 19 additions & 2 deletions packages/web/src/pages/ActivityDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,10 +775,24 @@ describe("ActivityDetailPage", () => {
gradeSystem: "v_scale",
grade: "V4",
sent: true,
attemptCount: 7,
ascentType: "Redpoint",
routeName: "Blue Circuit",
locationName: "Touchstone Pacific Pipe",
sourceName: "Kaya",
},
{
id: "climb-project",
climbType: "boulder",
gradeSystem: "v_scale",
grade: "V5",
sent: false,
attemptCount: 1,
ascentType: null,
routeName: "Project",
locationName: "Touchstone Pacific Pipe",
sourceName: "Kaya",
},
],
isLoading: false,
});
Expand All @@ -790,8 +804,11 @@ describe("ActivityDetailPage", () => {
expect(screen.getByText("Climbs")).toBeDefined();
expect(screen.getByText("V4")).toBeDefined();
expect(screen.getByText("Blue Circuit")).toBeDefined();
expect(screen.getByText("Sent")).toBeDefined();
expect(screen.getByText("Touchstone Pacific Pipe")).toBeDefined();
expect(screen.getByText("Redpoint")).toBeDefined();
expect(screen.getByText("Sent in 7 attempts")).toBeDefined();
expect(screen.getByText("Project")).toBeDefined();
expect(screen.getByText("Attempted 1 time")).toBeDefined();
expect(screen.getAllByText("Touchstone Pacific Pipe")).toHaveLength(2);

Object.assign(mockActivity, originalData);
});
Expand Down
Loading
Loading