diff --git a/src/db/hooks/useVolumeStats.ts b/src/db/hooks/useVolumeStats.ts index 411b240..00f9450 100644 --- a/src/db/hooks/useVolumeStats.ts +++ b/src/db/hooks/useVolumeStats.ts @@ -3,7 +3,7 @@ */ import { useMemo } from 'react'; -import { useWorkouts } from './useWorkouts'; +import { useWorkouts, type ViewMode } from './useWorkouts'; import { useProfile } from './useProfiles'; import { useExerciseMappings } from './useExerciseMappings'; import { calculateMuscleVolume, aggregateToFunctionalGroups } from '@core/volume-calculator'; @@ -110,19 +110,27 @@ function convertSets(dbSets: WorkoutSet[]): WorkoutSet[] { return dbSets; } +export type { ViewMode }; + /** * Get volume statistics at the ScientificMuscle level + * @param profileId - Profile ID + * @param daysBackOrMode - Number of days back (legacy) or ViewMode ('last7days' | 'calendarWeek') */ export function useScientificMuscleVolume( profileId: string | null, - daysBack: number = 7 + daysBackOrMode: number | ViewMode = 7 ): { stats: VolumeStatItem[]; totalVolume: number; isLoading: boolean; error: Error | null; } { - const { workouts, isLoading: workoutsLoading, error } = useWorkouts(profileId, daysBack); + // Convert to options format for useWorkouts + const workoutsArg = typeof daysBackOrMode === 'number' + ? daysBackOrMode + : { mode: daysBackOrMode }; + const { workouts, isLoading: workoutsLoading, error } = useWorkouts(profileId, workoutsArg); const { profile } = useProfile(profileId); const { mappings: userMappings, isLoading: mappingsLoading } = useExerciseMappings(profileId); @@ -161,10 +169,12 @@ export function useScientificMuscleVolume( /** * Get volume statistics at the FunctionalGroup level + * @param profileId - Profile ID + * @param daysBackOrMode - Number of days back (legacy) or ViewMode ('last7days' | 'calendarWeek') */ export function useFunctionalGroupVolume( profileId: string | null, - daysBack: number = 7 + daysBackOrMode: number | ViewMode = 7 ): { stats: VolumeStatItem[]; totalVolume: number; @@ -172,7 +182,11 @@ export function useFunctionalGroupVolume( isLoading: boolean; error: Error | null; } { - const { workouts, isLoading: workoutsLoading, error } = useWorkouts(profileId, daysBack); + // Convert to options format for useWorkouts + const workoutsArg = typeof daysBackOrMode === 'number' + ? daysBackOrMode + : { mode: daysBackOrMode }; + const { workouts, isLoading: workoutsLoading, error } = useWorkouts(profileId, workoutsArg); const { profile } = useProfile(profileId); const { mappings: userMappings, isLoading: mappingsLoading } = useExerciseMappings(profileId); @@ -232,17 +246,20 @@ export function useFunctionalGroupVolume( /** * Get the breakdown of scientific muscles within a functional group + * @param profileId - Profile ID + * @param functionalGroup - Functional group to get breakdown for + * @param daysBackOrMode - Number of days back (legacy) or ViewMode ('last7days' | 'calendarWeek') */ export function useFunctionalGroupBreakdown( profileId: string | null, functionalGroup: FunctionalGroup, - daysBack: number = 7 + daysBackOrMode: number | ViewMode = 7 ): { breakdown: VolumeStatItem[]; isLoading: boolean; error: Error | null; } { - const { stats, isLoading, error } = useScientificMuscleVolume(profileId, daysBack); + const { stats, isLoading, error } = useScientificMuscleVolume(profileId, daysBackOrMode); const { profile } = useProfile(profileId); const breakdown = useMemo(() => { diff --git a/src/db/hooks/useWorkouts.ts b/src/db/hooks/useWorkouts.ts index a00f5a2..243555f 100644 --- a/src/db/hooks/useWorkouts.ts +++ b/src/db/hooks/useWorkouts.ts @@ -7,6 +7,8 @@ import { db, generateId, type Workout, type WorkoutSet } from '../schema'; const WORKOUTS_KEY = ['workouts']; +export type ViewMode = 'last7days' | 'calendarWeek'; + /** * Get the start of today in the user's local timezone */ @@ -15,25 +17,76 @@ function getStartOfToday(): Date { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } +/** + * Get the end of today in the user's local timezone (23:59:59.999) + */ +function getEndOfToday(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); +} + +/** + * Get the start of the current calendar week (Monday) + */ +function getStartOfWeek(): Date { + const now = new Date(); + const day = now.getDay(); + const diff = day === 0 ? -6 : 1 - day; // Adjust when Sunday (0) + const monday = new Date(now); + monday.setDate(now.getDate() + diff); + return new Date(monday.getFullYear(), monday.getMonth(), monday.getDate()); +} + +export interface UseWorkoutsOptions { + mode?: ViewMode; +} + /** * Get workouts for a profile within a date range + * @param profileId - Profile ID + * @param daysBackOrOptions - Number of days back (legacy) or options object with mode */ export function useWorkouts( profileId: string | null, - daysBack: number = 7 + daysBackOrOptions: number | UseWorkoutsOptions = 7 ): { workouts: Workout[]; isLoading: boolean; error: Error | null; } { + // Support both legacy daysBack number and new options object + const options: UseWorkoutsOptions = typeof daysBackOrOptions === 'number' + ? { mode: 'last7days' } + : daysBackOrOptions; + const mode = options.mode ?? 'last7days'; + + // For legacy daysBack support, extract the number + const daysBack = typeof daysBackOrOptions === 'number' ? daysBackOrOptions : 7; + const { data, isLoading, error } = useQuery({ - queryKey: [...WORKOUTS_KEY, profileId, daysBack], + queryKey: [...WORKOUTS_KEY, profileId, mode, daysBack], queryFn: async () => { if (!profileId) return []; - const endDate = new Date(); - const startDate = new Date(getStartOfToday()); - startDate.setDate(startDate.getDate() - daysBack + 1); + let startDate: Date; + let endDate: Date; + + if (typeof daysBackOrOptions === 'number') { + // Legacy behavior: rolling daysBack window + endDate = new Date(); + startDate = new Date(getStartOfToday()); + startDate.setDate(startDate.getDate() - daysBackOrOptions + 1); + } else if (mode === 'last7days') { + endDate = getEndOfToday(); + startDate = new Date(getStartOfToday()); + startDate.setDate(startDate.getDate() - 6); // 6 days back + today = 7 days + } else { + // calendarWeek mode + startDate = getStartOfWeek(); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 6); // Monday + 6 days = Sunday + endDate.setHours(23, 59, 59, 999); + } return db.workouts .where('profileId') diff --git a/src/ui/components/MuscleHeatmap.tsx b/src/ui/components/MuscleHeatmap.tsx index 7522d67..785addc 100644 --- a/src/ui/components/MuscleHeatmap.tsx +++ b/src/ui/components/MuscleHeatmap.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo, useId } from 'react'; -import { useScientificMuscleVolume } from '@db/hooks/useVolumeStats'; +import { useScientificMuscleVolume, type ViewMode } from '@db/hooks/useVolumeStats'; import type { ScientificMuscle } from '@core/taxonomy'; import { getVolumeColor, getNoTargetColor } from '@core/color-scale'; import Model from 'react-body-highlighter'; @@ -14,6 +14,7 @@ import type { IExerciseData, Muscle } from 'react-body-highlighter'; interface MuscleHeatmapProps { profileId: string | null; daysBack?: number; + viewMode?: ViewMode; } /** @@ -138,8 +139,10 @@ function getFrequencyLevel(percentage: number): number { * @param daysBack - Number of days to aggregate stats over (default: 7) * @returns A React element containing the split anterior/posterior muscle heatmap */ -export function MuscleHeatmap({ profileId, daysBack = 7 }: MuscleHeatmapProps): React.ReactElement { - const { stats, isLoading, error } = useScientificMuscleVolume(profileId, daysBack); +export function MuscleHeatmap({ profileId, daysBack = 7, viewMode }: MuscleHeatmapProps): React.ReactElement { + // Use viewMode if provided, otherwise fall back to daysBack + const volumeArg = viewMode ?? daysBack; + const { stats, isLoading, error } = useScientificMuscleVolume(profileId, volumeArg); // Map stats to muscle-level data const muscleStats = useMemo((): MuscleStats[] => { diff --git a/src/ui/components/TotalVolumeCard.tsx b/src/ui/components/TotalVolumeCard.tsx index 88b21f5..20e3956 100644 --- a/src/ui/components/TotalVolumeCard.tsx +++ b/src/ui/components/TotalVolumeCard.tsx @@ -3,13 +3,18 @@ * Shows total weekly volume with progress bar */ -import { useFunctionalGroupVolume } from '@db/hooks/useVolumeStats'; +import { useFunctionalGroupVolume, type ViewMode } from '@db/hooks/useVolumeStats'; import { useCurrentProfile } from '../context/ProfileContext'; -export function TotalVolumeCard(): React.ReactElement { +interface TotalVolumeCardProps { + viewMode?: ViewMode; +} + +export function TotalVolumeCard({ viewMode = 'last7days' }: TotalVolumeCardProps): React.ReactElement { const { currentProfile } = useCurrentProfile(); const { totalVolume, totalGoal, isLoading } = useFunctionalGroupVolume( - currentProfile?.id ?? null + currentProfile?.id ?? null, + viewMode ); if (isLoading) { diff --git a/src/ui/components/WeeklyActivityChart.tsx b/src/ui/components/WeeklyActivityChart.tsx index a7e30b7..9c281c9 100644 --- a/src/ui/components/WeeklyActivityChart.tsx +++ b/src/ui/components/WeeklyActivityChart.tsx @@ -8,12 +8,14 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from import { useDailyStats, type DailyActivity } from '../../db/hooks/useDailyStats'; import { useCurrentProfile } from '../context/ProfileContext'; +type ViewMode = 'last7days' | 'calendarWeek'; + interface WeeklyActivityChartProps { profileId?: string; + viewMode?: ViewMode; + onViewModeChange?: (mode: ViewMode) => void; } -type ViewMode = 'last7days' | 'calendarWeek'; - interface CustomTooltipProps { active?: boolean; payload?: Array<{ payload: DailyActivity }>; @@ -153,11 +155,23 @@ function DayDetailPanel({ day, onClose }: DayDetailPanelProps): React.ReactEleme */ export function WeeklyActivityChart({ profileId: propProfileId, + viewMode: controlledViewMode, + onViewModeChange, }: WeeklyActivityChartProps): React.ReactElement { const { currentProfile } = useCurrentProfile(); const profileId = propProfileId ?? currentProfile?.id ?? null; - const [viewMode, setViewMode] = useState('last7days'); + // Support both controlled and uncontrolled modes + const [internalViewMode, setInternalViewMode] = useState('last7days'); + const viewMode = controlledViewMode ?? internalViewMode; + const setViewMode = (mode: ViewMode): void => { + if (onViewModeChange) { + onViewModeChange(mode); + } else { + setInternalViewMode(mode); + } + }; + const [selectedDayIndex, setSelectedDayIndex] = useState(null); const { days, isLoading, error } = useDailyStats(profileId, { mode: viewMode }); diff --git a/src/ui/components/mobile/MobileCarousel.tsx b/src/ui/components/mobile/MobileCarousel.tsx index 0565cf9..4fe25aa 100644 --- a/src/ui/components/mobile/MobileCarousel.tsx +++ b/src/ui/components/mobile/MobileCarousel.tsx @@ -12,10 +12,12 @@ import { useState, useEffect, useCallback } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; import { MobileHeatmap } from '@ui/components/mobile/MobileHeatmap'; import { MobileMuscleList } from '@ui/components/mobile/MobileMuscleList'; +import type { ViewMode } from '@db/hooks/useWorkouts'; interface MobileCarouselProps { profileId: string | null; daysBack?: number; + viewMode?: ViewMode; } /** @@ -26,7 +28,8 @@ interface MobileCarouselProps { */ export function MobileCarousel({ profileId, - daysBack = 7 + daysBack = 7, + viewMode, }: MobileCarouselProps): React.ReactElement { // Initialize Embla with critical options const [emblaRef, emblaApi] = useEmblaCarousel({ @@ -71,13 +74,14 @@ export function MobileCarousel({ {/* Slide 2: Muscle List */}
- +
diff --git a/src/ui/components/mobile/MobileHeatmap.tsx b/src/ui/components/mobile/MobileHeatmap.tsx index dff22ef..468baf3 100644 --- a/src/ui/components/mobile/MobileHeatmap.tsx +++ b/src/ui/components/mobile/MobileHeatmap.tsx @@ -11,7 +11,7 @@ import { useMemo, useId, useState, useCallback, useEffect } from 'react'; import Model from 'react-body-highlighter'; import type { IExerciseData, Muscle } from 'react-body-highlighter'; -import { useScientificMuscleVolume } from '@db/hooks'; +import { useScientificMuscleVolume, type ViewMode } from '@db/hooks'; import { useEffectiveMuscleGroupConfig } from '@db/hooks/useMuscleGroups'; import type { ScientificMuscle } from '@core/taxonomy'; import { getVolumeColor, getNoTargetColor } from '@core/color-scale'; @@ -21,6 +21,7 @@ import { MuscleDetailModal } from './MuscleDetailModal'; interface MobileHeatmapProps { profileId: string | null; daysBack?: number; + viewMode?: ViewMode; isActive?: boolean; } @@ -134,9 +135,12 @@ interface MuscleStats { export function MobileHeatmap({ profileId, daysBack = 7, + viewMode, isActive = true, }: MobileHeatmapProps): React.ReactElement { - const { stats, isLoading, error } = useScientificMuscleVolume(profileId, daysBack); + // Use viewMode if provided, otherwise fall back to daysBack + const volumeArg = viewMode ?? daysBack; + const { stats, isLoading, error } = useScientificMuscleVolume(profileId, volumeArg); const { config } = useEffectiveMuscleGroupConfig(profileId); const [view, setView] = useSessionState<'front' | 'back'>( 'scientificmuscle_heatmap_view', @@ -341,6 +345,7 @@ export function MobileHeatmap({ relatedMuscles={selectedRegion ? REGION_TO_MUSCLES[selectedRegion].related : undefined} profileId={profileId} daysBack={daysBack} + viewMode={viewMode} /> ); diff --git a/src/ui/components/mobile/MobileMuscleList.tsx b/src/ui/components/mobile/MobileMuscleList.tsx index e9f5718..2b77b79 100644 --- a/src/ui/components/mobile/MobileMuscleList.tsx +++ b/src/ui/components/mobile/MobileMuscleList.tsx @@ -15,7 +15,7 @@ */ import { useState, useMemo, useEffect } from 'react'; -import { useScientificMuscleVolume, type VolumeStatItem } from '@db/hooks/useVolumeStats'; +import { useScientificMuscleVolume, type VolumeStatItem, type ViewMode } from '@db/hooks/useVolumeStats'; import { useEffectiveMuscleGroupConfig } from '@db/hooks/useMuscleGroups'; import { getVolumeColor } from '@core/color-scale'; import type { ScientificMuscle } from '@core/taxonomy'; @@ -23,6 +23,7 @@ import type { ScientificMuscle } from '@core/taxonomy'; interface MobileMuscleListProps { profileId: string | null; daysBack?: number; + viewMode?: ViewMode; } /** @@ -40,9 +41,12 @@ function formatVolume(volume: number): string { export function MobileMuscleList({ profileId, daysBack = 7, + viewMode, }: MobileMuscleListProps): React.ReactElement { // Fetch volume data for all muscles - const { stats, isLoading: volumeLoading, error } = useScientificMuscleVolume(profileId, daysBack); + // Use viewMode if provided, otherwise fall back to daysBack + const volumeArg = viewMode ?? daysBack; + const { stats, isLoading: volumeLoading, error } = useScientificMuscleVolume(profileId, volumeArg); // Fetch custom group configuration const { config, isLoading: configLoading } = useEffectiveMuscleGroupConfig(profileId); diff --git a/src/ui/components/mobile/MuscleDetailModal.tsx b/src/ui/components/mobile/MuscleDetailModal.tsx index f7c389b..ad21308 100644 --- a/src/ui/components/mobile/MuscleDetailModal.tsx +++ b/src/ui/components/mobile/MuscleDetailModal.tsx @@ -15,7 +15,7 @@ import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { useScientificMuscleVolume, type VolumeStatItem } from '@db/hooks/useVolumeStats'; +import { useScientificMuscleVolume, type VolumeStatItem, type ViewMode } from '@db/hooks/useVolumeStats'; import { getVolumeColor } from '@core/color-scale'; import type { ScientificMuscle } from '@core/taxonomy'; import type { BodyRegion } from './MobileHeatmap'; @@ -32,6 +32,7 @@ interface MuscleDetailModalProps { // Common profileId: string | null; daysBack?: number; + viewMode?: ViewMode; } interface MuscleData { @@ -107,11 +108,14 @@ export function MuscleDetailModal({ muscle, profileId, daysBack = 7, + viewMode, }: MuscleDetailModalProps): React.ReactElement | null { // Determine mode: single muscle vs region const isSingleMuscleMode = muscle !== undefined && muscle !== null; // Fetch volume data for all muscles - const { stats } = useScientificMuscleVolume(profileId, daysBack); + // Use viewMode if provided, otherwise fall back to daysBack + const volumeArg = viewMode ?? daysBack; + const { stats } = useScientificMuscleVolume(profileId, volumeArg); // Create stats map for O(1) muscle lookup const statsMap = useMemo(() => { diff --git a/src/ui/pages/Dashboard.tsx b/src/ui/pages/Dashboard.tsx index 2f77c42..7ea3625 100644 --- a/src/ui/pages/Dashboard.tsx +++ b/src/ui/pages/Dashboard.tsx @@ -13,11 +13,14 @@ import { MobileCarousel } from '@ui/components/mobile/MobileCarousel'; import { TotalVolumeCard } from '../components/TotalVolumeCard'; import { WeeklyActivityChart } from '../components/WeeklyActivityChart'; +type ViewMode = 'last7days' | 'calendarWeek'; + export function Dashboard(): React.ReactElement { const { currentProfile, isLoading } = useCurrentProfile(); const { count: unmappedCount } = useUnmappedExercises(currentProfile?.id ?? null); const isMobile = useIsMobileDevice(); const [dismissedAlert, setDismissedAlert] = useState(false); + const [viewMode, setViewMode] = useState('last7days'); if (isLoading) { return ( @@ -78,22 +81,24 @@ export function Dashboard(): React.ReactElement { {/* Total Weekly Volume */} - + {/* Weekly Activity Chart */} - + - {/* This Week Section */} + {/* Volume Display Section */}
{isMobile ? ( - + ) : ( <>
-

This Week

+

+ {viewMode === 'calendarWeek' ? 'This Week' : 'Last 7 Days'} +

- + )}