diff --git a/apps/web/public/app/app.js b/apps/web/public/app/app.js
index a16bc35..edb7b91 100644
--- a/apps/web/public/app/app.js
+++ b/apps/web/public/app/app.js
@@ -1,5 +1,6 @@
// tleehealth app. Plain ES module, no build step. Talks only to /api/v1.
import * as recordVaultCrypto from '/assets/vault-crypto.js';
+import { detectUnits } from '/assets/units.js';
const root = document.getElementById('root');
const modal = document.getElementById('modal');
@@ -1368,6 +1369,30 @@ async function viewInvites(q) {
/* -------------------------------------------------------------- settings -- */
+function unitsCard() {
+ const saved = state.me.user.units ?? 'auto';
+ const detected = detectUnits(navigator.languages ?? [navigator.language]);
+ const opt = (v, label) => ``;
+ return ` Used by the health tracker. Readings you already saved are converted for display.Units
CoinPay accounts are matched by their provider ID. The email on a CoinPay profile is not used to find or merge tleehealth accounts.
+ ${unitsCard()} `; document.getElementById('signout').onclick = signOut; + bindUnitsCard(); document.getElementById('disconnect-coinpay')?.addEventListener('click', async () => { try { await api('DELETE', '/me/coinpay'); diff --git a/apps/web/public/app/units.js b/apps/web/public/app/units.js new file mode 100644 index 0000000..a9c5ff1 --- /dev/null +++ b/apps/web/public/app/units.js @@ -0,0 +1,62 @@ +// Measurement units for the health tracker and account settings. +// An account stores 'imperial', 'metric' or nothing (null = follow the browser +// locale). Only the United States, Liberia and Myanmar default to imperial. + +export const UNIT_SYSTEMS = ['imperial', 'metric']; +const IMPERIAL_REGIONS = new Set(['US', 'LR', 'MM']); + +/** The region of a BCP 47 tag, guessing the likely one when absent ("en" -> US). */ +export function regionOf(tag) { + try { + const locale = new Intl.Locale(tag); + return (locale.region ?? locale.maximize().region ?? '').toUpperCase(); + } catch { + return ''; + } +} + +/** 'imperial' or 'metric' for a list of browser languages (navigator.languages). */ +export function detectUnits(languages) { + const list = (Array.isArray(languages) ? languages : [languages]).filter(Boolean); + const first = list.find((tag) => regionOf(tag)) ?? list[0]; + return first && IMPERIAL_REGIONS.has(regionOf(first)) ? 'imperial' : 'metric'; +} + +/** The system to use: the account's saved choice, else the browser's locale. */ +export function resolveUnits(saved, languages = globalThis.navigator?.languages ?? [globalThis.navigator?.language]) { + return UNIT_SYSTEMS.includes(saved) ? saved : detectUnits(languages); +} + +// Readings whose unit depends on the system. Readings saved before units existed +// carry no unit and were entered in the metric one. +export const UNITS = { + weight: { metric: 'kg', imperial: 'lb' }, + temperature: { metric: '°C', imperial: '°F' }, +}; +export const PLACEHOLDERS = { + weight: { metric: '70.5', imperial: '155.4' }, + temperature: { metric: '36.8', imperial: '98.2' }, +}; + +const round = (n, places) => Math.round(n * 10 ** places) / 10 ** places; + +/** Convert a numeric reading between units of the same kind. */ +export function convert(value, from, to) { + if (from === to) return value; + if (from === 'kg' && to === 'lb') return round(value * 2.2046226218, 1); + if (from === 'lb' && to === 'kg') return round(value / 2.2046226218, 1); + if (from === '°C' && to === '°F') return round(value * 9 / 5 + 32, 1); + if (from === '°F' && to === '°C') return round((value - 32) * 5 / 9, 1); + throw new Error(`Cannot convert ${from} to ${to}`); +} + +/** The text to show for a saved reading in the viewer's unit system. */ +export function displayReading(entry, system, fixedUnit) { + const units = UNITS[entry.kind]; + if (!units) return `${entry.value} ${fixedUnit}`; + const stored = Object.values(units).includes(entry.unit) ? entry.unit : units.metric; + const want = units[system] ?? units.metric; + const n = Number(entry.value); + if (!Number.isFinite(n) || stored === want) return `${entry.value} ${stored}`; + return `${convert(n, stored, want)} ${want}`; +} diff --git a/apps/web/public/health/health.css b/apps/web/public/health/health.css index 1d3dcde..9f0c25f 100644 --- a/apps/web/public/health/health.css +++ b/apps/web/public/health/health.css @@ -63,6 +63,7 @@ button.link { min-height: auto; padding: 0; border: 0; color: var(--accent); tex .hint { color: var(--dim); font-size: 13px; } .list-head { align-items: flex-end; } .tools { gap: 8px; } +.units-pick select { min-height: 38px; padding: 0 10px; font-size: 13px; width: auto; } .import { position: relative; overflow: hidden; white-space: nowrap; } .import input { position: absolute; inset: 0; min-height: 100%; opacity: 0; cursor: pointer; } .empty { margin: 0; padding: 22px 20px; color: var(--dim); } diff --git a/apps/web/public/health/health.js b/apps/web/public/health/health.js index f20027c..4aafa7d 100644 --- a/apps/web/public/health/health.js +++ b/apps/web/public/health/health.js @@ -1,10 +1,14 @@ import * as vaultCrypto from '/assets/vault-crypto.js'; +import { UNITS, PLACEHOLDERS, displayReading, resolveUnits, detectUnits } from '/assets/units.js'; const sessionResponse = await fetch('/api/v1/me', { credentials: 'same-origin', cache: 'no-store' }).catch(() => null); if (!sessionResponse?.ok) { location.replace('/signin?next=%2Fhealth%2F'); throw new Error('Sign in required to use the health tracker.'); } +const me = await sessionResponse.json().catch(() => ({})); +let savedUnits = me?.user?.units ?? null; +let unitSystem = resolveUnits(savedUnits); const DB_NAME = 'tleehealth-health-tracker'; const DB_VERSION = 2; @@ -86,10 +90,12 @@ function fromBase64Url(text) { return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } +const unitFor = (k) => UNITS[k]?.[unitSystem] ?? LABELS[k].unit; + function updateKind() { const spec = LABELS[kind.value]; - document.getElementById('unit').textContent = spec.unit + (kind.value === 'blood-pressure' ? ' (systolic/diastolic)' : ''); - value.placeholder = spec.placeholder; + document.getElementById('unit').textContent = unitFor(kind.value) + (kind.value === 'blood-pressure' ? ' (systolic/diastolic)' : ''); + value.placeholder = PLACEHOLDERS[kind.value]?.[unitSystem] ?? spec.placeholder; value.inputMode = kind.value === 'blood-pressure' ? 'text' : 'decimal'; } @@ -101,6 +107,7 @@ function localDateInput() { function validValue(entry) { if (typeof entry.value !== 'string' || entry.value.length > 32 || !entry.value.trim()) return false; if (!LABELS[entry.kind]) return false; + if (entry.unit !== undefined && !Object.values(UNITS[entry.kind] ?? {}).includes(entry.unit)) return false; if (entry.kind === 'blood-pressure') return /^\s*\d{2,3}\s*\/\s*\d{2,3}\s*$/.test(entry.value); return /^\s*\d{1,3}(?:\.\d{1,2})?\s*$/.test(entry.value); } @@ -134,7 +141,7 @@ async function renderEntries() { for (const entry of entries) { const row = document.createElement('tr'); appendCell(row, LABELS[entry.kind].name, 'measure'); - appendCell(row, `${entry.value} ${LABELS[entry.kind].unit}`, 'reading'); + appendCell(row, displayReading(entry, unitSystem, LABELS[entry.kind].unit), 'reading'); appendCell(row, formatDate(entry.takenAt), 'date'); appendCell(row, entry.note, 'note'); const action = document.createElement('td'); @@ -346,6 +353,31 @@ async function handleImport(event) { } kind.addEventListener('change', updateKind); +const unitsSelect = document.getElementById('units'); +const detectedUnits = detectUnits(navigator.languages ?? [navigator.language]); +unitsSelect.querySelector('option[value="auto"]').textContent = `Automatic (${detectedUnits})`; +unitsSelect.value = savedUnits ?? 'auto'; +unitsSelect.addEventListener('change', async () => { + const choice = unitsSelect.value; + const previous = savedUnits; + savedUnits = choice === 'auto' ? null : choice; + unitSystem = resolveUnits(savedUnits); + updateKind(); + if (vaultHandle) renderEntries().catch(() => {}); + try { + const res = await fetch('/api/v1/me', { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ units: choice }), + }); + if (!res.ok) throw new Error(); + status.textContent = 'Units saved to your account.'; + } catch { + savedUnits = previous; + status.textContent = 'Units changed here, but could not be saved to your account.'; + } +}); takenAt.value = localDateInput(); updateKind(); form.addEventListener('submit', async (event) => { @@ -357,6 +389,7 @@ form.addEventListener('submit', async (event) => { takenAt: new Date(takenAt.value).toISOString(), note: String(new FormData(form).get('note') ?? '').trim().slice(0, 500), }; + if (UNITS[entry.kind]) entry.unit = unitFor(entry.kind); if (!validValue(entry)) { status.textContent = kind.value === 'blood-pressure' ? 'Enter blood pressure as systolic/diastolic, such as 120/80.' : 'Enter a number for this measurement.'; value.focus(); diff --git a/apps/web/public/health/index.html b/apps/web/public/health/index.html index b23c1c0..0701a09 100644 --- a/apps/web/public/health/index.html +++ b/apps/web/public/health/index.html @@ -29,7 +29,7 @@New reading
New reading