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
6 changes: 3 additions & 3 deletions js/health-pwa.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/health-pwa.mjs.map

Large diffs are not rendered by default.

25 changes: 18 additions & 7 deletions src/pwa/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ function openEntryModal(metric: MetricDefinition, trigger: HTMLElement, directOp
}
content.append(choices, modalCancel(dialog))
} else if (mode === 'direct-increment') {
const input = numericInput(s('Value'), 'numeric', metric.minimum ?? undefined, metric.maximum ?? undefined)
const input = numericInput(s('Value'), 'numeric')
const form = numericForm(metric, input, error, closeAfterSuccess, dialog)
const increment = button('+1', () => action(increment, () => queueIncrement(metric)), 'metric-action primary')
increment.setAttribute('aria-label', s('Add one {metric}').replace('{metric}', metricLabel(metric.metricKey)))
Expand All @@ -244,7 +244,7 @@ function openEntryModal(metric: MetricDefinition, trigger: HTMLElement, directOp
})
content.append(form)
} else {
const input = numericInput(s('Value'), 'decimal', metric.minimum ?? undefined, metric.maximum ?? undefined)
const input = numericInput(s('Value'), 'decimal')
content.append(numericForm(metric, input, error, closeAfterSuccess, dialog))
}
dialog.append(content)
Expand Down Expand Up @@ -372,15 +372,26 @@ async function saveForm(metric: MetricDefinition, form: HTMLFormElement, operati
}
}

function numericInput(labelText: string, inputMode: 'numeric' | 'decimal', minimum?: number, maximum?: number): { label: HTMLLabelElement, input: HTMLInputElement } {
/**
* Build the entry field for a numeric metric.
*
* The field is a text input so that `parseLocaleNumber` receives exactly what
* the user typed. A `number` input applies its own sanitisation first, which
* silently rewrites grouped input such as the German `1.234,5` and leaves no
* way to recover the intended value. The range is enforced by
* `isValidNumericValue` on submit, and `inputMode` still selects the numeric
* keypad on touch devices.
*
* @param labelText Visible field label.
* @param inputMode Keypad requested for touch devices.
*/
function numericInput(labelText: string, inputMode: 'numeric' | 'decimal'): { label: HTMLLabelElement, input: HTMLInputElement } {
const label = element('label', undefined, labelText)
const input = element('input')
input.type = 'number'
input.type = 'text'
input.required = true
input.inputMode = inputMode
input.step = inputMode === 'numeric' ? '1' : 'any'
if (minimum !== undefined) { input.min = String(minimum) }
if (maximum !== undefined) { input.max = String(maximum) }
input.autocomplete = 'off'
label.append(input)
return { label, input }
}
Expand Down
20 changes: 17 additions & 3 deletions src/pwa/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,25 @@ export function optionIcon(metric: MetricDefinition, option: string): string | n
return symbol === '•' ? null : symbol
}

/**
* Parse a number the way the account locale writes it.
*
* Group separators are removed before the decimal separator is canonicalised,
* so a grouped entry such as the German `12.000` is read as twelve thousand
* instead of twelve. Whitespace is dropped because several locales group with
* a no-break space that a keyboard does not produce.
*
* @param value Raw text entered by the user.
* @param locale Account locale, or the browser locale as a fallback.
*/
export function parseLocaleNumber(value: string, locale: string): number {
const parts = new Intl.NumberFormat(locale).formatToParts(1.1)
const parts = new Intl.NumberFormat(locale).formatToParts(11111.1)
const decimal = parts.find((part) => part.type === 'decimal')?.value ?? '.'
const normalized = decimal === '.' ? value : value.replace(decimal, '.')
return Number(normalized.trim())
const group = parts.find((part) => part.type === 'group')?.value ?? ''
const compact = value.replaceAll(/\s/gu, '')
const ungrouped = group === '' ? compact : compact.replaceAll(group, '')
const normalized = decimal === '.' ? ungrouped : ungrouped.replaceAll(decimal, '.')
return normalized === '' ? Number.NaN : Number(normalized)
}

/**
Expand Down
15 changes: 15 additions & 0 deletions tests/frontend/pwa.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ test('loads only enabled metric definitions and selects a control from API value
assert.equal(parseLocaleNumber('2140,5', 'de-DE'), 2140.5)
})

test('reads grouped numbers the way the account locale writes them', () => {
assert.equal(parseLocaleNumber('12.000', 'de-DE'), 12000)
assert.equal(parseLocaleNumber('1.234,5', 'de-DE'), 1234.5)
assert.equal(parseLocaleNumber('12,000', 'en-US'), 12000)
assert.equal(parseLocaleNumber('1,234.5', 'en-US'), 1234.5)
assert.equal(parseLocaleNumber('1 234,5', 'fr-FR'), 1234.5)
assert.equal(parseLocaleNumber('2140.5', 'en-US'), 2140.5)
})

test('rejects entries without digits instead of reading them as zero', () => {
assert.ok(Number.isNaN(parseLocaleNumber('', 'de-DE')))
assert.ok(Number.isNaN(parseLocaleNumber(' ', 'de-DE')))
assert.ok(Number.isNaN(parseLocaleNumber('abc', 'en-US')))
})

test('maps server metric types to dialog entry modes without metric-key special cases', () => {
const stress = definition('stress', 'journal', 'scale', { minimum: 1, maximum: 5 })
const hydration = definition('hydration', 'journal', 'event', { allowedOptions: ['small_glass', 'coffee', 'tea'] })
Expand Down