From eb12d10a6c9399430b51dd8706763f19ccb4cb14 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Wed, 22 Jul 2026 14:28:42 -0700 Subject: [PATCH 1/3] fix(climbing): show attempt outcomes --- docs/production-incident-baseline.md | 41 ++++++++++++++++++ packages/format/src/format.test.ts | 10 +++++ packages/format/src/format.ts | 7 +++ packages/mobile/app/activity/[id].test.tsx | 43 ++++++++++++++----- packages/mobile/app/activity/[id].tsx | 6 ++- .../repositories/climbing-repository.test.ts | 10 +++++ .../src/repositories/climbing-repository.ts | 9 ++++ .../src/routers/climbing.integration.test.ts | 6 +++ .../web/src/pages/ActivityDetailPage.test.tsx | 21 ++++++++- packages/web/src/pages/ActivityDetailPage.tsx | 6 ++- src/providers/kaya/import.test.ts | 14 ++++++ src/providers/kaya/import.ts | 2 +- 12 files changed, 159 insertions(+), 16 deletions(-) diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index 065c1e8582..c1e142dfc1 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -15226,3 +15226,44 @@ 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. +- **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. diff --git a/packages/format/src/format.test.ts b/packages/format/src/format.test.ts index 0e9bb36f8d..04c1a403e8 100644 --- a/packages/format/src/format.test.ts +++ b/packages/format/src/format.test.ts @@ -4,6 +4,7 @@ import { formatBodyCompositionPercent, formatCalories, formatCaloriesMeasurement, + formatClimbingAttemptResult, formatDateForDisplay, formatDateLong, formatDateMedium, @@ -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"); diff --git a/packages/format/src/format.ts b/packages/format/src/format.ts index 0b660651de..5eea12c79f 100644 --- a/packages/format/src/format.ts +++ b/packages/format/src/format.ts @@ -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. */ diff --git a/packages/mobile/app/activity/[id].test.tsx b/packages/mobile/app/activity/[id].test.tsx index a55ff8522b..6c62fb828f 100644 --- a/packages/mobile/app/activity/[id].test.tsx +++ b/packages/mobile/app/activity/[id].test.tsx @@ -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(); + 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", () => ({})); @@ -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, }); @@ -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 () => { diff --git a/packages/mobile/app/activity/[id].tsx b/packages/mobile/app/activity/[id].tsx index f3b4d580c3..5210c2dc72 100644 --- a/packages/mobile/app/activity/[id].tsx +++ b/packages/mobile/app/activity/[id].tsx @@ -1,4 +1,5 @@ import { + formatClimbingAttemptResult, formatDateLong, formatDurationRange, formatDurationSeconds, @@ -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; @@ -385,8 +388,9 @@ function ClimbingEntryBreakdown({ entries }: { entries: ClimbingEntry[] }) { )} + {entry.ascentType && {entry.ascentType}} - {entry.sent ? "Sent" : "Attempted"} + {formatClimbingAttemptResult(entry.sent, entry.attemptCount)} {entry.sourceName} diff --git a/packages/server/src/repositories/climbing-repository.test.ts b/packages/server/src/repositories/climbing-repository.test.ts index 5e2d79e4b2..abe9cad5b7 100644 --- a/packages/server/src/repositories/climbing-repository.test.ts +++ b/packages/server/src/repositories/climbing-repository.test.ts @@ -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", @@ -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", @@ -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", @@ -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", @@ -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"); diff --git a/packages/server/src/repositories/climbing-repository.ts b/packages/server/src/repositories/climbing-repository.ts index a6d2f437e7..5c71f70df9 100644 --- a/packages/server/src/repositories/climbing-repository.ts +++ b/packages/server/src/repositories/climbing-repository.ts @@ -103,6 +103,8 @@ export interface ClimbingActivityEntryRow { gradeSystem: ClimbingGradeSystem; grade: string; sent: boolean; + attemptCount: number; + ascentType: "Flash" | "Onsight" | "Redpoint" | "Repeat" | null; routeName: string | null; locationName: string | null; sourceName: string; @@ -122,6 +124,7 @@ export class ClimbingActivityEntry { const climbTypeSchema = z.enum(["boulder", "route"]); const gradeSystemSchema = z.enum(["v_scale", "yds"]); +const ascentTypeSchema = z.enum(["Flash", "Onsight", "Redpoint", "Repeat"]); const progressionRowSchema = z.object({ session_date: dateStringSchema, @@ -159,6 +162,8 @@ const activityEntryRowSchema = z.object({ 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(), @@ -335,6 +340,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 @@ -354,6 +361,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, diff --git a/packages/server/src/routers/climbing.integration.test.ts b/packages/server/src/routers/climbing.integration.test.ts index 56aceda129..333850d891 100644 --- a/packages/server/src/routers/climbing.integration.test.ts +++ b/packages/server/src/routers/climbing.integration.test.ts @@ -238,18 +238,24 @@ describe("climbing router integration", () => { grade: "V2", routeName: "Warmup", sent: true, + attemptCount: 2, + ascentType: null, }), 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, }), ]); }); diff --git a/packages/web/src/pages/ActivityDetailPage.test.tsx b/packages/web/src/pages/ActivityDetailPage.test.tsx index 4968aa9aab..71df0856cb 100644 --- a/packages/web/src/pages/ActivityDetailPage.test.tsx +++ b/packages/web/src/pages/ActivityDetailPage.test.tsx @@ -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, }); @@ -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); }); diff --git a/packages/web/src/pages/ActivityDetailPage.tsx b/packages/web/src/pages/ActivityDetailPage.tsx index c3d7b7589e..c8a6d79a03 100644 --- a/packages/web/src/pages/ActivityDetailPage.tsx +++ b/packages/web/src/pages/ActivityDetailPage.tsx @@ -1,4 +1,5 @@ import { + formatClimbingAttemptResult, formatDateLong, formatDateTime, formatDurationSeconds, @@ -301,8 +302,11 @@ function ClimbingEntryBreakdown({ entries }: { entries: ClimbingActivityEntryRow
+ {entry.ascentType && ( +

{entry.ascentType}

+ )}

- {entry.sent ? "Sent" : "Attempted"} + {formatClimbingAttemptResult(entry.sent, entry.attemptCount)}

{entry.sourceName}

diff --git a/src/providers/kaya/import.test.ts b/src/providers/kaya/import.test.ts index 7699786b06..097807c2b4 100644 --- a/src/providers/kaya/import.test.ts +++ b/src/providers/kaya/import.test.ts @@ -220,6 +220,20 @@ Thu Jul 09 2026 14:22:19 GMT+0000 (GMT+00:00),0,,Redpoint,,v3,Pink,Route B,Touch }); }); + it("imports a flash as a successful first-attempt ascent", () => { + const csv = `${kayaHeader}\nThu Jul 09 2026 14:22:19 GMT+0000 (GMT+00:00),0,,Flash,1,v3,Pink,Named Problem,Touchstone Pacific Pipe,,`; + + const result = parseKayaExport(csv); + + expect(result.errors).toEqual([]); + expect(result.activities[0]?.entries[0]).toMatchObject({ + sent: true, + attemptCount: 1, + routeName: "Named Problem", + raw: expect.objectContaining({ ascentType: "Flash" }), + }); + }); + it("reports invalid rows with specific messages", () => { const cases = [ { diff --git a/src/providers/kaya/import.ts b/src/providers/kaya/import.ts index 67acf34788..3896ee3e86 100644 --- a/src/providers/kaya/import.ts +++ b/src/providers/kaya/import.ts @@ -23,7 +23,7 @@ const KAYA_HEADER = [ "country", ] as const; -const SENT_ASCENT_TYPES = new Set(["Redpoint", "Repeat", "Onsight"]); +const SENT_ASCENT_TYPES = new Set(["Flash", "Onsight", "Redpoint", "Repeat"]); const attemptCountSchema = z.number().int().min(1).max(2_147_483_647); interface KayaDecodedRow { From 667f6ed34a48d57fc0676be347bce31f863e3e71 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Wed, 22 Jul 2026 15:21:44 -0700 Subject: [PATCH 2/3] docs: record climbing PR validation --- docs/production-incident-baseline.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index c1e142dfc1..12a21d107d 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -15261,7 +15261,11 @@ Drizzle schema and runtime Zod schemas. Findings and remediations: 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. + 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, From af0b7f4655154def1c0646383278bdae70a54df8 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Wed, 22 Jul 2026 15:37:45 -0700 Subject: [PATCH 3/3] refactor(climbing): address PR review --- .../src/repositories/climbing-repository.ts | 37 ++++---- .../src/routers/climbing.integration.test.ts | 4 +- packages/web/src/pages/ActivityDetailPage.tsx | 92 +------------------ .../components/ClimbingEntryBreakdown.tsx | 38 ++++++++ .../components/DeleteActivityButton.tsx | 53 +++++++++++ 5 files changed, 115 insertions(+), 109 deletions(-) create mode 100644 packages/web/src/pages/activity-detail/components/ClimbingEntryBreakdown.tsx create mode 100644 packages/web/src/pages/activity-detail/components/DeleteActivityButton.tsx diff --git a/packages/server/src/repositories/climbing-repository.ts b/packages/server/src/repositories/climbing-repository.ts index 5c71f70df9..307ed20b09 100644 --- a/packages/server/src/repositories/climbing-repository.ts +++ b/packages/server/src/repositories/climbing-repository.ts @@ -97,6 +97,24 @@ 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; + export interface ClimbingActivityEntryRow { id: string; climbType: ClimbingClimbType; @@ -104,7 +122,7 @@ export interface ClimbingActivityEntryRow { grade: string; sent: boolean; attemptCount: number; - ascentType: "Flash" | "Onsight" | "Redpoint" | "Repeat" | null; + ascentType: ClimbingActivityEntryDatabaseRow["ascent_type"]; routeName: string | null; locationName: string | null; sourceName: string; @@ -122,10 +140,6 @@ export class ClimbingActivityEntry { } } -const climbTypeSchema = z.enum(["boulder", "route"]); -const gradeSystemSchema = z.enum(["v_scale", "yds"]); -const ascentTypeSchema = z.enum(["Flash", "Onsight", "Redpoint", "Repeat"]); - const progressionRowSchema = z.object({ session_date: dateStringSchema, climb_type: climbTypeSchema, @@ -156,19 +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(), - 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(), -}); - const climbingGradeSortSql = sql` CASE WHEN ce.grade_system = 'v_scale' AND ce.grade = 'VB' THEN -1 diff --git a/packages/server/src/routers/climbing.integration.test.ts b/packages/server/src/routers/climbing.integration.test.ts index 333850d891..902e361d5a 100644 --- a/packages/server/src/routers/climbing.integration.test.ts +++ b/packages/server/src/routers/climbing.integration.test.ts @@ -119,7 +119,7 @@ describe("climbing router integration", () => { 'Warmup', 'Touchstone Pacific Pipe', 'Kaya', - '{}'::jsonb + '{"ascentType":"Redpoint"}'::jsonb ), ( ${climbingActivityId}, @@ -239,7 +239,7 @@ describe("climbing router integration", () => { routeName: "Warmup", sent: true, attemptCount: 2, - ascentType: null, + ascentType: "Redpoint", }), expect.objectContaining({ climbType: "boulder", diff --git a/packages/web/src/pages/ActivityDetailPage.tsx b/packages/web/src/pages/ActivityDetailPage.tsx index c8a6d79a03..057a6e0acb 100644 --- a/packages/web/src/pages/ActivityDetailPage.tsx +++ b/packages/web/src/pages/ActivityDetailPage.tsx @@ -1,5 +1,4 @@ import { - formatClimbingAttemptResult, formatDateLong, formatDateTime, formatDurationSeconds, @@ -23,10 +22,9 @@ import { formatActivityTypeLabel, isCyclingActivity, } from "@dofek/training/training"; -import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { Link, useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ActivityDetail } from "../../../server/src/models/activity.ts"; -import type { ClimbingActivityEntryRow } from "../../../server/src/repositories/climbing-repository.ts"; import type { StreamPoint, StrengthExerciseDetail } from "../../../server/src/routers/activity.ts"; import { ActivityExportDropdown } from "../components/ActivityExportDropdown.tsx"; import { ChartDescriptionTooltip } from "../components/ChartDescriptionTooltip.tsx"; @@ -44,6 +42,8 @@ import { } from "../lib/chartTheme.ts"; import { trpc } from "../lib/trpc.ts"; import { useUnitConverter } from "../lib/unitContext.ts"; +import { ClimbingEntryBreakdown } from "./activity-detail/components/ClimbingEntryBreakdown.tsx"; +import { DeleteActivityButton } from "./activity-detail/components/DeleteActivityButton.tsx"; import { RecomputeActivityButton } from "./activity-detail/components/RecomputeActivityButton.tsx"; import { ProviderAbsentBanner } from "./ProviderAbsentBanner.tsx"; @@ -280,92 +280,6 @@ export function ActivityDetailPage() { ); } -function ClimbingEntryBreakdown({ entries }: { entries: ClimbingActivityEntryRow[] }) { - return ( -
- {entries.map((entry) => ( -
-
- - {entry.grade} - -
-

- {entry.routeName ?? (entry.climbType === "boulder" ? "Boulder" : "Route")} -

- {entry.locationName && ( -

{entry.locationName}

- )} -
-
-
- {entry.ascentType && ( -

{entry.ascentType}

- )} -

- {formatClimbingAttemptResult(entry.sent, entry.attemptCount)} -

-

{entry.sourceName}

-
-
- ))} -
- ); -} - -function DeleteActivityButton({ activityId }: { activityId: string }) { - const [showConfirm, setShowConfirm] = useState(false); - const navigate = useNavigate(); - const trpcUtils = trpc.useUtils(); - const deleteMutation = trpc.activity.delete.useMutation({ - onSuccess: async () => { - await Promise.all([ - trpcUtils.activity.list.invalidate(), - trpcUtils.calendar.weekList.invalidate(), - trpcUtils.calendar.activityOverview.invalidate(), - ]); - navigate({ to: "/dashboard" }); - }, - }); - - if (showConfirm) { - return ( -
- Delete this activity? This cannot be undone. - - -
- ); - } - - return ( - - ); -} - export function ActivityHeader({ activity, units, diff --git a/packages/web/src/pages/activity-detail/components/ClimbingEntryBreakdown.tsx b/packages/web/src/pages/activity-detail/components/ClimbingEntryBreakdown.tsx new file mode 100644 index 0000000000..18d9b82766 --- /dev/null +++ b/packages/web/src/pages/activity-detail/components/ClimbingEntryBreakdown.tsx @@ -0,0 +1,38 @@ +import { formatClimbingAttemptResult } from "@dofek/format/format"; +import type { ClimbingActivityEntryRow } from "../../../../../server/src/repositories/climbing-repository.ts"; + +export function ClimbingEntryBreakdown({ entries }: { entries: ClimbingActivityEntryRow[] }) { + return ( +
+ {entries.map((entry) => ( +
+
+ + {entry.grade} + +
+

+ {entry.routeName ?? (entry.climbType === "boulder" ? "Boulder" : "Route")} +

+ {entry.locationName && ( +

{entry.locationName}

+ )} +
+
+
+ {entry.ascentType && ( +

{entry.ascentType}

+ )} +

+ {formatClimbingAttemptResult(entry.sent, entry.attemptCount)} +

+

{entry.sourceName}

+
+
+ ))} +
+ ); +} diff --git a/packages/web/src/pages/activity-detail/components/DeleteActivityButton.tsx b/packages/web/src/pages/activity-detail/components/DeleteActivityButton.tsx new file mode 100644 index 0000000000..2760856f0e --- /dev/null +++ b/packages/web/src/pages/activity-detail/components/DeleteActivityButton.tsx @@ -0,0 +1,53 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { trpc } from "../../../lib/trpc.ts"; + +export function DeleteActivityButton({ activityId }: { activityId: string }) { + const [showConfirm, setShowConfirm] = useState(false); + const navigate = useNavigate(); + const trpcUtils = trpc.useUtils(); + const deleteMutation = trpc.activity.delete.useMutation({ + onSuccess: async () => { + await Promise.all([ + trpcUtils.activity.list.invalidate(), + trpcUtils.calendar.weekList.invalidate(), + trpcUtils.calendar.activityOverview.invalidate(), + ]); + navigate({ to: "/dashboard" }); + }, + }); + + if (showConfirm) { + return ( +
+ Delete this activity? This cannot be undone. + + +
+ ); + } + + return ( + + ); +}