Skip to content
Merged
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
27 changes: 27 additions & 0 deletions apps/web/public/app/app.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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) => `<option value="${v}" ${saved === v ? 'selected' : ''}>${label}</option>`;
return `<section class="card"><div class="card-h"><h2>Units</h2></div><div class="card-b stack">
<label class="f">Measurement units<select id="units-select" name="units">
${opt('auto', `Automatic (${detected === 'imperial' ? 'imperial' : 'metric'} for your region)`)}
${opt('imperial', 'Imperial (lb, °F)')}
${opt('metric', 'Metric (kg, °C)')}
</select></label>
<p class="dim small">Used by the health tracker. Readings you already saved are converted for display.</p>
</div></section>`;
}

function bindUnitsCard() {
document.getElementById('units-select')?.addEventListener('change', (e) => {
const units = e.target.value;
act(async () => {
await api('PATCH', '/me', { units });
state.me.user.units = units === 'auto' ? null : units;
}, 'Units saved');
});
}

function renderAccount(q) {
const outcomes = {
linked: 'CoinPay is connected. You can now use it to sign in to tleehealth.',
Expand All @@ -1388,8 +1413,10 @@ function renderAccount(q) {
${connected ? '<button class="danger" id="disconnect-coinpay">Disconnect</button>' : '<a class="btn primary" href="/auth/coinpay?mode=link">Connect CoinPay</a>'}</div>
<p class="dim small">CoinPay accounts are matched by their provider ID. The email on a CoinPay profile is not used to find or merge tleehealth accounts.</p>
</div></section>
${unitsCard()}
</div>`;
document.getElementById('signout').onclick = signOut;
bindUnitsCard();
document.getElementById('disconnect-coinpay')?.addEventListener('click', async () => {
try {
await api('DELETE', '/me/coinpay');
Expand Down
62 changes: 62 additions & 0 deletions apps/web/public/app/units.js
Original file line number Diff line number Diff line change
@@ -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}`;
}
1 change: 1 addition & 0 deletions apps/web/public/health/health.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
39 changes: 36 additions & 3 deletions apps/web/public/health/health.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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';
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/health/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h1>Your readings, on this device.</h1>
<section class="card" id="vault-card"><div class="card-b" id="vault-screen" aria-live="polite"></div></section>
<div id="tracker" hidden>
<section class="card">
<div class="card-head"><div><p class="eyebrow">New reading</p><h2>Add a measurement</h2></div><button type="button" id="lock">Lock tracker</button></div>
<div class="card-head"><div><p class="eyebrow">New reading</p><h2>Add a measurement</h2></div><div class="actions tools"><label class="units-pick"><span class="sr-only">Units</span><select id="units" aria-label="Measurement units"><option value="auto">Automatic</option><option value="imperial">Imperial (lb, °F)</option><option value="metric">Metric (kg, °C)</option></select></label><button type="button" id="lock">Lock tracker</button></div></div>
<form id="entry-form" class="form">
<label>Measurement
<select name="kind" id="kind" required>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/health/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE = 'tleehealth-health-v4';
const CACHE = 'tleehealth-health-v5';

self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ api.get('/me', async (c) => {
const [{ n }] = await db()`select count(*)::int as n from passkeys where user_id = ${user.id}`;
const coinpayConnected = await auth.coinPayIdentityLinked(user.id);
return c.json({
user: { id: user.id, email: user.email, name: user.name },
user: { id: user.id, email: user.email, name: user.name, units: user.units ?? null },
passkeys: n,
coinpay_connected: coinpayConnected,
orgs: memberships.filter((m) => TEAM.includes(m.user_type)),
Expand All @@ -236,8 +236,16 @@ api.get('/me', async (c) => {

api.patch('/me', async (c) => {
const user = await requireUser(c);
const { name } = await body(c);
await db()`update users set name = ${str(name, 120)} where id = ${user.id}`;
const input = await body(c);
// Only the fields sent change, so saving units never clears the name.
if ('units' in input && input.units !== null && input.units !== 'auto' && !['imperial', 'metric'].includes(input.units)) {
return c.json({ error: 'units must be imperial, metric or auto' }, 400);
}
if ('name' in input) await db()`update users set name = ${str(input.name, 120)} where id = ${user.id}`;
if ('units' in input) {
const units = input.units === 'imperial' || input.units === 'metric' ? input.units : null;
await db()`update users set units = ${units} where id = ${user.id}`;
}
return c.json({ ok: true });
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const asset = (name, type) => (c) =>
c.body(pub(`app/${name}`), 200, { 'content-type': type, 'cache-control': 'no-cache' });
app.get('/assets/app.js', asset('app.js', 'text/javascript; charset=utf-8'));
app.get('/assets/app.css', asset('app.css', 'text/css; charset=utf-8'));
app.get('/assets/units.js', asset('units.js', 'text/javascript; charset=utf-8'));
app.get('/assets/vault-crypto.js', (c) =>
c.body(RECORD_VAULT_CRYPTO_JS, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }),
);
Expand Down
3 changes: 3 additions & 0 deletions packages/db/migrations/0012_user_units.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Measurement units for the health tracker. null = follow the browser locale
-- (imperial only for the US, Liberia and Myanmar).
alter table users add column if not exists units text check (units in ('imperial', 'metric'));
13 changes: 13 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,19 @@ describe.skipIf(!configured())('api', () => {
expect(res.headers.get('location')).toBe('/signin?error=expired');
});

test('an account saves its measurement units without touching its name', async () => {
await owner.patch('/me', { name: 'Unit Tester' });
expect((await owner.get('/me')).body.user.units).toBeNull();
expect((await owner.patch('/me', { units: 'imperial' })).status).toBe(200);
let me = (await owner.get('/me')).body.user;
expect(me.units).toBe('imperial');
expect(me.name).toBe('Unit Tester');
expect((await owner.patch('/me', { units: 'furlongs' })).status).toBe(400);
expect((await owner.patch('/me', { units: 'auto' })).status).toBe(200);
me = (await owner.get('/me')).body.user;
expect(me.units).toBeNull();
});

test('owner creates a practice with a location', async () => {
const r = await owner.post('/orgs', { name: `Lin Family Practice ${tag}`, location: 'Mission St' });
expect(r.status).toBe(201);
Expand Down
8 changes: 7 additions & 1 deletion test/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,15 @@ describe('web', () => {
expect(worker.status).toBe(200);
expect(worker.headers.get('service-worker-allowed')).toBe('/health/');
const workerSource = await worker.text();
expect(workerSource).toContain('tleehealth-health-v4');
expect(workerSource).toContain('tleehealth-health-v5');
expect(workerSource).toContain("client.navigate('/health/')");
});
test('units module is public and imported by the tracker and account page', async () => {
const res = await get('/assets/units.js');
expect(res.status).toBe(200);
expect(await res.text()).toContain('export function detectUnits');
expect(await (await get('/assets/app.js')).text()).toContain("from '/assets/units.js'");
});
test('patient portal links to the free local health tracker', async () => {
const source = await (await get('/assets/app.js')).text();
expect(source).toContain("link('/health/', 'Free tracker')");
Expand Down
38 changes: 38 additions & 0 deletions test/units.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import { convert, detectUnits, displayReading, resolveUnits } from '../apps/web/public/app/units.js';

describe('units', () => {
test('en-US and other US locales default to imperial', () => {
expect(detectUnits(['en-US'])).toBe('imperial');
expect(detectUnits(['es-US', 'es'])).toBe('imperial');
expect(detectUnits(['en'])).toBe('imperial'); // en maximizes to en-Latn-US
});

test('everyone else defaults to metric', () => {
for (const tag of ['en-GB', 'en-CA', 'en-AU', 'de-DE', 'fr', 'ja-JP', 'es-MX', 'pt-BR']) expect(detectUnits([tag])).toBe('metric');
expect(detectUnits([])).toBe('metric');
expect(detectUnits(['not a tag'])).toBe('metric');
});

test('an account choice beats the locale', () => {
expect(resolveUnits('metric', ['en-US'])).toBe('metric');
expect(resolveUnits('imperial', ['de-DE'])).toBe('imperial');
expect(resolveUnits(null, ['en-US'])).toBe('imperial');
expect(resolveUnits('bogus', ['de-DE'])).toBe('metric');
});

test('converts weight and temperature', () => {
expect(convert(70, 'kg', 'lb')).toBe(154.3);
expect(convert(154.3, 'lb', 'kg')).toBe(70);
expect(convert(37, '°C', '°F')).toBe(98.6);
expect(convert(98.6, '°F', '°C')).toBe(37);
});

test('old readings without a unit were metric and convert for imperial viewers', () => {
expect(displayReading({ kind: 'weight', value: '70' }, 'imperial')).toBe('154.3 lb');
expect(displayReading({ kind: 'weight', value: '70' }, 'metric')).toBe('70 kg');
expect(displayReading({ kind: 'temperature', value: '98.6', unit: '°F' }, 'imperial')).toBe('98.6 °F');
expect(displayReading({ kind: 'temperature', value: '98.6', unit: '°F' }, 'metric')).toBe('37 °C');
expect(displayReading({ kind: 'heart-rate', value: '72' }, 'imperial', 'bpm')).toBe('72 bpm');
});
});
Loading