Skip to content
Merged
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
117 changes: 72 additions & 45 deletions apps/web/src/modules/fizruk/pages/Workouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { PullToRefresh } from "@shared/components/ui/PullToRefresh";
import { Button } from "@shared/components/ui/Button";
import { ConfirmDialog } from "@shared/components/ui/ConfirmDialog";
import { Skeleton } from "@shared/components/ui/Skeleton";
import {
DataState,
type DataStateQueryLike,
} from "@shared/components/ui/DataState";
import { useToast } from "@shared/hooks/useToast";
import { showUndoToast } from "@shared/lib/ui/undoToast";
import { WorkoutTemplatesSection } from "../components/WorkoutTemplatesSection";
Expand Down Expand Up @@ -36,6 +40,7 @@ import { useWorkouts } from "../hooks/useWorkouts";
import { recoveryConflictsForExercise } from "@sergeant/fizruk-domain";
import type { RawExerciseDef } from "@sergeant/fizruk-domain/data";
import type {
Workout,
WorkoutFinishSummary,
WorkoutGroup,
WorkoutItem,
Expand Down Expand Up @@ -446,6 +451,33 @@ export function Workouts() {
// re-render automatically once the engine writes new state.
const handlePullRefresh = useCallback(() => requestCloudPull(2500), []);

// DataState contract for the workout journal:
// - `useWorkouts` flips `loaded` from false → true after the first
// hydration tick. While `loaded === false` we feed `data: undefined`
// so DataState renders the skeleton slot; from the second tick on
// `data` is the real list (possibly empty), which lets the journal
// render its own "порожньо" empty-state without flashing it during
// mount.
// - `isLoading` mirrors the inverted `loaded` flag so a future
// stale-revalidate (cloud pull → re-merge) keeps the list visible.
const journalQuery: DataStateQueryLike<readonly Workout[]> = {
data: workoutsLoaded ? workouts : undefined,
isLoading: !workoutsLoaded,
};

const workoutsLoadingSkeleton = (
<div
className="space-y-3"
role="status"
aria-live="polite"
aria-label="Завантажуємо тренування"
>
<Skeleton className="h-28 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
);

return (
<PullToRefresh onRefresh={handlePullRefresh} variant="fizruk">
<div className="max-w-4xl mx-auto px-4 pt-4 page-tabbar-pad">
Expand Down Expand Up @@ -511,51 +543,46 @@ export function Workouts() {
/>
) : null}

{view === "log" && !workoutsLoaded && (
// First-paint placeholder while `useWorkouts` is still rehydrating
// from `localStorage` (one tick on mount). Prevents the "порожньо"
// empty-state from flashing before real data renders — matches the
// Skeleton pattern already used in Finyk.
<div
className="space-y-3"
role="status"
aria-live="polite"
aria-label="Завантажуємо тренування"
>
<Skeleton className="h-28 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
)}
{view === "log" && workoutsLoaded && (
<WorkoutJournalSection
activeWorkout={activeWorkout}
activeDuration={activeDuration}
workouts={workouts}
activeWorkoutId={activeWorkoutId}
setActiveWorkoutId={setActiveWorkoutId}
retroOpen={retroOpen}
setRetroOpen={setRetroOpen}
retroDate={retroDate}
setRetroDate={setRetroDate}
retroTime={retroTime}
setRetroTime={setRetroTime}
createWorkout={createWorkout}
setMode={setView}
musclesUk={musclesUk}
recBy={rec.by}
lastByExerciseId={lastByExerciseId}
setRestTimer={setRestTimer}
updateWorkout={updateWorkout}
updateItem={updateItem}
removeItem={removeItemWithUndo}
setFinishFlash={setFinishFlash}
endWorkout={endWorkout}
summarizeWorkoutForFinish={summarizeWorkoutForFinish}
submitRetroWorkout={submitRetroWorkout}
deleteWorkout={deleteWorkout}
restoreWorkout={restoreWorkout}
/>
{view === "log" && (
// DataState contract: `data === undefined` triggers the skeleton
// slot. `useWorkouts` flips `loaded` from false → true after one
// tick on mount (it rehydrates from `localStorage` / SQLite),
// so on first paint we feed `data: undefined` and from the
// second tick onwards `data` is the real list — even when it
// happens to be empty. This prevents the "порожньо" empty-state
// inside `WorkoutJournalSection` from flashing during hydration.
<DataState query={journalQuery} skeleton={workoutsLoadingSkeleton}>
{() => (
Comment on lines +554 to +555
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

DataState will treat empty workouts arrays as empty-state and skip WorkoutJournalSection.

With current props, once loading finishes and workouts is [], DataState default emptiness handling can render its own empty slot instead of the journal’s internal empty UI. That regresses the intended behavior described in this PR.

Suggested fix
-          <DataState query={journalQuery} skeleton={workoutsLoadingSkeleton}>
+          <DataState
+            query={journalQuery}
+            skeleton={workoutsLoadingSkeleton}
+            isEmpty={() => false}
+          >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<DataState query={journalQuery} skeleton={workoutsLoadingSkeleton}>
{() => (
<DataState
query={journalQuery}
skeleton={workoutsLoadingSkeleton}
isEmpty={() => false}
>
{() => (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/modules/fizruk/pages/Workouts.tsx` around lines 554 - 555,
DataState is treating an empty workouts array as empty-state and skipping
WorkoutJournalSection; update the DataState usage (which currently receives
journalQuery) to only treat null/undefined as empty by supplying a custom
emptiness checker so an empty array still renders the journal UI. Locate
DataState with journalQuery and add an isEmpty (or equivalent prop supported by
DataState) that returns true only when data == null/undefined (e.g., isEmpty:
data => data == null) so WorkoutJournalSection sees an empty workouts array and
can render its own empty UI.

<WorkoutJournalSection
activeWorkout={activeWorkout}
activeDuration={activeDuration}
workouts={workouts}
activeWorkoutId={activeWorkoutId}
setActiveWorkoutId={setActiveWorkoutId}
retroOpen={retroOpen}
setRetroOpen={setRetroOpen}
retroDate={retroDate}
setRetroDate={setRetroDate}
retroTime={retroTime}
setRetroTime={setRetroTime}
createWorkout={createWorkout}
setMode={setView}
musclesUk={musclesUk}
recBy={rec.by}
lastByExerciseId={lastByExerciseId}
setRestTimer={setRestTimer}
updateWorkout={updateWorkout}
updateItem={updateItem}
removeItem={removeItemWithUndo}
setFinishFlash={setFinishFlash}
endWorkout={endWorkout}
summarizeWorkoutForFinish={summarizeWorkoutForFinish}
submitRetroWorkout={submitRetroWorkout}
deleteWorkout={deleteWorkout}
restoreWorkout={restoreWorkout}
/>
)}
</DataState>
)}

{view === "templates" && (
Expand Down
Loading