Skip to content

Commit 1a5d5ca

Browse files
committed
v0.7.0 — itemLabels module + slider formatter for eventToShortText
- New appTemplates.collectItemLabels (and lower-level helpers) gather form-level label overrides for an item across active CollectorClients with source attribution. Pure data layer; consumed by hds-forms-js HDSFormField and any patient/diary surface. - New shared types: ItemLabels, ItemCustomization, ItemLabelSource, ItemLabelsWithSource, CollectItemLabelsOptions. - eventToShortText handles type:slider — applies slider.display.{multiplier,precision,suffix} so EQ VAS 0.73 → "73 /100" everywhere events are summarised.
1 parent 429113c commit 1a5d5ca

7 files changed

Lines changed: 353 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
## [0.7.0] - 2026-04-27
6+
7+
### Added — itemLabels (per-form label overrides) module
8+
- `appTemplates.collectItemLabels(itemKey, contacts)` — gathers label overrides for an item across every active CollectorClient, attributing each entry to its source contact + form. Pure data layer; usable by any patient/diary surface, not just the form renderer.
9+
- `appTemplates.collectItemLabelsFromSections(itemKey, sources, opts?)` — lower-level helper for callers that already have section objects.
10+
- `appTemplates.getSectionItemLabels(section, itemKey)` — read labels for one item out of one section.
11+
- New types: `ItemLabels`, `ItemCustomization`, `ItemLabelSource`, `ItemLabelsWithSource`, `CollectItemLabelsOptions` (mirror the `section.itemCustomizations[itemKey].labels` shape; previously duplicated as inline interfaces in hds-forms-js).
12+
- Tests: `tests/itemLabels.test.js` (deduplication, requireLabels gating, options-map preservation, source attribution).
13+
14+
### Changed — eventToShortText handles slider items
15+
- `eventToShortText` now formats `type: 'slider'` events using the item def's `slider.display` block (`multiplier`, `precision`, `suffix`). EQ VAS storing `0.73` now renders as `"73 /100"` everywhere events are summarised (diary card, timeline tooltips, etc.). Previously fell through to the generic number path and read `"0.73"`.
16+
517
### Added
618
- `HDSModelOverload` — apps can extend the shared HDS data-model at init time with their own itemDefs, streams, eventTypes, settings, datasources, or appStreams, plus refine translations and default `repeatable` values.
719
- `HDSModel.load(url, overload?)` — merges the overload (after policy validation) before freezing.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hds-lib",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Health Data Safe - Library",
55
"type": "module",
66
"engines": {

tests/eventToShortText.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,27 @@ describe('[ESTX] eventToShortText', () => {
249249
assert.equal(result, '42');
250250
});
251251

252+
it('[EST15s] slider with display.multiplier+suffix (EQ VAS 0.73 → 73 /100)', () => {
253+
const itemDef = model.itemsDefs.forKey('wellbeing-self-rated-health');
254+
if (!itemDef || itemDef.data.type !== 'slider') {
255+
// Model not yet redeployed with slider — skip rather than fail
256+
return;
257+
}
258+
const event = { content: 0.73, streamIds: ['wellbeing-self-rated-health'], type: 'ratio/proportion' };
259+
const result = eventToShortText(event);
260+
// Multiplier 100, precision 0 → "73"; suffix "/100" → "73 /100"
261+
assert.ok(result === '73 /100' || result === '73', `Expected "73 /100" or "73", got: ${result}`);
262+
});
263+
264+
it('[EST15s2] slider edge cases (min/max)', () => {
265+
const itemDef = model.itemsDefs.forKey('wellbeing-self-rated-health');
266+
if (!itemDef || itemDef.data.type !== 'slider') return;
267+
const min = eventToShortText({ content: 0, streamIds: ['wellbeing-self-rated-health'], type: 'ratio/proportion' });
268+
const max = eventToShortText({ content: 1, streamIds: ['wellbeing-self-rated-health'], type: 'ratio/proportion' });
269+
assert.ok(min === '0 /100' || min === '0', `min: ${min}`);
270+
assert.ok(max === '100 /100' || max === '100', `max: ${max}`);
271+
});
272+
252273
it('[EST16] ratio/generic select', () => {
253274
const itemDef = model.itemsDefs.forKey('body-vulva-wetness-feeling');
254275
assert.ok(itemDef, 'itemDef should exist');

tests/itemLabels.test.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import {
3+
getSectionItemLabels,
4+
collectItemLabelsFromSections
5+
} from '../ts/appTemplates/itemLabels.ts';
6+
7+
describe('[ILBL] itemLabels', function () {
8+
describe('getSectionItemLabels', function () {
9+
it('returns labels for an itemKey present in section', function () {
10+
const section = {
11+
key: 's1',
12+
type: 'permanent',
13+
name: { en: 'Section 1' },
14+
itemKeys: ['function-mobility'],
15+
itemCustomizations: {
16+
'function-mobility': {
17+
labels: { question: { en: 'MOBILITY' } }
18+
}
19+
}
20+
};
21+
const labels = getSectionItemLabels(section, 'function-mobility');
22+
assert.deepEqual(labels, { question: { en: 'MOBILITY' } });
23+
});
24+
25+
it('returns undefined when itemKey not in section', function () {
26+
const section = { key: 's', type: 'permanent', name: { en: '' }, itemKeys: ['x'] };
27+
assert.equal(getSectionItemLabels(section, 'function-mobility'), undefined);
28+
});
29+
30+
it('returns undefined when no customizations', function () {
31+
const section = { key: 's', type: 'permanent', name: { en: '' }, itemKeys: ['function-mobility'] };
32+
assert.equal(getSectionItemLabels(section, 'function-mobility'), undefined);
33+
});
34+
});
35+
36+
describe('collectItemLabelsFromSections', function () {
37+
const mkSection = (key, itemKeys, customs) => ({
38+
key, type: 'permanent', name: { en: key }, itemKeys, itemCustomizations: customs
39+
});
40+
41+
it('returns empty when no section uses the item', function () {
42+
const sources = [{
43+
section: mkSection('s', ['x'], {}),
44+
source: { contactName: 'A' }
45+
}];
46+
assert.deepEqual(collectItemLabelsFromSections('function-mobility', sources), []);
47+
});
48+
49+
it('returns one entry per section with non-empty labels and source attribution', function () {
50+
const sources = [
51+
{
52+
section: mkSection('s1', ['function-mobility'], {
53+
'function-mobility': { labels: { question: { en: 'MOBILITY' } } }
54+
}),
55+
source: { contactName: 'Dr. A', formTitle: { en: 'Form A' } }
56+
},
57+
{
58+
section: mkSection('s2', ['function-mobility'], {
59+
'function-mobility': { labels: { question: { en: 'Walking' } } }
60+
}),
61+
source: { contactName: 'Dr. B', formTitle: { en: 'Form B' } }
62+
}
63+
];
64+
const result = collectItemLabelsFromSections('function-mobility', sources);
65+
assert.equal(result.length, 2);
66+
assert.deepEqual(result[0].question, { en: 'MOBILITY' });
67+
assert.equal(result[0].source.contactName, 'Dr. A');
68+
assert.deepEqual(result[1].question, { en: 'Walking' });
69+
assert.equal(result[1].source.contactName, 'Dr. B');
70+
});
71+
72+
it('skips sections with no labels when requireLabels=true (default)', function () {
73+
const sources = [
74+
{
75+
section: mkSection('s1', ['function-mobility'], {}),
76+
source: { contactName: 'A' }
77+
},
78+
{
79+
section: mkSection('s2', ['function-mobility'], {
80+
'function-mobility': { labels: { question: { en: 'M' } } }
81+
}),
82+
source: { contactName: 'B' }
83+
}
84+
];
85+
const result = collectItemLabelsFromSections('function-mobility', sources);
86+
assert.equal(result.length, 1);
87+
assert.equal(result[0].source.contactName, 'B');
88+
});
89+
90+
it('includes empty-label sections when requireLabels=false', function () {
91+
const sources = [
92+
{
93+
section: mkSection('s', ['function-mobility'], {}),
94+
source: { contactName: 'A' }
95+
}
96+
];
97+
const result = collectItemLabelsFromSections('function-mobility', sources, { requireLabels: false });
98+
assert.equal(result.length, 1);
99+
assert.equal(result[0].source.contactName, 'A');
100+
});
101+
102+
it('deduplicates identical label sets (default)', function () {
103+
const sameLabels = { labels: { question: { en: 'MOBILITY' } } };
104+
const sources = [
105+
{
106+
section: mkSection('s1', ['function-mobility'], { 'function-mobility': sameLabels }),
107+
source: { contactName: 'Dr. A' }
108+
},
109+
{
110+
section: mkSection('s2', ['function-mobility'], { 'function-mobility': sameLabels }),
111+
source: { contactName: 'Dr. B' }
112+
}
113+
];
114+
const result = collectItemLabelsFromSections('function-mobility', sources);
115+
assert.equal(result.length, 1, 'identical labels collapse to one entry');
116+
assert.equal(result[0].source.contactName, 'Dr. A', 'first occurrence wins');
117+
});
118+
119+
it('keeps duplicates when deduplicate=false', function () {
120+
const same = { labels: { question: { en: 'M' } } };
121+
const sources = [
122+
{ section: mkSection('s1', ['m'], { m: same }), source: { contactName: 'A' } },
123+
{ section: mkSection('s2', ['m'], { m: same }), source: { contactName: 'B' } }
124+
];
125+
const result = collectItemLabelsFromSections('m', sources, { deduplicate: false });
126+
assert.equal(result.length, 2);
127+
});
128+
129+
it('preserves the options override map', function () {
130+
const sources = [{
131+
section: mkSection('s', ['function-mobility'], {
132+
'function-mobility': {
133+
labels: {
134+
question: { en: 'MOBILITY' },
135+
options: { 0: { en: 'No problems walking' }, 1: { en: 'Unable to walk' } }
136+
}
137+
}
138+
}),
139+
source: { contactName: 'Dr. A' }
140+
}];
141+
const result = collectItemLabelsFromSections('function-mobility', sources);
142+
assert.equal(result.length, 1);
143+
assert.deepEqual(result[0].options[0], { en: 'No problems walking' });
144+
assert.deepEqual(result[0].options[1], { en: 'Unable to walk' });
145+
});
146+
});
147+
});

ts/HDSModel/eventToShortText.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ function formatWithItemDef (event: any, content: any, itemDef: any, model: any):
9696
return formatDatasource(content);
9797
}
9898

99+
if (type === 'slider') {
100+
return formatSlider(content, itemDef);
101+
}
102+
99103
// number, text, composite, etc.
100104
if (typeof content === 'number') {
101105
return formatNumber(event.type, content, model);
@@ -166,6 +170,31 @@ function formatNumber (eventType: string, content: number, model: any): string {
166170
return symbol ? `${content} ${symbol}` : String(content);
167171
}
168172

173+
/**
174+
* Format a slider event: applies the item-def's slider.display block
175+
* (multiplier / precision / suffix) so the readout matches what the patient
176+
* saw when entering the value. Storage is the raw number; display rescales.
177+
*/
178+
function formatSlider (content: any, itemDef: any): string {
179+
if (typeof content !== 'number') return String(content);
180+
const display = itemDef.data?.slider?.display || {};
181+
const multiplier: number = typeof display.multiplier === 'number' ? display.multiplier : 1;
182+
let precision: number;
183+
if (typeof display.precision === 'number') {
184+
precision = display.precision;
185+
} else if (multiplier >= 10) {
186+
precision = 0;
187+
} else {
188+
const stepStr = String(itemDef.data?.step ?? 1);
189+
const dot = stepStr.indexOf('.');
190+
precision = dot >= 0 ? Math.min(stepStr.length - dot - 1, 4) : 0;
191+
}
192+
const scaled = content * multiplier;
193+
const text = scaled.toFixed(precision);
194+
const suffix = display.suffix ? localizeText(display.suffix) : '';
195+
return suffix ? `${text} ${suffix}` : text;
196+
}
197+
169198
function formatSelect (event: any, content: any, itemDef: any): string {
170199
let valueForSelect = content;
171200
let prefix = '';

ts/appTemplates/appTemplates.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,16 @@ export type { ContactInvite } from './Contact.ts';
1010
export type { AccessUpdateRequest, AccessUpdateRequestContent, AccessUpdateAction } from './interfaces.ts';
1111
export { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from './bridgeAccess.ts';
1212
export type { BridgeAccessOptions, BridgeAccessResult } from './bridgeAccess.ts';
13+
export {
14+
getSectionItemLabels,
15+
collectItemLabelsFromSections,
16+
collectItemLabels
17+
} from './itemLabels.ts';
18+
export type {
19+
ItemLabels,
20+
ItemCustomization,
21+
ItemLabelSource,
22+
ItemLabelsWithSource,
23+
CollectItemLabelsOptions
24+
} from './itemLabels.ts';
1325
export { AppManagingAccount, AppClientAccount, Application, Collector, CollectorClient, CollectorInvite, CollectorRequest, Contact };

ts/appTemplates/itemLabels.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Per-form item label overrides — types and helpers shared by form renderers
3+
* (hds-forms-js) and patient/diary apps (hds-webapp, doctor-dashboard).
4+
*
5+
* Storage model: `CollectorRequest.sections[].itemCustomizations[itemKey].labels`
6+
* — see ItemCustomization below. The same itemKey may be requested by multiple
7+
* forms (across multiple contacts) with different labels. Renderers should
8+
* surface every variant with attribution rather than picking one.
9+
*/
10+
11+
import type { localizableText } from '../localizeText.ts';
12+
import type { CollectorSectionInterface } from './interfaces.ts';
13+
import type { Contact } from './Contact.ts';
14+
15+
/**
16+
* Per-form label overrides for an HDS item, applied on top of the item def.
17+
* Mirrors `section.itemCustomizations[itemKey].labels`.
18+
*/
19+
export interface ItemLabels {
20+
/** Override for the question label (replaces itemDef.label) */
21+
question?: localizableText;
22+
/** Override for the question description (replaces itemDef.description) */
23+
description?: localizableText;
24+
/** Per-option-value overrides for `select` fields, keyed by raw option value */
25+
options?: Record<string | number, localizableText>;
26+
}
27+
28+
/**
29+
* Per-item customizations bag stored on a CollectorRequest section.
30+
* Mirrors `itemCustomizations[itemKey]`.
31+
*/
32+
export interface ItemCustomization {
33+
repeatable?: string;
34+
reminder?: Record<string, unknown>;
35+
labels?: ItemLabels;
36+
[key: string]: unknown;
37+
}
38+
39+
/** Identification of which form / contact a label override comes from. */
40+
export interface ItemLabelSource {
41+
/** Display name of the requesting contact (e.g. "Dr. drandy"). */
42+
contactName?: string;
43+
/** Title of the form/data-set requesting the item. */
44+
formTitle?: localizableText;
45+
/** Section key within the form. */
46+
sectionKey?: string;
47+
/** Optional access/request key for tie-breaking. */
48+
requestKey?: string;
49+
}
50+
51+
/** A label override paired with the form/contact it originated from. */
52+
export interface ItemLabelsWithSource extends ItemLabels {
53+
source: ItemLabelSource;
54+
}
55+
56+
export interface CollectItemLabelsOptions {
57+
/** Only include sections that explicitly override labels. Default true. */
58+
requireLabels?: boolean;
59+
/** Drop duplicate label sets keeping first occurrence. Default true. */
60+
deduplicate?: boolean;
61+
}
62+
63+
/** Read labels (if any) for itemKey from a single section. */
64+
export function getSectionItemLabels (section: CollectorSectionInterface, itemKey: string): ItemLabels | undefined {
65+
if (!section.itemKeys.includes(itemKey)) return undefined;
66+
const cust = section.itemCustomizations?.[itemKey] as ItemCustomization | undefined;
67+
return cust?.labels;
68+
}
69+
70+
function isEmptyLabels (l: ItemLabels | undefined): boolean {
71+
return !l || (l.question == null && l.description == null && (l.options == null || Object.keys(l.options).length === 0));
72+
}
73+
74+
/**
75+
* Collect label overrides for itemKey from a list of (section, source) pairs.
76+
* One entry per section that uses the item; deduplicated by label content.
77+
*/
78+
export function collectItemLabelsFromSections (
79+
itemKey: string,
80+
sources: Array<{ section: CollectorSectionInterface, source: ItemLabelSource }>,
81+
opts: CollectItemLabelsOptions = {}
82+
): ItemLabelsWithSource[] {
83+
const requireLabels = opts.requireLabels ?? true;
84+
const deduplicate = opts.deduplicate ?? true;
85+
const out: ItemLabelsWithSource[] = [];
86+
const seen = new Set<string>();
87+
for (const { section, source } of sources) {
88+
if (!section.itemKeys.includes(itemKey)) continue;
89+
const labels = getSectionItemLabels(section, itemKey) || {};
90+
if (requireLabels && isEmptyLabels(labels)) continue;
91+
const entry: ItemLabelsWithSource = { ...labels, source };
92+
if (deduplicate) {
93+
const sig = JSON.stringify({ q: entry.question, d: entry.description, o: entry.options });
94+
if (seen.has(sig)) continue;
95+
seen.add(sig);
96+
}
97+
out.push(entry);
98+
}
99+
return out;
100+
}
101+
102+
/**
103+
* Gather labels for itemKey from every active CollectorClient on the given
104+
* contacts. Each entry is attributed to the contact and form it came from.
105+
*/
106+
export function collectItemLabels (
107+
itemKey: string,
108+
contacts: Contact[],
109+
opts: CollectItemLabelsOptions = {}
110+
): ItemLabelsWithSource[] {
111+
const sources: Array<{ section: CollectorSectionInterface, source: ItemLabelSource }> = [];
112+
for (const contact of contacts) {
113+
for (const cc of contact.collectorClients) {
114+
if (cc.status !== 'Active') continue;
115+
const formTitle = cc.request?.title;
116+
const sections = cc.getSections() || [];
117+
for (const section of sections) {
118+
sources.push({
119+
section,
120+
source: {
121+
contactName: contact.displayName,
122+
formTitle,
123+
sectionKey: section.key,
124+
requestKey: cc.key
125+
}
126+
});
127+
}
128+
}
129+
}
130+
return collectItemLabelsFromSections(itemKey, sources, opts);
131+
}

0 commit comments

Comments
 (0)