Skip to content
Draft
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
47 changes: 47 additions & 0 deletions SparkyFitnessMobile/__tests__/hooks/useDailySummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fetchDailyGoals } from '../../src/services/api/goalsApi';
import { fetchFoodEntries } from '../../src/services/api/foodEntriesApi';
import { fetchExerciseEntries } from '../../src/services/api/exerciseApi';
import { fetchWaterIntake } from '../../src/services/api/measurementsApi';
import { fetchDashboardStats } from '../../src/services/api/dashboardApi';
import { createTestQueryClient, createQueryWrapper, type QueryClient } from './queryTestUtils';

jest.mock('../../src/services/api/goalsApi', () => ({
Expand All @@ -31,6 +32,10 @@ jest.mock('../../src/services/api/measurementsApi', () => ({
fetchWaterIntake: jest.fn(),
}));

jest.mock('../../src/services/api/dashboardApi', () => ({
fetchDashboardStats: jest.fn(),
}));

jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn((callback) => {
callback();
Expand All @@ -41,13 +46,18 @@ const mockFetchDailyGoals = fetchDailyGoals as jest.MockedFunction<typeof fetchD
const mockFetchFoodEntries = fetchFoodEntries as jest.MockedFunction<typeof fetchFoodEntries>;
const mockFetchExerciseEntries = fetchExerciseEntries as jest.MockedFunction<typeof fetchExerciseEntries>;
const mockFetchWaterIntake = fetchWaterIntake as jest.MockedFunction<typeof fetchWaterIntake>;
const mockFetchDashboardStats = fetchDashboardStats as jest.MockedFunction<typeof fetchDashboardStats>;

describe('useDailySummary', () => {
let queryClient: QueryClient;

beforeEach(() => {
jest.clearAllMocks();
mockFetchWaterIntake.mockResolvedValue({ water_ml: 0 });
mockFetchDashboardStats.mockResolvedValue({
eaten: 0, burned: 0, remaining: 0, goal: 0, net: 0, progress: 0,
steps: 0, stepCalories: 0, bmr: 0, unit: 'kcal',
});
queryClient = createTestQueryClient();
});

Expand Down Expand Up @@ -191,6 +201,43 @@ describe('useDailySummary', () => {
expect(result.current.summary?.waterGoal).toBe(2500);
});

test('includes server-computed stepCalories in summary', async () => {
mockFetchDailyGoals.mockResolvedValue({ calories: 2000, protein: 150, carbs: 200, fat: 65, dietary_fiber: 30 });
mockFetchFoodEntries.mockResolvedValue([]);
mockFetchExerciseEntries.mockResolvedValue([]);
mockFetchDashboardStats.mockResolvedValue({
eaten: 0, burned: 203, remaining: 1797, goal: 2000, net: -203, progress: 0,
steps: 5000, stepCalories: 105, bmr: 1600, unit: 'kcal',
});

const { result } = renderHook(() => useDailySummary({ date: testDate }), {
wrapper: createQueryWrapper(queryClient),
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.summary?.stepCalories).toBe(105);
});

test('gracefully handles dashboard stats API failure with stepCalories defaulting to 0', async () => {
mockFetchDailyGoals.mockResolvedValue({ calories: 2000, protein: 150, carbs: 200, fat: 65, dietary_fiber: 30 });
mockFetchFoodEntries.mockResolvedValue([]);
mockFetchExerciseEntries.mockResolvedValue([]);
mockFetchDashboardStats.mockRejectedValue(new Error('Server error'));

const { result } = renderHook(() => useDailySummary({ date: testDate }), {
wrapper: createQueryWrapper(queryClient),
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.summary?.stepCalories).toBe(0);
});

test('calculates net and remaining calories correctly', async () => {
mockFetchDailyGoals.mockResolvedValue({
calories: 2000,
Expand Down
99 changes: 99 additions & 0 deletions SparkyFitnessMobile/__tests__/services/api/dashboardApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { fetchDashboardStats, type DashboardStats } from '../../../src/services/api/dashboardApi';
import { getActiveServerConfig, ServerConfig } from '../../../src/services/storage';

jest.mock('../../../src/services/storage', () => ({
getActiveServerConfig: jest.fn(),
proxyHeadersToRecord: jest.requireActual('../../../src/services/storage').proxyHeadersToRecord,
}));

jest.mock('../../../src/services/LogService', () => ({
addLog: jest.fn(),
}));

const mockGetActiveServerConfig = getActiveServerConfig as jest.MockedFunction<
typeof getActiveServerConfig
>;

describe('dashboardApi', () => {
const mockFetch = jest.fn();

beforeEach(() => {
jest.resetAllMocks();
(globalThis as any).fetch = mockFetch;
Copy link
Contributor

Choose a reason for hiding this comment

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

the actual implementation uses apiFetch in apiClient.ts so this isn't testing the real code path

jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

const testConfig: ServerConfig = {
id: 'test-id',
url: 'https://example.com',
apiKey: 'test-api-key-12345',
};

const mockStats: DashboardStats = {
eaten: 1200,
burned: 350,
remaining: 1450,
goal: 2000,
net: 850,
progress: 43,
steps: 5000,
stepCalories: 105,
bmr: 1600,
unit: 'kcal',
};

describe('fetchDashboardStats', () => {
test('sends GET request to /api/dashboard/stats with date', async () => {
mockGetActiveServerConfig.mockResolvedValue(testConfig);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStats),
});

await fetchDashboardStats('2026-03-24');

expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api/dashboard/stats?date=2026-03-24',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer test-api-key-12345',
}),
}),
);
});

test('returns parsed stats including stepCalories', async () => {
mockGetActiveServerConfig.mockResolvedValue(testConfig);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStats),
});

const result = await fetchDashboardStats('2026-03-24');

expect(result.steps).toBe(5000);
expect(result.stepCalories).toBe(105);
expect(result.eaten).toBe(1200);
expect(result.unit).toBe('kcal');
});

test('throws error on non-OK response', async () => {
mockGetActiveServerConfig.mockResolvedValue(testConfig);
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
});

await expect(fetchDashboardStats('2026-03-24')).rejects.toThrow(
'Server error: 401 - Unauthorized',
);
});
});
});
21 changes: 6 additions & 15 deletions SparkyFitnessMobile/__tests__/services/calculations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
calculateAge,
calculateBmr,
calculateBodyFatNavy,
stepsToCalories,
calculateEffectiveBurned,
calculateCalorieBalance,
} from '../../src/services/calculations';
Expand Down Expand Up @@ -128,20 +127,12 @@ describe('calculations', () => {
});
});

describe('stepsToCalories', () => {
it('converts steps to calories at 0.04 kcal per step', () => {
expect(stepsToCalories(10000)).toBe(400);
expect(stepsToCalories(5000)).toBe(200);
expect(stepsToCalories(0)).toBe(0);
});
});

describe('calculateEffectiveBurned', () => {
it('prioritizes exercise calories over active calories and steps', () => {
it('prioritizes exercise calories over active calories and step calories', () => {
const burned = calculateEffectiveBurned({
activeCalories: 300,
otherExerciseCalories: 150,
steps: 10000,
stepCalories: 203,
});
expect(burned).toBe(150);
});
Expand All @@ -150,18 +141,18 @@ describe('calculations', () => {
const burned = calculateEffectiveBurned({
activeCalories: 300,
otherExerciseCalories: 0,
steps: 10000,
stepCalories: 203,
});
expect(burned).toBe(300);
});

it('falls back to step-derived calories when no exercise or active calories', () => {
it('falls back to server-computed step calories when no exercise or active calories', () => {
const burned = calculateEffectiveBurned({
activeCalories: 0,
otherExerciseCalories: 0,
steps: 10000,
stepCalories: 203,
});
expect(burned).toBe(400); // 10000 * 0.04
expect(burned).toBe(203);
});
});

Expand Down
10 changes: 7 additions & 3 deletions SparkyFitnessMobile/src/hooks/useDailySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
calculateExerciseDuration,
} from '../services/api/exerciseApi';
import { fetchWaterIntake } from '../services/api/measurementsApi';
import { fetchDashboardStats } from '../services/api/dashboardApi';
import type { DailySummary } from '../types/dailySummary';
import type { DailyGoals } from '../types/goals';
import type { FoodEntry } from '../types/foodEntries';
Expand All @@ -29,6 +30,7 @@ export interface DailySummaryRawData {
foodEntries: FoodEntry[];
exerciseEntries: ExerciseSessionResponse[];
waterIntake: WaterIntake;
stepCalories: number;
}

interface UseDailySummaryOptions {
Expand All @@ -40,17 +42,18 @@ export function useDailySummary({ date, enabled = true }: UseDailySummaryOptions
const query = useQuery({
queryKey: dailySummaryQueryKey(date),
queryFn: async () => {
const [goals, foodEntries, exerciseEntries, waterIntake] = await Promise.all([
const [goals, foodEntries, exerciseEntries, waterIntake, dashboardStats] = await Promise.all([
fetchDailyGoals(date),
fetchFoodEntries(date),
fetchExerciseEntries(date),
fetchWaterIntake(date).catch(() => ({ water_ml: 0 })),
fetchDashboardStats(date).catch(() => ({ stepCalories: 0 })),
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For better type safety and consistency with how other failed fetches are handled (like fetchWaterIntake), it's a good practice for the .catch block to return an object that conforms to the DashboardStats interface, even if it's just a default/fallback version. This makes the code more robust for future modifications where other properties of dashboardStats might be used.

A default DashboardStats object could be defined and reused, perhaps in dashboardApi.ts.

Suggested change
fetchDashboardStats(date).catch(() => ({ stepCalories: 0 })),
fetchDashboardStats(date).catch(() => ({ eaten: 0, burned: 0, remaining: 0, goal: 0, net: 0, progress: 0, steps: 0, stepCalories: 0, bmr: 0, unit: 'kcal' })),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The extra zeroed fields don't matter since the hook only reads stepCalories anyways. There's no real type issue if the shape allows partials.

]);

return { goals, foodEntries, exerciseEntries, waterIntake };
return { goals, foodEntries, exerciseEntries, waterIntake, stepCalories: dashboardStats.stepCalories };
},
select: (raw): DailySummary => {
const { goals, foodEntries, exerciseEntries, waterIntake } = raw;
const { goals, foodEntries, exerciseEntries, waterIntake, stepCalories } = raw;

const calorieGoal = goals.calories || 0;
const caloriesConsumed = calculateCaloriesConsumed(foodEntries);
Expand All @@ -68,6 +71,7 @@ export function useDailySummary({ date, enabled = true }: UseDailySummaryOptions
caloriesBurned,
activeCalories,
otherExerciseCalories,
stepCalories,
exerciseMinutes,
exerciseMinutesGoal: goals.target_exercise_duration_minutes || 0,
exerciseCaloriesGoal: goals.target_exercise_calories_burned || 0,
Expand Down
4 changes: 2 additions & 2 deletions SparkyFitnessMobile/src/screens/DashboardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const DashboardScreen: React.FC<DashboardScreenProps> = ({ navigation }) => {
const { preferences, isLoading: isPreferencesLoading, isError: isPreferencesError, refetch: refetchPreferences } = usePreferences({
enabled: isConnected,
});
const { measurements, isLoading: isMeasurementsLoading, isError: isMeasurementsError, refetch: refetchMeasurements } = useMeasurements({
const { isLoading: isMeasurementsLoading, isError: isMeasurementsError, refetch: refetchMeasurements } = useMeasurements({
date: selectedDate,
enabled: isConnected,
});
Expand Down Expand Up @@ -197,7 +197,7 @@ const DashboardScreen: React.FC<DashboardScreenProps> = ({ navigation }) => {
const totalBurned = calculateEffectiveBurned({
activeCalories: summary.activeCalories,
otherExerciseCalories: summary.otherExerciseCalories,
steps: measurements?.steps || 0,
stepCalories: summary.stepCalories,
});

const { netCalories, remainingCalories } = calculateCalorieBalance({
Expand Down
22 changes: 22 additions & 0 deletions SparkyFitnessMobile/src/services/api/dashboardApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { apiFetch } from './apiClient';

export interface DashboardStats {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a shared package (just for web and mobile right now) in the /shared/src/ folder that uses zod to validate and then infer types. We are moving new code to that pattern

eaten: number;
burned: number;
remaining: number;
goal: number;
net: number;
progress: number;
steps: number;
stepCalories: number;
bmr: number;
unit: string;
}

export const fetchDashboardStats = async (date: string): Promise<DashboardStats> => {
Copy link
Contributor

Choose a reason for hiding this comment

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

should encodeURIComponent the date to be sure

return apiFetch<DashboardStats>({
endpoint: `/api/dashboard/stats?date=${date}`,
serviceName: 'Dashboard API',
operation: 'fetch dashboard stats',
});
};
16 changes: 4 additions & 12 deletions SparkyFitnessMobile/src/services/calculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,38 +102,30 @@ export function calculateBodyFatNavy({
);
}

/**
* Converts steps to estimated calories burned.
* Uses a simple approximation of 0.04 kcal per step.
*/
export function stepsToCalories(steps: number): number {
return steps * 0.04;
}

interface EffectiveBurnedParams {
activeCalories: number;
otherExerciseCalories: number;
steps: number;
stepCalories: number; // Server-computed via stride formula (height/weight-aware)
}

/**
* Calculates effective burned calories using a priority cascade:
* 1. Manually logged exercise calories (what the user explicitly entered)
* 2. Active calories from a watch/tracker (more accurate than steps)
* 3. Step-derived calorie estimate (fallback)
* 3. Server-computed step calories (fallback)
*/
export function calculateEffectiveBurned({
activeCalories,
otherExerciseCalories,
steps,
stepCalories,
}: EffectiveBurnedParams): number {
if (otherExerciseCalories > 0) {
return otherExerciseCalories;
}
if (activeCalories > 0) {
return activeCalories;
}
return stepsToCalories(steps);
return stepCalories;
}

interface CalorieBalanceParams {
Expand Down
1 change: 1 addition & 0 deletions SparkyFitnessMobile/src/types/dailySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface DailySummary {
carbs: MacroSummary;
fat: MacroSummary;
fiber: MacroSummary;
stepCalories: number; // Server-computed step calories using stride formula
exerciseMinutes: number;
exerciseMinutesGoal: number;
exerciseCaloriesGoal: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Migration: Add steps column to exercise_preset_entries table
-- The previous migration (20260323122741) only added steps to exercise_entries.
-- The exercise history service also queries steps from exercise_preset_entries.
ALTER TABLE public.exercise_preset_entries
ADD COLUMN IF NOT EXISTS steps integer;
COMMENT ON COLUMN public.exercise_preset_entries.steps IS 'Total number of steps recorded for this workout session, sourced from Garmin or other providers.';
1 change: 1 addition & 0 deletions SparkyFitnessServer/services/DashboardService.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ async function getDashboardStats(userId, date) {
net: Math.round(netCalories),
progress: Math.round(progress),
steps: stepsCount,
stepCalories: backgroundStepCalories,
bmr: Math.round(bmr),
unit: "kcal",
};
Expand Down
Loading