Skip to content

Commit f654933

Browse files
gPinatoclaude
andcommitted
feat: add measurement decimal places preference
Add a global user preference (0, 1, or 2 decimal places) for how measurements are displayed in the Recent Activity section. Addresses PR reviewer feedback that rounding to integers is too aggressive for metrics like BMI or fitness age. - New DB migration adds measurement_decimal_places column (default 0) - Settings UI exposes the selector under Preferences - RecentActivity uses the preference via toFixed() instead of Math.round() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5e4be48 commit f654933

9 files changed

Lines changed: 311 additions & 231 deletions

File tree

SparkyFitnessFrontend/public/locales/en/translation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@
467467
"kilograms": "Kilograms (kg)",
468468
"pounds": "Pounds (lbs)",
469469
"measurementUnit": "Measurement Unit",
470+
"measurementDecimalPlaces": "Measurement Decimal Places",
470471
"centimeters": "Centimeters (cm)",
471472
"inches": "Inches (in)",
472473
"distanceUnit": "Distance Unit",

SparkyFitnessFrontend/src/contexts/PreferencesContext.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ interface PreferencesContextType {
108108
tdeeAllowNegativeAdjustment: boolean;
109109
selectedDiet: string;
110110
firstDayOfWeek: DayOfWeek;
111+
measurementDecimalPlaces: number;
111112
goalMode: GoalMode;
112113
goalModeCalculationMethod: GoalModeCalculationMethod;
113114
goalModeCustomPercentage: number;
115+
setMeasurementDecimalPlaces: (places: number) => void;
114116
setGoalMode: (mode: GoalMode) => void;
115117
setGoalModeCalculationMethod: (method: GoalModeCalculationMethod) => void;
116118
setGoalModeCustomPercentage: (pct: number) => void;
@@ -215,6 +217,7 @@ export interface DefaultPreferences {
215217
vitamin_calculation_algorithm: VitaminCalculationAlgorithm;
216218
sugar_calculation_algorithm: SugarCalculationAlgorithm;
217219
first_day_of_week: number;
220+
measurement_decimal_places: number;
218221
goal_mode: GoalMode;
219222
goal_mode_calculation_method: GoalModeCalculationMethod;
220223
goal_mode_custom_percentage: number;
@@ -317,6 +320,8 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
317320
);
318321
const [selectedDiet, setSelectedDietState] = useState<string>('balanced');
319322
const [firstDayOfWeek, setFirstDayOfWeekState] = useState<DayOfWeek>(0);
323+
const [measurementDecimalPlaces, setMeasurementDecimalPlacesState] =
324+
useState<number>(0);
320325
const [goalMode, setGoalModeState] = useState<GoalMode>('maintain');
321326
const [goalModeCalculationMethod, setGoalModeCalculationMethodState] =
322327
useState<GoalModeCalculationMethod>('manual');
@@ -696,6 +701,7 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
696701
);
697702
setSelectedDietState(data.selected_diet || 'balanced');
698703
setFirstDayOfWeekState(data.first_day_of_week ?? 0);
704+
setMeasurementDecimalPlacesState(data.measurement_decimal_places ?? 0);
699705
setGoalModeState(data.goal_mode || 'maintain');
700706
setGoalModeCalculationMethodState(
701707
data.goal_mode_calculation_method || 'manual'
@@ -856,6 +862,8 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
856862
newPrefs?.sugarCalculationAlgorithm ?? sugarCalculationAlgorithm,
857863
selected_diet: newPrefs?.selectedDiet ?? selectedDiet,
858864
first_day_of_week: newPrefs?.firstDayOfWeek ?? firstDayOfWeek,
865+
measurement_decimal_places:
866+
newPrefs?.measurementDecimalPlaces ?? measurementDecimalPlaces,
859867
goal_mode: newPrefs?.goalMode ?? goalMode,
860868
goal_mode_calculation_method:
861869
newPrefs?.goalModeCalculationMethod ?? goalModeCalculationMethod,
@@ -913,6 +921,7 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
913921
sugarCalculationAlgorithm,
914922
selectedDiet,
915923
firstDayOfWeek,
924+
measurementDecimalPlaces,
916925
goalMode,
917926
goalModeCalculationMethod,
918927
goalModeCustomPercentage,
@@ -1147,9 +1156,11 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
11471156
sugarCalculationAlgorithm,
11481157
selectedDiet,
11491158
firstDayOfWeek,
1159+
measurementDecimalPlaces,
11501160
goalMode,
11511161
goalModeCalculationMethod,
11521162
goalModeCustomPercentage,
1163+
setMeasurementDecimalPlaces: setMeasurementDecimalPlacesState,
11531164
setGoalMode,
11541165
setGoalModeCalculationMethod,
11551166
setGoalModeCustomPercentage,
@@ -1233,6 +1244,7 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({
12331244
sugarCalculationAlgorithm,
12341245
selectedDiet,
12351246
firstDayOfWeek,
1247+
measurementDecimalPlaces,
12361248
goalMode,
12371249
goalModeCalculationMethod,
12381250
goalModeCustomPercentage,

SparkyFitnessFrontend/src/pages/CheckIn/RecentActivity.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const RecentActivity: React.FC<RecentActivityProps> = ({
4444
const {
4545
weightUnit: defaultWeightUnit,
4646
measurementUnit: defaultMeasurementUnit,
47+
measurementDecimalPlaces,
4748
} = usePreferences();
4849
const { t } = useTranslation();
4950

@@ -103,7 +104,7 @@ export const RecentActivity: React.FC<RecentActivityProps> = ({
103104
const val =
104105
measurement.value === '' || isNaN(num)
105106
? measurement.value
106-
: Math.round(num);
107+
: Number(num.toFixed(measurementDecimalPlaces));
107108
displayString = `${val} ${unit}`.trim();
108109
}
109110
} else if (measurement.type === 'standard') {
@@ -130,7 +131,7 @@ export const RecentActivity: React.FC<RecentActivityProps> = ({
130131
const val =
131132
measurement.value === '' || isNaN(num)
132133
? measurement.value
133-
: Math.round(num);
134+
: Number(num.toFixed(measurementDecimalPlaces));
134135
displayString = `${val} ${unit}`.trim();
135136
}
136137
} else if (measurement.type === 'stress') {

SparkyFitnessFrontend/src/pages/Settings/PreferenceSettings.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getLanguageDisplayName,
44
} from '@/utils/languageUtils'; // Import language utilities
55
import { Button } from '@/components/ui/button';
6+
import { Input } from '@/components/ui/input';
67
import { Label } from '@/components/ui/label';
78
import {
89
Select,
@@ -51,6 +52,8 @@ export const PreferenceSettings = () => {
5152
loggingLevel,
5253
firstDayOfWeek,
5354
setFirstDayOfWeek,
55+
measurementDecimalPlaces,
56+
setMeasurementDecimalPlaces,
5457
timezone,
5558
setTimezone,
5659
saveAllPreferences,
@@ -78,6 +81,7 @@ export const PreferenceSettings = () => {
7881
autoScaleOnlineImports,
7982
language,
8083
firstDayOfWeek,
84+
measurementDecimalPlaces,
8185
timezone,
8286
loggingLevel: localLoggingLevel,
8387
});
@@ -230,6 +234,25 @@ export const PreferenceSettings = () => {
230234
</SelectContent>
231235
</Select>
232236
</div>
237+
<div>
238+
<Label htmlFor="measurement_decimal_places">
239+
{t(
240+
'settings.preferences.measurementDecimalPlaces',
241+
'Measurement Decimal Places'
242+
)}
243+
</Label>
244+
<Input
245+
id="measurement_decimal_places"
246+
type="number"
247+
min={0}
248+
value={measurementDecimalPlaces}
249+
onChange={(e) =>
250+
setMeasurementDecimalPlaces(
251+
Math.max(0, parseInt(e.target.value, 10) || 0)
252+
)
253+
}
254+
/>
255+
</div>
233256
<div>
234257
<Label htmlFor="first_day_of_week">
235258
{t(

SparkyFitnessFrontend/src/tests/components/RecentActivity.test.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ jest.mock('react-i18next', () => ({
99
}),
1010
}));
1111

12+
const mockPreferences = {
13+
weightUnit: 'kg',
14+
measurementUnit: 'cm',
15+
measurementDecimalPlaces: 0,
16+
};
17+
1218
jest.mock('@/contexts/PreferencesContext', () => ({
13-
usePreferences: () => ({
14-
weightUnit: 'kg',
15-
measurementUnit: 'cm',
16-
}),
19+
usePreferences: () => mockPreferences,
1720
}));
1821

1922
const baseMeasurement: CombinedMeasurement = {
@@ -187,4 +190,32 @@ describe('RecentActivity', () => {
187190

188191
expect(screen.getByText('abc')).toBeInTheDocument();
189192
});
193+
194+
it('respects measurementDecimalPlaces preference', () => {
195+
mockPreferences.measurementDecimalPlaces = 1;
196+
197+
const measurements: CombinedMeasurement[] = [
198+
{
199+
...baseMeasurement,
200+
display_name: 'BMI',
201+
value: '23.700000762939453',
202+
custom_categories: {
203+
id: 'cat5',
204+
name: 'BMI',
205+
measurement_type: 'N/A',
206+
frequency: 'daily',
207+
data_type: null,
208+
display_name: 'BMI',
209+
},
210+
},
211+
];
212+
213+
render(
214+
<RecentActivity {...defaultProps} recentMeasurements={measurements} />
215+
);
216+
217+
expect(screen.getByText('23.7')).toBeInTheDocument();
218+
219+
mockPreferences.measurementDecimalPlaces = 0;
220+
});
190221
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE public.user_preferences
2+
ADD COLUMN measurement_decimal_places integer NOT NULL DEFAULT 0;

SparkyFitnessServer/models/preferenceRepository.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ async function updateUserPreferences(userId: any, preferenceData: any) {
4242
add_exercise_water_to_goal = COALESCE($40, add_exercise_water_to_goal),
4343
default_barcode_provider_id = CASE WHEN $28 THEN $27 ELSE default_barcode_provider_id END,
4444
active_ai_service_id = CASE WHEN $39 THEN $38 ELSE active_ai_service_id END,
45+
measurement_decimal_places = COALESCE($41, measurement_decimal_places),
4546
updated_at = now()
4647
WHERE user_id = $29
4748
RETURNING *`,
@@ -86,6 +87,7 @@ async function updateUserPreferences(userId: any, preferenceData: any) {
8687
preferenceData.active_ai_service_id,
8788
'active_ai_service_id' in preferenceData,
8889
preferenceData.add_exercise_water_to_goal,
90+
preferenceData.measurement_decimal_places,
8991
]
9092
);
9193
return result.rows[0];
@@ -169,6 +171,7 @@ async function upsertUserPreferences(preferenceData: any) {
169171
use_external_bmr,
170172
add_exercise_water_to_goal,
171173
active_ai_service_id,
174+
measurement_decimal_places,
172175
created_at, updated_at
173176
) VALUES (
174177
$1, COALESCE($2, 'yyyy-MM-dd'), COALESCE($3, 'lbs'), COALESCE($4, 'in'), COALESCE($5, 'km'),
@@ -191,6 +194,7 @@ async function upsertUserPreferences(preferenceData: any) {
191194
COALESCE($37, false),
192195
COALESCE($40, false),
193196
$38,
197+
COALESCE($41, 0),
194198
now(), now()
195199
)
196200
ON CONFLICT (user_id) DO UPDATE SET
@@ -231,6 +235,7 @@ async function upsertUserPreferences(preferenceData: any) {
231235
add_exercise_water_to_goal = COALESCE(EXCLUDED.add_exercise_water_to_goal, user_preferences.add_exercise_water_to_goal),
232236
default_barcode_provider_id = CASE WHEN $29 THEN EXCLUDED.default_barcode_provider_id ELSE user_preferences.default_barcode_provider_id END,
233237
active_ai_service_id = CASE WHEN $39 THEN EXCLUDED.active_ai_service_id ELSE user_preferences.active_ai_service_id END,
238+
measurement_decimal_places = COALESCE(EXCLUDED.measurement_decimal_places, user_preferences.measurement_decimal_places),
234239
updated_at = now()
235240
RETURNING *`,
236241
[
@@ -274,6 +279,7 @@ async function upsertUserPreferences(preferenceData: any) {
274279
preferenceData.active_ai_service_id,
275280
'active_ai_service_id' in preferenceData,
276281
preferenceData.add_exercise_water_to_goal,
282+
preferenceData.measurement_decimal_places,
277283
]
278284
);
279285
return result.rows[0];

0 commit comments

Comments
 (0)