[HourInput] Read 7.15 as 7h15min, not 7h9min

This commit is contained in:
Aaro Koivukangas
2026-10-11 19:45:57 +03:00
parent 14082beac4
commit 9cc0ad8e1d
5 changed files with 20 additions and 10 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
Log your daily hours, see how much you are over/under your weekly target, Log your daily hours, see how much you are over/under your weekly target,
and keep a running balance of extra/minus hours. and keep a running balance of extra/minus hours.
- Enter each day as a **total** (`7.5`, `7:15`, `7h15`; 0–24 h) — the default — or as - Enter each day as a **total** (`7.15`, `7:15`, `7h15` = 7 h 15 min; `7.5` = 7 h 30 min; 0–24 h) — the default — or as
**start–end + break**, or mark it **off** (vacation, sick, holiday → no target). **start–end + break**, or mark it **off** (vacation, sick, holiday → no target).
- Full-time target defaults to **36 h 15 min/week** (7:15 per weekday). - Full-time target defaults to **36 h 15 min/week** (7:15 per weekday).
- **Workload periods** (Settings) scale the target from a given date, e.g. 50% or 25%. - **Workload periods** (Settings) scale the target from a given date, e.g. 50% or 25%.
+2
View File
@@ -35,6 +35,8 @@ test.describe('entering total hours', () => {
for (const [typed, minutes] of [ for (const [typed, minutes] of [
['7.5', 450], ['7.5', 450],
['7,5', 450], ['7,5', 450],
['7.30', 450],
['7,30', 450],
['7:30', 450], ['7:30', 450],
['7h30', 450], ['7h30', 450],
['7h 30min', 450], ['7h 30min', 450],
+8 -6
View File
@@ -37,8 +37,10 @@ export function weekDays(monday: string): string[] {
// ---------- Durations ---------- // ---------- Durations ----------
/** /**
* Parses "7.5", "7,5", "7:30", "7h30", "7h 30min", "45m" or "-1:30" into minutes. * Parses "7.5", "7,5", "7.15", "7:30", "7h30", "7h 30min", "45m" or "-1:30" into minutes.
* Returns null for anything it does not understand; "" also returns null. * Two digits after "." or "," are minutes like after ":" ("7.15" = 7 h 15 min); one digit is
* a decimal hour ("7.5" = 7 h 30 min). Returns null for anything it does not understand;
* "" also returns null.
*/ */
export function parseDuration(input: string): number | null { export function parseDuration(input: string): number | null {
const s = input.trim().toLowerCase().replace(/\s+/g, ''); const s = input.trim().toLowerCase().replace(/\s+/g, '');
@@ -47,11 +49,11 @@ export function parseDuration(input: string): number | null {
const body = s.replace(/^[+-]/, ''); const body = s.replace(/^[+-]/, '');
let m: RegExpMatchArray | null; let m: RegExpMatchArray | null;
if ((m = body.match(/^(\d+)(?:[.,](\d+))?h?$/))) { if ((m = body.match(/^(\d+)(?:[.,](\d))?h?$/))) {
return sign * Math.round(parseFloat(`${m[1]}.${m[2] ?? '0'}`) * 60); return sign * (Number(m[1]) * 60 + Number(m[2] ?? 0) * 6);
} }
if ((m = body.match(/^(\d+):(\d{1,2})$/)) && Number(m[2]) < 60) { if ((m = body.match(/^(\d+)(?::(\d{1,2})|[.,](\d{2}))$/)) && Number(m[2] ?? m[3]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2])); return sign * (Number(m[1]) * 60 + Number(m[2] ?? m[3]));
} }
if ((m = body.match(/^(\d+)h(\d{1,2})(?:m|min)?$/)) && Number(m[2]) < 60) { if ((m = body.match(/^(\d+)h(\d{1,2})(?:m|min)?$/)) && Number(m[2]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2])); return sign * (Number(m[1]) * 60 + Number(m[2]));
+1 -1
View File
@@ -12,7 +12,7 @@ interface Props {
} }
/** /**
* Free-text duration field ("7.5", "7:15", "7h15"). Valid input is committed as you type; * Free-text duration field ("7.5", "7.15", "7:15", "7h15"). Valid input is committed as you type;
* on blur the text is normalised to h:mm. Invalid text is kept and outlined until fixed. * on blur the text is normalised to h:mm. Invalid text is kept and outlined until fixed.
*/ */
export function DurationInput({ value, onChange, placeholder, ariaLabel, min = -Infinity, max = Infinity }: Props) { export function DurationInput({ value, onChange, placeholder, ariaLabel, min = -Infinity, max = Infinity }: Props) {
+8 -2
View File
@@ -35,7 +35,11 @@ const fullWeek = (minutes: number) =>
describe('parseDuration', () => { describe('parseDuration', () => {
it.each([ it.each([
['7.5', 450], ['7.5', 450],
['7,25', 435], ['7,5', 450],
['7.15', 435],
['7,15', 435],
['7.45', 465],
['7.05', 425],
['7:15', 435], ['7:15', 435],
['7h15', 435], ['7h15', 435],
['7h 15min', 435], ['7h 15min', 435],
@@ -45,7 +49,9 @@ describe('parseDuration', () => {
['8', 480], ['8', 480],
])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected)); ])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected));
it.each(['', 'abc', '7:75', '7h99'])('rejects %j', (input) => expect(parseDuration(input)).toBeNull()); it.each(['', 'abc', '7:75', '7h99', '7.75', '7.123'])('rejects %j', (input) =>
expect(parseDuration(input)).toBeNull(),
);
}); });
describe('formatting', () => { describe('formatting', () => {