Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5fc3eda
test(graphs): red tests for scroll-first scrub, scrub panel and chart…
mikenrafter Nov 7, 2026
d0d4428
feat(charts): annotation lane on ChartFrame
mikenrafter Nov 7, 2026
d74f80a
test(charts): the ChartFrame annotation rig describes its scrub posit…
mikenrafter Nov 7, 2026
817d541
fix(charts): scrub only on a horizontal drag; a vertical drag scrolls…
mikenrafter Nov 7, 2026
12ce620
feat(l10n): strings for the chart scrub panel
mikenrafter Nov 7, 2026
9fb4999
feat(charts): an always-present scrub panel on the metric, sleep and …
mikenrafter Nov 7, 2026
c3cf5a4
docs(ui2): gallery cases and README for the chart panel and annotatio…
mikenrafter Nov 7, 2026
8330801
feat(l10n): Journal marks chip label; the version-change footnote say…
mikenrafter Nov 7, 2026
92ca0ce
feat(charts): algorithm-version marks and a Journal marks chip on met…
mikenrafter Nov 7, 2026
02503d9
test(charts): red tests for scrub and panel on health HRV, workout lo…
mikenrafter Nov 7, 2026
0bef6f6
fix(ui2): the start card lays out inside a scrolling page
mikenrafter Nov 7, 2026
ce11f19
feat(charts): scrub and panel on health HRV, workout load and the mon…
mikenrafter Nov 7, 2026
dfaa660
fix(a11y): speak the whole chart-panel reading once, at rest and whil…
mikenrafter Nov 7, 2026
2701381
fix(a11y): keep annotation icons and +n clusters as labelled controls
mikenrafter Nov 7, 2026
c114541
fix(charts): anchor SlotScrub days to its series; pin DST and failed-…
mikenrafter Nov 7, 2026
79d6ef3
fix(graphs): restore the per-day device line in the metric-detail panel
mikenrafter Nov 7, 2026
9008e0b
fix(graphs): tier-2 review nits (spoken labels, empty-window caption,…
mikenrafter Nov 7, 2026
19e0b46
fix(graphs): keep annotation tap boxes a full 44 pt tall above the pl…
mikenrafter Nov 7, 2026
9249a1f
refactor(graphs): drop annotations from multi-day charts
mikenrafter Nov 9, 2026
87f8830
refactor(graphs): move the chart annotation engine to a later PR
mikenrafter Nov 9, 2026
a55f9d6
feat(charts): a chevron on the chart panel when the day opens
mikenrafter Nov 9, 2026
cea8841
fix(graphs): review pass 1 on the scrub panels
mikenrafter Nov 9, 2026
dce7078
test(graphs): the readiness panel keeps its height on a missing day
mikenrafter Nov 9, 2026
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
21 changes: 21 additions & 0 deletions lib/compute/vendor_sleep.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ const String kOurRingNightSource = 'hr_5min_stager';
/// ('auto_fallback'), not the device's.
const String kHrWindowNightSource = 'hr_5min_window';

/// The point list a night is drawn and read from: each epoch's stage from its
/// start, closed by a final point at wake. A stretch no epoch covers gets an
/// `unobserved` boundary where the epoch before it ends, so a reader of the
/// list never carries that epoch's stage across the hole.
List<Map<String, Object?>> vendorHypnogram(
List<VendorEpoch> epochs, int wakeSec) {
final out = <Map<String, Object?>>[];
for (var i = 0; i < epochs.length; i++) {
final e = epochs[i];
out.add({'t': e.startSec, 'stage': e.stage});
final next = i + 1 < epochs.length ? epochs[i + 1].startSec : wakeSec;
if (next > e.endSec) out.add({'t': e.endSec, 'stage': 'unobserved'});
}
final last = epochs.last;
out.add({
't': wakeSec,
'stage': last.endSec < wakeSec ? 'unobserved' : last.stage,
});
return out;
}

/// One night of one device's epochs, as `vendor_sleep_epoch` groups them.
class VendorNight {
final String deviceId;
Expand Down
6 changes: 2 additions & 4 deletions lib/data/local_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import '../compute/manual_session.dart';
import '../compute/onehz_pipeline.dart' show kUnknownAbsenceNote, needInputNote;
import '../compute/profile.dart';
import '../compute/quiet_level.dart';
import '../compute/vendor_sleep.dart' show vendorHypnogram;
import 'package:openstrap_protocol/openstrap_protocol.dart' as proto;
import 'package:openstrap_analytics/onehz.dart' as ana;

Expand Down Expand Up @@ -2316,10 +2317,7 @@ class LocalRepositoryImpl extends LocalRepository {
'stage_min': minutes,
// Same point shape as our own night: each stage from its start,
// closed by a final point at wake.
'hypnogram': [
for (final e in n.epochs) {'t': e.startSec, 'stage': e.stage},
{'t': n.offsetSec, 'stage': n.epochs.last.stage},
],
'hypnogram': vendorHypnogram(n.epochs, n.offsetSec),
});
}
return out;
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,10 @@
"metricDetailSlotNoValueRecording": "{day}, kein Wert · {who}",
"metricDetailSlotNothingRecording": "{day}, keine Aufzeichnung",
"metricDetailOpenDay": "{day} öffnen",
"chartPanelLatest": "ZULETZT",
"chartPanelSelected": "AUSGEWÄHLT",
"chartPanelSevenDayAvg": "7-Tage-Schnitt",
"chartPanelVsAvg": "ggü. Schnitt",
"metricDetailNoRecordLabel": "Kein Eintrag",
"metricDetailLowest": "Niedrigster",
"metricDetailTypical": "Typisch",
Expand Down
16 changes: 16 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -5668,6 +5668,22 @@
}
}
},
"chartPanelLatest": "LATEST",
"@chartPanelLatest": {
"description": "Caption prefix on the panel under a chart when nothing is selected: the newest reading. Shown upper-case where the script has case. It is not a judgement such as best."
},
"chartPanelSelected": "SELECTED",
"@chartPanelSelected": {
"description": "Caption prefix on the panel under a chart while a finger is on the chart: the reading the finger is on."
},
"chartPanelSevenDayAvg": "7-day avg",
"@chartPanelSevenDayAvg": {
"description": "Metric-detail panel stat: the last 7 days up to today, the same figure whichever day is shown."
},
"chartPanelVsAvg": "vs avg",
"@chartPanelVsAvg": {
"description": "Metric-detail panel stat: the shown value minus that 7-day average."
},
"metricDetailNoRecordLabel": "No record",
"@metricDetailNoRecordLabel": {
"description": "Value text shown in the touched-day row when that day has no record."
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,10 @@
"metricDetailSlotNoRecord": "{day}, sin registro",
"metricDetailSlotWithValue": "{day}, {value} {unit}",
"metricDetailOpenDay": "Abrir {day}",
"chartPanelLatest": "ÚLTIMO",
"chartPanelSelected": "SELECCIONADO",
"chartPanelSevenDayAvg": "Media de 7 días",
"chartPanelVsAvg": "vs. media",
"metricDetailNoRecordLabel": "Sin registro",
"metricDetailLowest": "Mínimo",
"metricDetailTypical": "Típico",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,10 @@
"metricDetailSlotNoRecord": "{day}, aucun enregistrement",
"metricDetailSlotWithValue": "{day}, {value} {unit}",
"metricDetailOpenDay": "Ouvrir {day}",
"chartPanelLatest": "DERNIER",
"chartPanelSelected": "SÉLECTIONNÉ",
"chartPanelSevenDayAvg": "Moy. sur 7 jours",
"chartPanelVsAvg": "vs moy.",
"metricDetailNoRecordLabel": "Aucun enregistrement",
"metricDetailLowest": "Minimum",
"metricDetailTypical": "Typique",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_hi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,10 @@
"metricDetailSlotNoRecord": "{day}, कोई रिकॉर्ड नहीं",
"metricDetailSlotWithValue": "{day}, {value} {unit}",
"metricDetailOpenDay": "{day} खोलें",
"chartPanelLatest": "नवीनतम",
"chartPanelSelected": "चुना गया",
"chartPanelSevenDayAvg": "7 दिन का औसत",
"chartPanelVsAvg": "औसत से अंतर",
"metricDetailNoRecordLabel": "कोई रिकॉर्ड नहीं",
"metricDetailLowest": "न्यूनतम",
"metricDetailTypical": "सामान्य",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_ru.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,10 @@
}
}
},
"chartPanelLatest": "ПОСЛЕДНЕЕ",
"chartPanelSelected": "ВЫБРАНО",
"chartPanelSevenDayAvg": "Среднее за 7 дн.",
"chartPanelVsAvg": "от среднего",
"metricDetailNoRecordLabel": "Нет записи",
"metricDetailLowest": "Наименьшее",
"metricDetailTypical": "Обычное",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_th.arb
Original file line number Diff line number Diff line change
Expand Up @@ -5668,6 +5668,10 @@
}
}
},
"chartPanelLatest": "ล่าสุด",
"chartPanelSelected": "ที่เลือก",
"chartPanelSevenDayAvg": "ค่าเฉลี่ย 7 วัน",
"chartPanelVsAvg": "เทียบค่าเฉลี่ย",
"metricDetailNoRecordLabel": "ไม่มีข้อมูลบันทึก",
"@metricDetailNoRecordLabel": {
"description": "Value text shown in the touched-day row when that day has no record."
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,10 @@
"metricDetailSlotNoRecord": "{day},无记录",
"metricDetailSlotWithValue": "{day},{value} {unit}",
"metricDetailOpenDay": "打开{day}",
"chartPanelLatest": "最新",
"chartPanelSelected": "已选",
"chartPanelSevenDayAvg": "7 天平均",
"chartPanelVsAvg": "与平均相比",
"metricDetailNoRecordLabel": "无记录",
"metricDetailLowest": "最低",
"metricDetailTypical": "典型",
Expand Down
50 changes: 49 additions & 1 deletion lib/ui2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,53 @@ that did not exist.
`Scrubber` is the only drag: it carries the slider role, so increase/decrease
reach it without a pointer, and `describe` is what those steps say out loud.

### ChartPanel — what a chart reads, always on screen

```dart
ChartPanel({required String? time, required bool scrubbed, required Color color,
required List<ChartPanelCell> cells, List<ChartPanelCell> stats = const [],
List<String> timeSpan = const [], VoidCallback? onOpen, String? openLabel})
ChartPanelCell(String label, {Color? color, String? value, Color? valueColor})
```

Put it under a chart that has a `Scrubber`, in the same card. It is there at
rest and while a finger is on the chart, so the chart never changes height under
the thumb and nothing is readable only while it is held. There is no pop-in.

- **Caption.** `LATEST · <time>` at rest (the newest reading, or on a night the
stage at wake), `SELECTED · <time>` while scrubbing. Neither is a judgement.
While scrubbing the caption and a 1.5 pt border take the series colour
(`p.on(...)`, so they clear 4.5:1 on `card2`); at rest there is no border.
- **Box.** `card2` fill, `R.rMd`, no shadow, inset in the chart's card. Its rect
is the same at rest, scrubbing, on a gap and at any text size. The caption
reserves the height of the tallest of `timeSpan` (every time the panel can
show), measured once per span, width, scale and locale, so a day name that
wraps in one language cannot move the panel.
- **Cells.** One per series, each its label and the value at that instant.
A series with nothing there reads `—`: never a neighbour's value, never an
interpolation. One cell is drawn large; several are equal width, one row when
they fit and wrapped when they do not.
- **Stats.** Summary figures of the one series (`7-day avg`, `vs avg` on metric
detail), spoken as one `label value` node. A stat with no value is a dash and
is not spoken.
- **Spoken once, by one owner at a time.** While a finger is on the chart the
`Scrubber` speaks the whole selected reading (`describe` carries every value
the panel shows, a missing one as "No record") and the panel's caption and
cells are excluded. At rest nothing is selected, so the panel speaks the
`LATEST · <day>, label value, …` reading itself, as one node, and the slider
says only "Nothing selected". `Scrubber.describe` and the panel must read the
same thing at every position. A screen with two panels for one instant (the
sleep night) speaks each chart's reading once, so each value is heard once per
chart.
- **Open the day.** `onOpen` makes the whole panel an `Open <day>` button of at
least 48 dp. Pass it only for a day with a value: an action is a promise, and
an empty day has no screen behind it.

The caller owns the reading. Metric detail, readiness and the two sleep charts
each build the cells from the series the painter draws; the sleep charts read
the stage from the night's timestamped boundaries (not the resample the painter
draws) and a signal from its nearest sample within 15 minutes, else a dash.

### Layout primitives

```dart
Expand Down Expand Up @@ -373,7 +420,8 @@ Five rules:
curve, and it does not take taps. **A mark without a `footnote` is a line
nobody can read**: the footnote is the mark's only screen-reader form and
the only thing that can say what it is. Keep the wording flat — a version
change is provenance, not something that happened to the user.
change is provenance, not something that happened to the user. Multi-day
charts (metric detail) use `xMarks`.
4. **More than one colour means a `legend`.** Use the painters' own, never
retyped: `Hypnogram.legend(p, l)`, `IntervalLadder.legend` (instance).
The swatch is the mark's *solved* colour, so the key and the plot can never
Expand Down
Loading