-
-
Notifications
You must be signed in to change notification settings - Fork 137
Server-side step calorie calculation #991
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a738978
f3c1b96
dddf1f4
da98792
e45d5e2
f671913
c60978d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| 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', | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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'; | ||||||
|
|
@@ -29,6 +30,7 @@ export interface DailySummaryRawData { | |||||
| foodEntries: FoodEntry[]; | ||||||
| exerciseEntries: ExerciseSessionResponse[]; | ||||||
| waterIntake: WaterIntake; | ||||||
| stepCalories: number; | ||||||
| } | ||||||
|
|
||||||
| interface UseDailySummaryOptions { | ||||||
|
|
@@ -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 })), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better type safety and consistency with how other failed fetches are handled (like A default
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The extra zeroed fields don't matter since the hook only reads |
||||||
| ]); | ||||||
|
|
||||||
| 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); | ||||||
|
|
@@ -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, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { apiFetch } from './apiClient'; | ||
|
|
||
| export interface DashboardStats { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
| }); | ||
| }; | ||
| 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.'; |
There was a problem hiding this comment.
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