From f6b5782e0f6136f670d7823ec8c030294209b285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Sj=C3=B6str=C3=B6m?= Date: Tue, 19 May 2026 11:50:56 +0200 Subject: [PATCH] feat: add per-workout "By Feel" toggle to strip pace targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the runner keep workout structure (warmup/main/cooldown, step transitions for fuel taper) while removing all pace/HR targets so the watch shows step names and durations only — no nagging. Post-generation toggle in EventModal strips targets from existing descriptions via stripPaceTargets(). byFeel flag also threaded through generators for future pre-generation support. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/CalendarView.tsx | 7 + app/components/EventModal.tsx | 46 +++++- .../__tests__/EventModal.integration.test.tsx | 133 +++++++++++++++++- .../__tests__/clothing.integration.test.tsx | 9 ++ app/screens/IntelScreen.tsx | 1 + lib/__tests__/byFeel.test.ts | 44 ++++++ lib/__tests__/descriptionBuilder.test.ts | 68 ++++++++- lib/__tests__/workoutGenerators.test.ts | 12 ++ lib/byFeel.ts | 13 ++ lib/descriptionBuilder.ts | 9 ++ lib/types.ts | 2 + lib/workoutGenerators.ts | 19 +-- 12 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 lib/__tests__/byFeel.test.ts create mode 100644 lib/byFeel.ts diff --git a/app/components/CalendarView.tsx b/app/components/CalendarView.tsx index 6c6ec1b9..3459264c 100644 --- a/app/components/CalendarView.tsx +++ b/app/components/CalendarView.tsx @@ -161,6 +161,12 @@ export function CalendarView({ initialEvents, isLoadingInitial, initialError, on ); }; + const handleEventUpdated = (eventId: string, patch: { name: string; description: string }) => { + setEvents((prev) => + prev.map((e) => (e.id === eventId ? { ...e, ...patch } : e)), + ); + }; + // Handle delete from modal (planned events or completed activities) const handleDeleteEvent = async (eventId: string) => { // Best-effort Google Calendar sync @@ -397,6 +403,7 @@ export function CalendarView({ initialEvents, isLoadingInitial, initialError, on onClose={closeWorkoutModal} onDateSaved={handleDateSaved} onDelete={handleDeleteEvent} + onEventUpdated={handleEventUpdated} isLoadingStreamData={isLoadingStreamData} runBGContexts={runBGContexts} paceTable={paceTable} diff --git a/app/components/EventModal.tsx b/app/components/EventModal.tsx index 1837843d..1a041a3f 100644 --- a/app/components/EventModal.tsx +++ b/app/components/EventModal.tsx @@ -9,6 +9,8 @@ import { updateEvent } from "@/lib/intervalsClient"; import { syncToGoogleCalendar } from "@/lib/googleCalendar"; import { parseEventId, formatPace } from "@/lib/format"; import { getWorkoutCategory } from "@/lib/constants"; +import { isByFeel, addByFeel } from "@/lib/byFeel"; +import { stripPaceTargets } from "@/lib/descriptionBuilder"; import { getEventStatusBadge } from "@/lib/eventStyles"; import { useCurrentBG } from "../hooks/useCurrentBG"; import { currentTsbAtom, currentIobAtom } from "../atoms"; @@ -29,7 +31,8 @@ type EditMode = | { kind: "saving-date"; editDate: string } | { kind: "confirming-delete" } | { kind: "deleting" } - | { kind: "replacing" }; + | { kind: "replacing" } + | { kind: "toggling-by-feel" }; interface ModalState { editMode: EditMode; @@ -47,6 +50,9 @@ type ModalAction = | { type: "DELETE" } | { type: "DELETE_FAILED"; error: string } | { type: "START_REPLACE" } + | { type: "TOGGLE_BY_FEEL" } + | { type: "BY_FEEL_DONE" } + | { type: "BY_FEEL_FAILED"; error: string } | { type: "CANCEL" } | { type: "RESET" } | { type: "START_CLOSING" }; @@ -76,6 +82,12 @@ function modalReducer(state: ModalState, action: ModalAction): ModalState { return { ...state, editMode: { kind: "confirming-delete" }, error: action.error }; case "START_REPLACE": return { ...state, editMode: { kind: "replacing" }, error: null }; + case "TOGGLE_BY_FEEL": + return { ...state, editMode: { kind: "toggling-by-feel" }, error: null }; + case "BY_FEEL_DONE": + return INITIAL_MODAL_STATE; + case "BY_FEEL_FAILED": + return { ...state, editMode: { kind: "idle" }, error: action.error }; case "CANCEL": return { ...state, editMode: { kind: "idle" }, error: null }; case "RESET": @@ -90,6 +102,7 @@ interface EventModalProps { onClose: () => void; onDateSaved: (eventId: string, newDate: Date) => void; onDelete: (eventId: string) => Promise; + onEventUpdated: (eventId: string, patch: { name: string; description: string }) => void; /** Only relevant for completed events — shows a spinner while stream data loads. */ isLoadingStreamData?: boolean; runBGContexts?: Map; @@ -106,6 +119,7 @@ export function EventModal({ onClose, onDateSaved, onDelete, + onEventUpdated, isLoadingStreamData, runBGContexts, paceTable, @@ -185,6 +199,25 @@ export function EventModal({ } }; + const toggleByFeel = async () => { + const numericId = parseEventId(selectedEvent.id); + if (isNaN(numericId)) return; + + dispatch({ type: "TOGGLE_BY_FEEL" }); + try { + const newName = addByFeel(selectedEvent.name); + const newDescription = stripPaceTargets(selectedEvent.description); + await updateEvent(numericId, { name: newName, description: newDescription }); + onEventUpdated(selectedEvent.id, { name: newName, description: newDescription }); + dispatch({ type: "BY_FEEL_DONE" }); + } catch (err) { + console.error("Failed to toggle by feel:", err); + dispatch({ type: "BY_FEEL_FAILED", error: "Failed to update workout. Please try again." }); + } + }; + + const byFeel = isByFeel(selectedEvent.name); + return (
- {editMode.kind === "idle" && ( + {(editMode.kind === "idle" || editMode.kind === "toggling-by-feel") && ( <> {selectedEvent.type === "planned" && ( <> + {!byFeel && ( + + )}