Skip to content
Open
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
43 changes: 40 additions & 3 deletions src/features/workout-session/ui/workout-session-set.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AVAILABLE_WORKOUT_SET_TYPES, MAX_WORKOUT_SET_COLUMNS } from "@/shared/c
import { WorkoutSet, WorkoutSetType, WorkoutSetUnit } from "@/features/workout-session/types/workout-set";
import { getWorkoutSetTypeLabels } from "@/features/workout-session/lib/workout-set-labels";
import { Button } from "@/components/ui/button";
import { normalizeInteger, normalizeDecimal } from "@/shared/lib/number/normalize";

interface WorkoutSetRowProps {
set: WorkoutSet;
Expand All @@ -19,6 +20,21 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
const types = set.types || [];
const typeLabels = getWorkoutSetTypeLabels(t);

// Store weight as deci-units (x10). Example: "2.5" -> 25; "7,5" -> 75.
const parseWeightToDeci = (raw: string) => {
const n = parseFloat(String(raw).replace(",", "."));
if (!Number.isFinite(n) || n < 0) return 0;
// tiny epsilon avoids cases like 1.3 * 10 = 12.999999...
return Math.trunc(n * 10 + 1e-8);
};

// Show stored deci back with at most 1 decimal. 25 -> "2.5", 20 -> "2"
const formatDeciToDisplay = (val?: number) => {
if (val === undefined || val === null) return "";
const num = val / 10;
return Number.isInteger(num) ? String(num) : num.toFixed(1);
};

const handleTypeChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTypes = [...types];
newTypes[columnIndex] = e.target.value as WorkoutSetType;
Expand All @@ -37,6 +53,13 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
onChange(setIndex, { valuesSec: newValuesSec });
};

// Weight accepts decimals; store as deci-units inside valuesInt
const handleValueWeightChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const newValuesInt = Array.isArray(set.valuesInt) ? [...set.valuesInt] : [];
newValuesInt[columnIndex] = parseWeightToDeci(e.target.value);
onChange(setIndex, { valuesInt: newValuesInt });
};

const handleUnitChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLSelectElement>) => {
const newUnits = Array.isArray(set.units) ? [...set.units] : [];
newUnits[columnIndex] = e.target.value as WorkoutSetUnit;
Expand Down Expand Up @@ -85,7 +108,9 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
disabled={set.completed}
min={0}
onChange={handleValueIntChange(columnIndex)}
onBlur={(e) => { e.currentTarget.value = normalizeInteger(e.currentTarget.value); }}
pattern="[0-9]*"
inputMode="numeric"
placeholder="min"
type="number"
value={valuesInt[columnIndex] ?? ""}
Expand All @@ -96,7 +121,9 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
max={59}
min={0}
onChange={handleValueSecChange(columnIndex)}
onBlur={(e) => { e.currentTarget.value = normalizeInteger(e.currentTarget.value); }}
pattern="[0-9]*"
inputMode="numeric"
placeholder="sec"
type="number"
value={valuesSec[columnIndex] ?? ""}
Expand All @@ -110,11 +137,19 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
className="border border-black rounded px-1 py-2 w-1/2 text-base text-center font-bold dark:bg-slate-800"
disabled={set.completed}
min={0}
onChange={handleValueIntChange(columnIndex)}
pattern="[0-9]*"
step="0.5"
inputMode="decimal"
onChange={handleValueWeightChange(columnIndex)}
onBlur={(e) => {
e.currentTarget.value = normalizeDecimal(e.currentTarget.value, { maxDecimals: 1 });
}}
placeholder=""
type="number"
value={valuesInt[columnIndex] ?? ""}
value={
valuesInt[columnIndex] !== undefined
? formatDeciToDisplay(valuesInt[columnIndex])
: ""
}
/>
<select
className="border border-black rounded px-1 py-2 w-1/2 text-base font-bold bg-white dark:bg-slate-800 dark:text-gray-200 h-10 "
Expand All @@ -134,7 +169,9 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
disabled={set.completed}
min={0}
onChange={handleValueIntChange(columnIndex)}
onBlur={(e) => { e.currentTarget.value = normalizeInteger(e.currentTarget.value); }}
pattern="[0-9]*"
inputMode="numeric"
placeholder=""
type="number"
value={valuesInt[columnIndex] ?? ""}
Expand Down
68 changes: 68 additions & 0 deletions src/shared/lib/number/normalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export interface NormalizeDecimalOptions {
/** Maximum number of fractional digits to keep. Default: 3. */
maxDecimals?: number;
}

/**
* Normalize an integer-like string.
*
* Behavior:
* - Preserve empty string while editing.
* - Treat "." or "," as decimal separators and **drop the fractional part**.
* e.g., "2.0" -> "2", "00,7" -> "0"
* - Keep digits only.
* - Remove redundant leading zeros, but keep a single "0" if the value is zero.
*/
export function normalizeInteger(raw: string): string {
const input = raw.trim();
if (input === "") return "";

// Unify separators, then drop any fractional part.
const noFraction = input.replace(/,/g, ".").replace(/\..*$/, "");

const digitsOnly = noFraction.replace(/\D+/g, "");
const withoutRedundantZeros = digitsOnly.replace(/^0+(?=\d)/, "");

return withoutRedundantZeros === "" ? "0" : withoutRedundantZeros;
}

/**
* Normalize a decimal-like string (supports "." and "," as decimal separators).
*
* Behavior:
* - Preserve empty string while editing.
* - Convert commas to dots.
* - Remove invalid characters (keep digits and at most one dot).
* - If it starts with ".", prefix "0" (".5" -> "0.5").
* - Strip redundant leading zeros except for "0.xxx".
* - Limit fractional length via `maxDecimals` (default 3).
*/
export function normalizeDecimal(raw: string, opts?: NormalizeDecimalOptions): string {
const { maxDecimals = 3 } = opts ?? {};
const input = raw.trim();
if (input === "") return "";

const unifiedSeparator = input.replace(/,/g, ".");
const digitsAndDots = unifiedSeparator.replace(/[^0-9.]/g, "");

const firstDotIndex = digitsAndDots.indexOf(".");
const singleDot =
firstDotIndex === -1
? digitsAndDots
: digitsAndDots.slice(0, firstDotIndex + 1) +
digitsAndDots.slice(firstDotIndex + 1).replace(/\./g, "");

let normalized = singleDot.startsWith(".") ? `0${singleDot}` : singleDot;

if (!normalized.startsWith("0.")) {
normalized = normalized.replace(/^0+(?=\d)/, "");
if (normalized === "") normalized = "0";
}

const [integerPart, fractionalPart] = normalized.split(".");
if (fractionalPart && fractionalPart.length > maxDecimals) {
normalized = `${integerPart}.${fractionalPart.slice(0, maxDecimals)}`;
}

return normalized;
}