From 8a9805db83dff401f284e1c1049caa142119398f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:41:45 +0530 Subject: [PATCH 1/2] journal entries that carry a number, not just a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag can only say whether something happened. Most of what people wanted to log has a size: mood, sleep quality, how much water, how much caffeine and when. One coffee and five coffees are not the same day, and the tag vocabulary cannot tell them apart. Nine built-in fields, plus your own — name it, say whether it is a 1-5 rating, an amount, or minutes, and it behaves like a built-in everywhere after that. Caffeine and alcohol also ask when the last one was, because a 200 mg morning coffee and a 200 mg evening one are the same dose and a completely different night. The thing every control here is shaped around: absent and zero are different answers. "No caffeine today" is a measurement, "I didn't fill this in" is not, and reading the second as the first invents a data point at the bottom of the dose range. So a field starts unset, one tap on minus reaches a real zero, a value can always be cleared back to unset, and clearing one deletes it rather than leaving a stale reading nobody made. Ratings run 1-5 rather than 1-10. A ten-point self-report is not ten distinguishable states, and the extra resolution is noise. Forgetting a custom field forgets its label, not its history. Those readings were still real. The journal screen also stops keeping its own private copy of the tag list, which is how it would have drifted from the compose screen the first time either changed. --- lib/data/db.dart | 212 +++++++++- lib/data/journal_fields.dart | 236 +++++++++++ lib/data/local_repository.dart | 22 + lib/data/local_repository_impl.dart | 47 +++ .../journal/custom_journal_field_sheet.dart | 255 ++++++++++++ lib/ui/journal/journal_metric_editor.dart | 385 ++++++++++++++++++ lib/ui/journal/journal_screen.dart | 91 ++++- test/db_migration_ladder_test.dart | 51 +++ test/journal_fields_test.dart | 163 ++++++++ test/journal_metric_store_test.dart | 195 +++++++++ 10 files changed, 1649 insertions(+), 8 deletions(-) create mode 100644 lib/data/journal_fields.dart create mode 100644 lib/ui/journal/custom_journal_field_sheet.dart create mode 100644 lib/ui/journal/journal_metric_editor.dart create mode 100644 test/journal_fields_test.dart create mode 100644 test/journal_metric_store_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 2f880d26..34eb69d1 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -17,6 +17,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import 'day_label.dart'; +import 'journal_fields.dart'; import 'live_coverage_policy.dart'; import 'models.dart'; @@ -90,7 +91,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 27; + static const int schemaVersion = 28; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -402,6 +403,14 @@ class LocalDb { // for steps), and falls back to band rows otherwise. await _ensureLiveCoverageSource(db); } + if (oldV < 28) { + // The numeric half of a journal entry, plus definitions for + // user-invented fields. Purely new tables — the existing `journal` + // row for a day is untouched, so an upgrade loses no tags and no + // notes, and a day with only tags simply has no metric rows. + await _createJournalMetric(db); + await _createJournalFieldDef(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1295,6 +1304,66 @@ class LocalDb { ); } + /// journal_metric — the numeric half of a journal entry. + /// + /// The `journal` table holds a tag set and a note, which can only ever + /// answer "did this happen today". A field that carries a NUMBER — three + /// coffees, 700 ml of water, mood 4 out of 5 — carries a dose, and that is + /// usually the actual question. Kept in its own table rather than as columns + /// on `journal` so a user-defined field costs a row, not a migration. + /// + /// One row per (day, field): the value is the day's TOTAL for a dose-like + /// field and the day's single reading for a rating. + /// + /// `at_min` is local minutes past midnight for the LATEST occurrence, and is + /// null for anything without a meaningful time. It exists because when a + /// dose landed can matter more than its size — the sleep-relevant fact about + /// caffeine is the last cup, not the total. + static Future _createJournalMetric(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS journal_metric ( + date TEXT NOT NULL, + field TEXT NOT NULL, + value REAL NOT NULL, + at_min INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (date, field) + ) + '''); + // Correlations read one field across every day, so the index is on the + // field first — the primary key already covers day-scoped reads. + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_journal_metric_field ' + 'ON journal_metric(field, date)', + ); + } + + /// journal_field_def — definitions for USER-INVENTED numeric fields only. + /// + /// Built-in fields live in `lib/data/journal_fields.dart` as code, because a + /// definition that ships with the app should not be editable data. A custom + /// field has nowhere else to record what its number means, and without a + /// unit and a ceiling its values render as bare numbers and its entry has no + /// bounds — so it gets a row. + /// + /// Deleting a definition deliberately does NOT delete its history: those + /// readings were still real. They render unlabelled until the field is + /// defined again. + static Future _createJournalFieldDef(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS journal_field_def ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + kind TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT '', + max_value REAL NOT NULL, + step REAL NOT NULL, + has_time INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ) + '''); + } + // ── USER-DATA STORE (journal / cycle / workouts / notifications) ──────────── // On-device user-entered + locally-generated data. All keyed for idempotent // upserts; none of it round-trips to a server (cloud excised). @@ -1308,6 +1377,8 @@ class LocalDb { updated_at INTEGER NOT NULL ) '''); + await _createJournalMetric(db); + await _createJournalFieldDef(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -3394,6 +3465,11 @@ class LocalDb { await copyRows('day_result', where: 'day_id = ?', whereArgs: [dayId]); await copyRows('metric_series', where: 'date = ?', whereArgs: [dayId]); await copyRows('journal', where: 'date = ?', whereArgs: [dayId]); + await copyRows( + 'journal_metric', + where: 'date = ?', + whereArgs: [dayId], + ); await copyRows('cycle_log', where: 'date = ?', whereArgs: [dayId]); await copyRows('notifications', where: 'date = ?', whereArgs: [dayId]); await copyRows( @@ -3491,6 +3567,7 @@ class LocalDb { await deleteByIn(txn, 'day_result', 'day_id', sorted); await deleteByIn(txn, 'metric_series', 'date', sorted); await deleteByIn(txn, 'journal', 'date', sorted); + await deleteByIn(txn, 'journal_metric', 'date', sorted); await deleteByIn(txn, 'cycle_log', 'date', sorted); await deleteByIn(txn, 'notifications', 'date', sorted); await deleteByIn(txn, 'sleep_session_candidates', 'day_id', sorted); @@ -3529,6 +3606,8 @@ class LocalDb { 'metric_series', 'sessions', 'journal', + 'journal_metric', + 'journal_field_def', 'cycle_log', 'notifications', 'baselines', @@ -3835,6 +3914,8 @@ class LocalDb { 'baselines', 'sessions', 'journal', + 'journal_metric', + 'journal_field_def', 'cycle_log', 'notifications', 'sync_cursor', @@ -4507,6 +4588,135 @@ class LocalDb { return db.query('journal', orderBy: 'date DESC'); } + /// Replace one day's numeric journal fields. + /// + /// The map IS the day: a field that is absent from [fields] is DELETED for + /// that date, not left behind. Clearing a value the user cleared matters + /// more than it sounds — a stale "3 coffees" that survives an edit becomes a + /// data point the user never entered, and correlations are exactly where + /// that does damage. + /// + /// Written in one transaction so a day is never half-updated. + static Future putJournalMetrics( + String date, + Map fields, + ) async { + final db = await instance; + final now = DateTime.now().millisecondsSinceEpoch; + await db.transaction((txn) async { + await txn.delete('journal_metric', where: 'date = ?', whereArgs: [date]); + for (final e in fields.entries) { + await txn.insert('journal_metric', { + 'date': date, + 'field': e.key, + 'value': e.value.value, + 'at_min': e.value.atMinuteOfDay, + 'updated_at': now, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + /// One day's numeric fields, or an empty map when nothing was recorded. + static Future> journalMetricsForDay( + String date, + ) async { + final db = await instance; + final rows = await db.query( + 'journal_metric', + where: 'date = ?', + whereArgs: [date], + ); + return { + for (final r in rows) + r['field'] as String: JournalMetricValue( + (r['value'] as num).toDouble(), + atMinuteOfDay: (r['at_min'] as num?)?.toInt(), + ), + }; + } + + /// Numeric journal fields per day, oldest first, for the correlation pass. + /// [sinceDaysEpoch] is an optional inclusive lower bound on `date`. + static Future>> + journalMetricsByDay({String? sinceDaysEpoch}) async { + final db = await instance; + final rows = sinceDaysEpoch == null + ? await db.query('journal_metric', orderBy: 'date ASC') + : await db.query( + 'journal_metric', + where: 'date >= ?', + whereArgs: [sinceDaysEpoch], + orderBy: 'date ASC', + ); + final out = >{}; + for (final r in rows) { + (out[r['date'] as String] ??= {})[r['field'] as String] = + JournalMetricValue( + (r['value'] as num).toDouble(), + atMinuteOfDay: (r['at_min'] as num?)?.toInt(), + ); + } + return out; + } + + /// Every field name that has ever been recorded, so a user-defined field + /// keeps appearing in the editor after the day it was invented on. + static Future> journalMetricFields() async { + final db = await instance; + final rows = await db.rawQuery( + 'SELECT DISTINCT field FROM journal_metric ORDER BY field ASC', + ); + return [for (final r in rows) r['field'] as String]; + } + + /// Custom field definitions, ordered by label. + static Future> journalFieldDefs() async { + final db = await instance; + final rows = await db.query('journal_field_def', orderBy: 'label ASC'); + return [ + for (final r in rows) + JournalFieldSpec( + key: r['key'] as String, + label: r['label'] as String, + kind: JournalFieldKind.values.firstWhere( + (k) => k.name == r['kind'], + // A row written by a newer build with a kind this one has never + // heard of still renders as a dose rather than crashing the whole + // journal screen. + orElse: () => JournalFieldKind.dose, + ), + unit: r['unit'] as String, + max: (r['max_value'] as num).toDouble(), + step: (r['step'] as num).toDouble(), + hasTime: ((r['has_time'] as num?)?.toInt() ?? 0) == 1, + custom: true, + ), + ]; + } + + static Future putJournalFieldDef(JournalFieldSpec spec) async { + final db = await instance; + await db.insert('journal_field_def', { + 'key': spec.key, + 'label': spec.label, + 'kind': spec.kind.name, + 'unit': spec.unit, + 'max_value': spec.max, + 'step': spec.step, + 'has_time': spec.hasTime ? 1 : 0, + 'created_at': DateTime.now().millisecondsSinceEpoch, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + /// Forget a custom field's DEFINITION. Its recorded values are deliberately + /// left alone — they were real readings, and deleting a label should not + /// delete history. + static Future deleteJournalFieldDef(String key) async { + final db = await instance; + await db.delete('journal_field_def', where: 'key = ?', whereArgs: [key]); + } + // ── cycle log I/O ───────────────────────────────────────────────────────────── static Future putCycleLog( diff --git a/lib/data/journal_fields.dart b/lib/data/journal_fields.dart new file mode 100644 index 00000000..f87d4db4 --- /dev/null +++ b/lib/data/journal_fields.dart @@ -0,0 +1,236 @@ +// The numeric half of a journal entry — the vocabulary, not the storage. +// +// `journal` holds a tag set and a note, which can only ever answer "did this +// happen today". These fields carry a NUMBER, and the number is usually the +// question: one coffee and five coffees are not the same day, and a tag cannot +// tell them apart. +// +// Built-in fields live in [kJournalFields] as a plain constant table, the same +// shape as the workout-type table — extend it HERE, never with a local map in +// a screen. User-invented fields carry their own [JournalFieldSpec] loaded +// from the database, so a custom field behaves exactly like a built-in one +// everywhere downstream. + +import 'package:flutter/foundation.dart'; + +/// What a field measures, which decides how it is entered and read back. +enum JournalFieldKind { + /// A subjective 1–5 self-report (mood, sleep quality). Ordinal: the gap + /// between 3 and 4 is not claimed to equal the gap between 4 and 5, which is + /// exactly why the correlation over these is rank-based. + rating, + + /// A physical quantity with a unit (ml, mg, units). + dose, + + /// Minutes. + duration, +} + +/// One day's value for one field. +@immutable +class JournalMetricValue { + const JournalMetricValue(this.value, {this.atMinuteOfDay}); + + /// The day's total for a dose, or the day's single reading for a rating. + final double value; + + /// Local minutes past midnight for the LATEST occurrence, or null when the + /// field has no meaningful time. Held because when a dose landed often + /// matters more than how big it was — the sleep-relevant fact about caffeine + /// is the last cup, not the total. + final int? atMinuteOfDay; + + @override + bool operator ==(Object other) => + other is JournalMetricValue && + other.value == value && + other.atMinuteOfDay == atMinuteOfDay; + + @override + int get hashCode => Object.hash(value, atMinuteOfDay); + + @override + String toString() => 'JournalMetricValue($value, at: $atMinuteOfDay)'; +} + +@immutable +class JournalFieldSpec { + const JournalFieldSpec({ + required this.key, + required this.label, + required this.kind, + required this.unit, + required this.max, + required this.step, + this.hasTime = false, + this.custom = false, + }); + + /// Stored `field` value. Lowercase and stable — renaming one orphans its + /// history, so labels change freely and keys never do. + final String key; + final String label; + final JournalFieldKind kind; + + /// Shown after the number; empty for ratings. + final String unit; + + /// Entry ceiling. Not a physiological claim — it exists so a slip on a + /// stepper cannot enter 40 coffees and quietly dominate every correlation + /// that field appears in. + final double max; + + /// Increment for one tap of the stepper. + final double step; + + /// Whether the field offers a "last one at" time. + final bool hasTime; + + /// User-invented rather than built in. + final bool custom; + + bool get isRating => kind == JournalFieldKind.rating; + + /// Human-readable value, without the unit. + String format(double v) { + if (isRating) return v.round().toString(); + // Doses are entered on whole steps, so a trailing .0 is noise. + return v == v.roundToDouble() + ? v.round().toString() + : v.toStringAsFixed(1); + } + + String formatWithUnit(double v) => + unit.isEmpty ? format(v) : '${format(v)} $unit'; +} + +/// The built-in fields, in the order the editor shows them. +/// +/// Ratings run 1–5 rather than 1–10: a ten-point self-report is not ten +/// distinguishable states, and the extra resolution is noise that a rank +/// correlation then has to see through. +const kJournalFields = [ + JournalFieldSpec( + key: 'mood', + label: 'Mood', + kind: JournalFieldKind.rating, + unit: '', + max: 5, + step: 1, + ), + JournalFieldSpec( + key: 'sleep_quality', + label: 'Sleep quality', + kind: JournalFieldKind.rating, + unit: '', + max: 5, + step: 1, + ), + JournalFieldSpec( + key: 'energy', + label: 'Energy', + kind: JournalFieldKind.rating, + unit: '', + max: 5, + step: 1, + ), + JournalFieldSpec( + key: 'stress', + label: 'Stress', + kind: JournalFieldKind.rating, + unit: '', + max: 5, + step: 1, + ), + JournalFieldSpec( + key: 'soreness', + label: 'Soreness', + kind: JournalFieldKind.rating, + unit: '', + max: 5, + step: 1, + ), + JournalFieldSpec( + key: 'water_ml', + label: 'Water', + kind: JournalFieldKind.dose, + unit: 'ml', + max: 6000, + step: 250, + ), + // Caffeine is the field the timing support exists for. A 200 mg morning + // coffee and a 200 mg evening one are the same dose and a completely + // different night, and collapsing them loses the only part that predicts + // anything. + JournalFieldSpec( + key: 'caffeine_mg', + label: 'Caffeine', + kind: JournalFieldKind.dose, + unit: 'mg', + max: 1000, + step: 25, + hasTime: true, + ), + JournalFieldSpec( + key: 'alcohol_units', + label: 'Alcohol', + kind: JournalFieldKind.dose, + unit: 'units', + max: 20, + step: 1, + hasTime: true, + ), + JournalFieldSpec( + key: 'screens_min', + label: 'Screens before bed', + kind: JournalFieldKind.duration, + unit: 'min', + max: 480, + step: 15, + ), +]; + +/// Built-ins by key, for a lookup that does not walk the list. +final Map kJournalFieldsByKey = { + for (final f in kJournalFields) f.key: f, +}; + +/// Resolve a stored field key to its spec, preferring built-ins. +/// +/// Returns null for a key with no definition at all — a field whose custom +/// definition was deleted while its history remained. Callers must render +/// those as raw values rather than inventing a unit for them. +JournalFieldSpec? journalFieldSpec( + String key, { + List custom = const [], +}) { + final builtIn = kJournalFieldsByKey[key]; + if (builtIn != null) return builtIn; + for (final c in custom) { + if (c.key == key) return c; + } + return null; +} + +/// A stable storage key for a user-invented field name. +/// +/// Prefixed so a custom field can never collide with a built-in one, present +/// or future: adding `magnesium` to [kJournalFields] later must not silently +/// adopt somebody's existing custom column and reinterpret its units. +String customJournalFieldKey(String label) { + final slug = label + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + return 'custom_$slug'; +} + +/// Local minutes past midnight → "7:05 AM". +String formatMinuteOfDay(int minuteOfDay) { + final m = minuteOfDay % (24 * 60); + final h24 = m ~/ 60; + final mm = (m % 60).toString().padLeft(2, '0'); + final h = h24 % 12 == 0 ? 12 : h24 % 12; + return '$h:$mm ${h24 < 12 ? 'AM' : 'PM'}'; +} diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index ef7bb5c3..de857c8b 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -14,6 +14,7 @@ import '../compute/manual_session.dart' show SessionSpan; import '../gps/route_models.dart'; +import 'journal_fields.dart'; /// The single source of truth for "no step goal configured yet" (8k/day is /// the commonly-cited optimal benefit/cost ratio for step count). Both the @@ -179,6 +180,27 @@ abstract class LocalRepository { Future> getJournalInsights({String range = '90d'}) => throw UnimplementedError('re-layer: getJournalInsights'); + /// One day's numeric journal fields, keyed by field name. + Future> getJournalMetrics(String date) => + throw UnimplementedError('re-layer: getJournalMetrics'); + + /// Replace one day's numeric fields. A field absent from [fields] is cleared + /// for that day — the map IS the day, not a patch on it. + Future postJournalMetrics( + String date, + Map fields, + ) => throw UnimplementedError('re-layer: postJournalMetrics'); + + /// Built-in fields followed by the user's own, in editor order. + Future> getJournalFields() => + throw UnimplementedError('re-layer: getJournalFields'); + + Future postCustomJournalField(JournalFieldSpec spec) => + throw UnimplementedError('re-layer: postCustomJournalField'); + + Future deleteCustomJournalField(String key) => + throw UnimplementedError('re-layer: deleteCustomJournalField'); + // ── menstrual cycle ──────────────────────────────────────────────────────────── Future> getCycle() => throw UnimplementedError('re-layer: getCycle'); diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 923eaf14..59ee9257 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -26,6 +26,7 @@ import 'package:openstrap_analytics/onehz.dart' as ana; import 'day_label.dart'; import 'db.dart'; +import 'journal_fields.dart'; import 'local_repository.dart'; import '../gps/route_models.dart'; import '../gps/route_math.dart' as rmath; @@ -2706,6 +2707,52 @@ class LocalRepositoryImpl extends LocalRepository { await LocalDb.putJournal(date, jsonEncode(tags), note); } + @override + Future> getJournalMetrics(String date) => + LocalDb.journalMetricsForDay(date); + + @override + Future postJournalMetrics( + String date, + Map fields, + ) async { + // Clamp on the way in rather than trusting the editor. A value past the + // field's ceiling is almost always a mis-tap, and a single 40-coffee day + // would dominate every correlation that field appears in for months. + final specs = await getJournalFields(); + final clamped = {}; + for (final e in fields.entries) { + final spec = journalFieldSpec( + e.key, + custom: specs.where((s) => s.custom).toList(), + ); + final v = spec == null + ? e.value.value + : e.value.value.clamp(0.0, spec.max).toDouble(); + // A zero is a real answer ("no caffeine today") and is stored as one. + // Absence is expressed by leaving the field out of the map entirely. + clamped[e.key] = JournalMetricValue( + v, + atMinuteOfDay: e.value.atMinuteOfDay, + ); + } + await LocalDb.putJournalMetrics(date, clamped); + } + + @override + Future> getJournalFields() async => [ + ...kJournalFields, + ...await LocalDb.journalFieldDefs(), + ]; + + @override + Future postCustomJournalField(JournalFieldSpec spec) => + LocalDb.putJournalFieldDef(spec); + + @override + Future deleteCustomJournalField(String key) => + LocalDb.deleteJournalFieldDef(key); + /// For each distinct tag in the window, compare mean readiness on tagged days /// vs the window mean and emit a metric-delta card (only when n_with >= 2). @override diff --git a/lib/ui/journal/custom_journal_field_sheet.dart b/lib/ui/journal/custom_journal_field_sheet.dart new file mode 100644 index 00000000..6ec59038 --- /dev/null +++ b/lib/ui/journal/custom_journal_field_sheet.dart @@ -0,0 +1,255 @@ +// "Track something else" — define a user-invented numeric journal field. +// +// A custom field has to declare what its number MEANS, because nothing else +// can: without a unit its values render as bare numbers, and without a ceiling +// a mis-tap can enter a value that dominates every correlation it appears in. + +import 'package:flutter/material.dart'; + +import '../../data/journal_fields.dart'; +import '../design/design.dart'; + +/// Opens the sheet. Returns the new field, or null if dismissed. +/// +/// [existingKeys] are rejected on save so a second field cannot quietly +/// overwrite the first one's history by colliding on the generated key. +Future showCustomJournalFieldSheet( + BuildContext context, { + required Set existingKeys, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => Padding( + // The sheet holds a text field; without this it sits under the keyboard. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), + child: _CustomFieldSheet(existingKeys: existingKeys), + ), + ); +} + +class _CustomFieldSheet extends StatefulWidget { + const _CustomFieldSheet({required this.existingKeys}); + final Set existingKeys; + + @override + State<_CustomFieldSheet> createState() => _CustomFieldSheetState(); +} + +class _CustomFieldSheetState extends State<_CustomFieldSheet> { + final _nameCtrl = TextEditingController(); + final _unitCtrl = TextEditingController(); + JournalFieldKind _kind = JournalFieldKind.rating; + double _max = 5; + double _step = 1; + bool _hasTime = false; + String? _error; + + @override + void dispose() { + _nameCtrl.dispose(); + _unitCtrl.dispose(); + super.dispose(); + } + + void _selectKind(JournalFieldKind k) { + setState(() { + _kind = k; + // Sensible shapes per kind, so the common case needs no further tapping. + switch (k) { + case JournalFieldKind.rating: + _max = 5; + _step = 1; + _unitCtrl.text = ''; + _hasTime = false; + case JournalFieldKind.dose: + _max = 100; + _step = 1; + case JournalFieldKind.duration: + _max = 480; + _step = 15; + _unitCtrl.text = 'min'; + } + }); + } + + void _save() { + final label = _nameCtrl.text.trim(); + if (label.isEmpty) { + setState(() => _error = 'Give it a name'); + return; + } + final key = customJournalFieldKey(label); + // A name that slugs to nothing ("???") would produce a bare `custom_` + // key that every other such name also produces. + if (key == 'custom_') { + setState(() => _error = 'Use at least one letter or number'); + return; + } + if (widget.existingKeys.contains(key)) { + setState(() => _error = 'You already track something by that name'); + return; + } + Navigator.pop( + context, + JournalFieldSpec( + key: key, + label: label, + kind: _kind, + unit: _kind == JournalFieldKind.rating ? '' : _unitCtrl.text.trim(), + max: _max, + step: _step, + hasTime: _hasTime, + custom: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Track something else', style: AppText.h2), + const SizedBox(height: Sp.x4), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _field( + controller: _nameCtrl, + hint: 'Magnesium, screen time, headache…', + ), + const SizedBox(height: Sp.x4), + _label('What kind of number is it?'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + ToggleChip( + 'A 1–5 rating', + selected: _kind == JournalFieldKind.rating, + onTap: () => _selectKind(JournalFieldKind.rating), + ), + ToggleChip( + 'An amount', + selected: _kind == JournalFieldKind.dose, + onTap: () => _selectKind(JournalFieldKind.dose), + ), + ToggleChip( + 'Minutes', + selected: _kind == JournalFieldKind.duration, + onTap: () => + _selectKind(JournalFieldKind.duration), + ), + ], + ), + if (_kind != JournalFieldKind.rating) ...[ + const SizedBox(height: Sp.x4), + _label('Unit'), + _field(controller: _unitCtrl, hint: 'mg, ml, cups…'), + const SizedBox(height: Sp.x4), + _label('Step size'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final s in const [1.0, 5.0, 15.0, 50.0, 100.0]) + ToggleChip( + s.round().toString(), + selected: _step == s, + onTap: () => setState(() { + _step = s; + // The ceiling must stay above the step or + // the field can only ever hold 0. + if (_max < s) _max = s * 20; + }), + ), + ], + ), + const SizedBox(height: Sp.x4), + _label('Most you would log in a day'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final m in [ + _step * 10, + _step * 20, + _step * 50, + _step * 100, + ]) + ToggleChip( + m.round().toString(), + selected: _max == m, + onTap: () => setState(() => _max = m), + ), + ], + ), + const SizedBox(height: Sp.x4), + ToggleChip( + 'Ask when the last one was', + selected: _hasTime, + onTap: () => setState(() => _hasTime = !_hasTime), + ), + ], + if (_error != null) ...[ + const SizedBox(height: Sp.x3), + Text( + _error!, + style: AppText.label.copyWith(color: AppColors.bad), + ), + ], + ], + ), + ), + ), + const SizedBox(height: Sp.x4), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _save, + child: const Text('Start tracking it'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _label(String text) => Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Text(text, style: AppText.label.copyWith(color: AppColors.inkSoft)), + ); + + Widget _field({ + required TextEditingController controller, + required String hint, + }) => TextField( + controller: controller, + style: AppText.body, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: hint, + hintStyle: AppText.bodySoft.copyWith(color: AppColors.inkMuted), + filled: true, + fillColor: AppColors.surfaceAlt, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.cardSm), + borderSide: BorderSide.none, + ), + ), + ); +} diff --git a/lib/ui/journal/journal_metric_editor.dart b/lib/ui/journal/journal_metric_editor.dart new file mode 100644 index 00000000..fbe81a05 --- /dev/null +++ b/lib/ui/journal/journal_metric_editor.dart @@ -0,0 +1,385 @@ +// The numeric half of the journal editor — one row per field. +// +// The contract that shapes every control here: ABSENT AND ZERO ARE DIFFERENT. +// "No caffeine today" is a measurement; "I didn't fill this in" is not, and a +// correlation that reads the second as the first invents a point at the bottom +// of the dose range. So every field starts unset, a value can always be +// cleared back to unset, and a real 0 is reachable and looks different from +// nothing. + +import 'package:flutter/material.dart'; + +import '../../data/journal_fields.dart'; +import '../design/design.dart'; + +class JournalMetricEditor extends StatelessWidget { + const JournalMetricEditor({ + super.key, + required this.specs, + required this.values, + required this.onChanged, + this.onAddField, + this.onRemoveField, + }); + + /// Built-ins followed by the user's own. + final List specs; + + /// Current day's values. A key absent here means the field is unset. + final Map values; + + /// Called with the complete next map — the caller holds the state. + final ValueChanged> onChanged; + + /// Null hides the "add your own" affordance. + final VoidCallback? onAddField; + + /// Called to forget a custom field's definition. + final ValueChanged? onRemoveField; + + void _set(String key, JournalMetricValue? v) { + final next = Map.from(values); + if (v == null) { + next.remove(key); + } else { + next[key] = v; + } + onChanged(next); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final spec in specs) + Padding( + padding: const EdgeInsets.only(bottom: Sp.x3), + child: _FieldRow( + spec: spec, + value: values[spec.key], + onChanged: (v) => _set(spec.key, v), + onRemove: spec.custom && onRemoveField != null + ? () => onRemoveField!(spec) + : null, + ), + ), + if (onAddField != null) + Pressable( + pressedScale: 0.96, + onTap: onAddField, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: Sp.x1), + Text( + 'Track something else', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ], + ), + ), + ], + ); + } +} + +class _FieldRow extends StatelessWidget { + const _FieldRow({ + required this.spec, + required this.value, + required this.onChanged, + this.onRemove, + }); + + final JournalFieldSpec spec; + final JournalMetricValue? value; + final ValueChanged onChanged; + final VoidCallback? onRemove; + + Future _pickTime(BuildContext context) async { + final current = value; + if (current == null) return; + final initial = current.atMinuteOfDay ?? 20 * 60; + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: initial ~/ 60, minute: initial % 60), + ); + if (picked == null) return; + onChanged( + JournalMetricValue( + current.value, + atMinuteOfDay: picked.hour * 60 + picked.minute, + ), + ); + } + + @override + Widget build(BuildContext context) { + final v = value; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + spec.label, + style: AppText.label.copyWith(color: AppColors.inkSoft), + ), + ), + if (v != null) + Semantics( + button: true, + label: 'Clear ${spec.label}', + child: Pressable( + pressedScale: 0.9, + onTap: () => onChanged(null), + child: Padding( + padding: const EdgeInsets.all(Sp.x1), + child: Icon( + Icons.close_rounded, + size: 15, + color: AppColors.inkMuted, + ), + ), + ), + ), + if (onRemove != null) + Semantics( + button: true, + label: 'Stop tracking ${spec.label}', + child: Pressable( + pressedScale: 0.9, + onTap: onRemove, + child: Padding( + padding: const EdgeInsets.all(Sp.x1), + child: Icon( + Icons.delete_outline_rounded, + size: 15, + color: AppColors.inkMuted, + ), + ), + ), + ), + ], + ), + const SizedBox(height: Sp.x2), + if (spec.isRating) + _RatingDots( + spec: spec, + value: v?.value, + onChanged: (r) => + onChanged(r == null ? null : JournalMetricValue(r)), + ) + else + _Stepper( + spec: spec, + value: v, + onChanged: onChanged, + onPickTime: spec.hasTime && v != null + ? () => _pickTime(context) + : null, + ), + ], + ); + } +} + +/// 1..max as tappable dots. Tapping the current value clears it, which is the +/// only way to un-answer a rating — there is no "0 out of 5" mood. +class _RatingDots extends StatelessWidget { + const _RatingDots({ + required this.spec, + required this.value, + required this.onChanged, + }); + + final JournalFieldSpec spec; + final double? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final max = spec.max.round(); + return Row( + children: [ + for (var i = 1; i <= max; i++) + Padding( + padding: const EdgeInsets.only(right: Sp.x2), + child: Semantics( + button: true, + selected: value != null && value!.round() == i, + label: '${spec.label} $i of $max', + child: Pressable( + pressedScale: 0.9, + onTap: () => onChanged(value?.round() == i ? null : i * 1.0), + child: AnimatedContainer( + duration: Motion.fast, + curve: Motion.curve, + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: value != null && value!.round() >= i + ? AppColors.tonalFill(AppColors.accent) + : Elevation.surfaceAt(1), + border: Border.all( + color: value != null && value!.round() == i + ? AppColors.accent.withValues(alpha: 0.6) + : AppColors.divider, + ), + ), + child: Text( + '$i', + style: AppText.label.copyWith( + color: value != null && value!.round() >= i + ? AppColors.accent + : AppColors.inkMuted, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _Stepper extends StatelessWidget { + const _Stepper({ + required this.spec, + required this.value, + required this.onChanged, + this.onPickTime, + }); + + final JournalFieldSpec spec; + final JournalMetricValue? value; + final ValueChanged onChanged; + final VoidCallback? onPickTime; + + void _bump(double delta) { + final current = value; + // The first tap on + lands on one step; the first tap on − lands on a real + // zero, because "none today" is an answer worth being able to give in one + // tap rather than something you have to step down to. + if (current == null) { + onChanged(JournalMetricValue(delta > 0 ? spec.step : 0)); + return; + } + final next = (current.value + delta).clamp(0.0, spec.max).toDouble(); + onChanged( + JournalMetricValue(next, atMinuteOfDay: current.atMinuteOfDay), + ); + } + + @override + Widget build(BuildContext context) { + final v = value; + return Row( + children: [ + _StepButton( + icon: Icons.remove_rounded, + semanticLabel: 'Less ${spec.label}', + onTap: v != null && v.value <= 0 ? null : () => _bump(-spec.step), + ), + SizedBox( + width: 96, + child: Text( + v == null ? '—' : spec.formatWithUnit(v.value), + textAlign: TextAlign.center, + style: AppText.body.copyWith( + color: v == null ? AppColors.inkMuted : AppColors.ink, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + _StepButton( + icon: Icons.add_rounded, + semanticLabel: 'More ${spec.label}', + onTap: v != null && v.value >= spec.max + ? null + : () => _bump(spec.step), + ), + if (onPickTime != null) ...[ + const SizedBox(width: Sp.x3), + Pressable( + pressedScale: 0.94, + onTap: onPickTime, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x3, + vertical: Sp.x2, + ), + decoration: BoxDecoration( + color: Elevation.surfaceAt(1), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: AppColors.divider), + ), + child: Text( + v?.atMinuteOfDay == null + // Prompting for the LAST one is the point: a 200 mg + // morning coffee and a 200 mg evening one are the same + // dose and a completely different night. + ? 'Last at…' + : formatMinuteOfDay(v!.atMinuteOfDay!), + style: AppText.label.copyWith( + color: v?.atMinuteOfDay == null + ? AppColors.inkMuted + : AppColors.accent, + ), + ), + ), + ), + ], + ], + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({ + required this.icon, + required this.semanticLabel, + required this.onTap, + }); + + final IconData icon; + final String semanticLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: semanticLabel, + child: Pressable( + pressedScale: enabled ? 0.9 : 1.0, + onTap: onTap, + child: Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Elevation.surfaceAt(1), + border: Border.all(color: AppColors.divider), + ), + child: Icon( + icon, + size: 18, + color: enabled ? AppColors.ink : AppColors.inkMuted, + ), + ), + ), + ); + } +} diff --git a/lib/ui/journal/journal_screen.dart b/lib/ui/journal/journal_screen.dart index e1677cf8..b333e36e 100644 --- a/lib/ui/journal/journal_screen.dart +++ b/lib/ui/journal/journal_screen.dart @@ -11,11 +11,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../ai/journal_ai.dart' show kJournalPresetTags; +import '../../data/journal_fields.dart'; import '../../data/local_repository.dart'; import '../../state/app_state.dart'; import '../../theme/theme_switcher.dart'; import '../design/design.dart'; +import 'custom_journal_field_sheet.dart'; import 'journal_compose_screen.dart'; +import 'journal_metric_editor.dart'; class JournalScreen extends StatefulWidget { const JournalScreen({super.key}); @@ -24,12 +28,18 @@ class JournalScreen extends StatefulWidget { } class _JournalScreenState extends State { - // Preset tag vocabulary shown as toggle chips. - static const _presetTags = [ - 'caffeine', 'alcohol', 'late meal', 'stress', 'poor sleep', 'travel', - 'screens late', 'meds', 'sick', 'sauna', 'cold plunge', 'social', - 'workout', 'rest day', - ]; + // Preset tag vocabulary shown as toggle chips. Shared with the compose + // screen and the AI prompt — this used to be a second private copy, which is + // how the two screens would have drifted apart the first time either list + // changed. + static const _presetTags = kJournalPresetTags; + + /// Built-in numeric fields followed by the user's own. + List _fieldSpecs = kJournalFields; + + /// The editing day's numeric values. A field absent here is unset, which is + /// a different state from zero. + Map _metrics = const {}; final _noteCtrl = TextEditingController(); final Set _selectedTags = {}; @@ -92,10 +102,13 @@ class _JournalScreenState extends State { // Insights are optional — never fail the screen for them. } + final specs = await api.getJournalFields(); + if (!mounted) return; setState(() { _rows = rows; _insights = insights; + _fieldSpecs = specs; _loading = false; }); _bindEditor(_editingDate); @@ -117,6 +130,51 @@ class _JournalScreenState extends State { ..clear() ..addAll(existing.isEmpty ? const [] : existing.first.tags); _noteCtrl.text = existing.isEmpty ? '' : existing.first.note; + // Cleared immediately rather than left showing the previous day's + // numbers while the read is in flight — a stale 3 coffees sitting in the + // editor is one Save away from becoming a reading the user never made. + _metrics = const {}; + }); + unawaited(_loadMetricsFor(date)); + } + + Future _loadMetricsFor(String date) async { + final api = _api; + if (api == null) return; + try { + final m = await api.getJournalMetrics(date); + // The user can rebind to another day while this is in flight; only the + // read for the day still on screen may land. + if (!mounted || _editingDate != date) return; + setState(() => _metrics = m); + } catch (_) { + // The tags editor still works without them. + } + } + + Future _addCustomField() async { + final api = _api; + if (api == null) return; + final spec = await showCustomJournalFieldSheet( + context, + existingKeys: _fieldSpecs.map((f) => f.key).toSet(), + ); + if (spec == null) return; + await api.postCustomJournalField(spec); + if (!mounted) return; + setState(() => _fieldSpecs = [..._fieldSpecs, spec]); + } + + Future _removeCustomField(JournalFieldSpec spec) async { + final api = _api; + if (api == null) return; + await api.deleteCustomJournalField(spec.key); + if (!mounted) return; + setState(() { + _fieldSpecs = [..._fieldSpecs]..removeWhere((f) => f.key == spec.key); + // Its recorded values are deliberately left in the database — those + // readings were real, and forgetting a label should not delete history. + _metrics = {..._metrics}..remove(spec.key); }); } @@ -130,6 +188,7 @@ class _JournalScreenState extends State { _selectedTags.toList(), _noteCtrl.text.trim(), ); + await api.postJournalMetrics(_editingDate, _metrics); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Saved ${_isToday ? 'today' : _editingDate}')), @@ -236,7 +295,7 @@ class _JournalScreenState extends State { children: [ Expanded( child: Text( - (_isToday ? "TODAY'S TAGS" : 'EDITING $_editingDate') + (_isToday ? 'TODAY' : 'EDITING $_editingDate') .toUpperCase(), style: AppText.overline.copyWith(color: AppColors.inkMuted), ), @@ -251,6 +310,11 @@ class _JournalScreenState extends State { ], ), const SizedBox(height: Sp.x3), + Text( + 'TAGS', + style: AppText.overline.copyWith(color: AppColors.inkMuted), + ), + const SizedBox(height: Sp.x2), Wrap( spacing: Sp.x2, runSpacing: Sp.x2, @@ -265,6 +329,19 @@ class _JournalScreenState extends State { ), ], ), + const SizedBox(height: Sp.x5), + Text( + 'NUMBERS', + style: AppText.overline.copyWith(color: AppColors.inkMuted), + ), + const SizedBox(height: Sp.x3), + JournalMetricEditor( + specs: _fieldSpecs, + values: _metrics, + onChanged: (m) => setState(() => _metrics = m), + onAddField: _addCustomField, + onRemoveField: _removeCustomField, + ), const SizedBox(height: Sp.x4), TextField( controller: _noteCtrl, diff --git a/test/db_migration_ladder_test.dart b/test/db_migration_ladder_test.dart index 7e53f2ab..4ab13dc8 100644 --- a/test/db_migration_ladder_test.dart +++ b/test/db_migration_ladder_test.dart @@ -21,6 +21,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; /// The pre-v3 raw_records shape: keyed by frame hex, NO rec_ts column. const _legacyRawDdl = ''' @@ -293,4 +294,54 @@ void main() { ); }, ); + + test( + 'upgrade from v27 adds the numeric journal tables without touching the ' + 'tags and note already stored for a day', + () async { + const name = 'migrate_from_v27_test.db'; + created.add(name); + await _seedOldDb( + name, + 27, + [ + ..._v5DerivedDdl, + "CREATE TABLE journal (date TEXT PRIMARY KEY, " + "tags_json TEXT NOT NULL DEFAULT '[]', " + "note TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)", + ], + seedRows: (db) async { + await db.insert('journal', { + 'date': '2026-06-01', + 'tags_json': '["caffeine","late meal"]', + 'note': 'felt rough', + 'updated_at': 1, + }); + }, + ); + + final version = await _openThroughLocalDb(name); + expect(version, LocalDb.schemaVersion); + + // The upgrade is purely additive: a day that only ever had tags keeps + // them, and simply has no numeric rows. + final rows = await LocalDb.journalRows(); + expect(rows.single['tags_json'], '["caffeine","late meal"]'); + expect(rows.single['note'], 'felt rough'); + expect(await LocalDb.journalMetricsForDay('2026-06-01'), isEmpty); + + // And the new tables are usable immediately, not on the next launch. + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(4), + }); + expect( + (await LocalDb.journalMetricsForDay('2026-06-01'))['mood']!.value, + 4, + ); + expect(await LocalDb.journalFieldDefs(), isEmpty); + + final health = await LocalDb.schemaHealth(); + expect(health['ok'], isTrue, reason: '$health'); + }, + ); } diff --git a/test/journal_fields_test.dart b/test/journal_fields_test.dart new file mode 100644 index 00000000..58d84b64 --- /dev/null +++ b/test/journal_fields_test.dart @@ -0,0 +1,163 @@ +// The numeric journal vocabulary. +// +// The contract everything here defends: ABSENT AND ZERO ARE DIFFERENT. "No +// caffeine today" is a measurement; "I didn't fill this in" is not. A +// correlation that reads the second as the first invents a data point at the +// bottom of the dose range, which is where it does the most damage. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; + +void main() { + group('the built-in table', () { + test('every key is unique, lowercase, and stable-looking', () { + final keys = kJournalFields.map((f) => f.key).toList(); + expect(keys.toSet().length, keys.length, reason: 'duplicate field key'); + for (final k in keys) { + expect(k, k.toLowerCase()); + expect(k, isNot(startsWith('custom_')), + reason: 'the custom_ prefix is reserved so a user field can never ' + 'collide with a built-in one added later'); + } + }); + + test('the by-key index matches the table', () { + expect(kJournalFieldsByKey.length, kJournalFields.length); + for (final f in kJournalFields) { + expect(kJournalFieldsByKey[f.key], same(f)); + } + }); + + test('every field can actually be filled in', () { + // A step above the ceiling means the field can only ever hold 0. + for (final f in kJournalFields) { + expect(f.step, greaterThan(0), reason: '${f.key} cannot be stepped'); + expect(f.max, greaterThanOrEqualTo(f.step), reason: '${f.key} ceiling'); + } + }); + + test('ratings carry no unit and no time', () { + for (final f in kJournalFields.where((f) => f.isRating)) { + expect(f.unit, isEmpty); + expect(f.hasTime, isFalse); + expect(f.max, 5, reason: 'a ten-point self-report is not ten ' + 'distinguishable states'); + } + }); + + test('caffeine asks when the last one was', () { + // The field the timing support exists for: a 200 mg morning coffee and a + // 200 mg evening one are the same dose and a completely different night. + expect(kJournalFieldsByKey['caffeine_mg']!.hasTime, isTrue); + }); + }); + + group('journalFieldSpec', () { + const custom = JournalFieldSpec( + key: 'custom_magnesium', + label: 'Magnesium', + kind: JournalFieldKind.dose, + unit: 'mg', + max: 1000, + step: 50, + custom: true, + ); + + test('finds built-ins and customs', () { + expect(journalFieldSpec('mood')?.label, 'Mood'); + expect( + journalFieldSpec('custom_magnesium', custom: const [custom])?.label, + 'Magnesium', + ); + }); + + test('a built-in wins over a custom of the same key', () { + // Only reachable if a future release adopts a name someone had already + // invented — the shipped definition has to be the one that applies, or + // the same key means two different things across two installs. + const shadow = JournalFieldSpec( + key: 'mood', + label: 'Mood but in tens', + kind: JournalFieldKind.rating, + unit: '', + max: 10, + step: 1, + custom: true, + ); + expect(journalFieldSpec('mood', custom: const [shadow])?.max, 5); + }); + + test('returns null for a field with no definition left', () { + // A custom field whose definition was deleted while its readings + // remained. Callers must render those raw rather than invent a unit. + expect(journalFieldSpec('custom_gone'), isNull); + }); + }); + + group('customJournalFieldKey', () { + test('prefixes and slugs', () { + expect(customJournalFieldKey('Magnesium'), 'custom_magnesium'); + expect(customJournalFieldKey('Screen time'), 'custom_screen_time'); + expect(customJournalFieldKey(' Vitamin D3 '), 'custom_vitamin_d3'); + }); + + test('the prefix is what stops a collision with a future built-in', () { + // If `magnesium` ever ships as a built-in, it must not silently adopt + // somebody's existing column and reinterpret its units. + expect( + customJournalFieldKey('Magnesium'), + isNot(anyOf(kJournalFields.map((f) => f.key))), + ); + }); + + test('a name with nothing sluggable collapses, which callers must reject', + () { + // Every such name produces the same bare key, so the sheet refuses it + // rather than letting two fields share storage. + expect(customJournalFieldKey('???'), 'custom_'); + expect(customJournalFieldKey(''), 'custom_'); + }); + }); + + group('JournalMetricValue', () { + test('zero is a value, and is not equal to absence', () { + const zero = JournalMetricValue(0); + expect(zero.value, 0); + expect(zero, isNot(equals(null))); + }); + + test('equality includes the time', () { + expect( + const JournalMetricValue(200, atMinuteOfDay: 480), + const JournalMetricValue(200, atMinuteOfDay: 480), + ); + expect( + const JournalMetricValue(200, atMinuteOfDay: 480), + isNot(const JournalMetricValue(200, atMinuteOfDay: 1200)), + ); + }); + }); + + group('formatting', () { + test('a rating prints as a whole number', () { + expect(kJournalFieldsByKey['mood']!.format(4), '4'); + }); + + test('a dose drops a trailing zero but keeps a real fraction', () { + final water = kJournalFieldsByKey['water_ml']!; + expect(water.formatWithUnit(1500), '1500 ml'); + expect(water.format(2.5), '2.5'); + }); + + test('minute of day reads as a clock time', () { + expect(formatMinuteOfDay(0), '12:00 AM'); + expect(formatMinuteOfDay(7 * 60 + 5), '7:05 AM'); + expect(formatMinuteOfDay(12 * 60), '12:00 PM'); + expect(formatMinuteOfDay(20 * 60 + 30), '8:30 PM'); + }); + + test('minute of day wraps rather than printing an impossible hour', () { + expect(formatMinuteOfDay(24 * 60), '12:00 AM'); + }); + }); +} diff --git a/test/journal_metric_store_test.dart b/test/journal_metric_store_test.dart new file mode 100644 index 00000000..0e5aa70a --- /dev/null +++ b/test/journal_metric_store_test.dart @@ -0,0 +1,195 @@ +// journal_metric round-trip: the numeric half of a journal entry. +// +// The behaviour worth pinning is the destructive one. `putJournalMetrics` +// treats the map it is given as THE day, not a patch on it, so a field the +// user cleared is deleted rather than left behind. A stale "3 coffees" +// surviving an edit becomes a reading nobody made, and it lands in a +// correlation as if it were real. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_journal_metric_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async => LocalDb.close()); + + setUp(() async { + final db = await LocalDb.instance; + await db.delete('journal_metric'); + await db.delete('journal_field_def'); + }); + + test('a day round-trips, time included', () async { + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(4), + 'caffeine_mg': const JournalMetricValue(200, atMinuteOfDay: 855), + }); + + final back = await LocalDb.journalMetricsForDay('2026-06-01'); + expect(back['mood'], const JournalMetricValue(4)); + expect(back['caffeine_mg']!.value, 200); + expect(back['caffeine_mg']!.atMinuteOfDay, 855); + }); + + test('a zero is stored as a real reading', () async { + // "No caffeine today" is an answer. It has to survive a round trip as 0 + // and not come back as absent. + await LocalDb.putJournalMetrics('2026-06-01', { + 'caffeine_mg': const JournalMetricValue(0), + }); + final back = await LocalDb.journalMetricsForDay('2026-06-01'); + expect(back.containsKey('caffeine_mg'), isTrue); + expect(back['caffeine_mg']!.value, 0); + }); + + test('a field left out of the map is cleared, not merged', () async { + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(4), + 'water_ml': const JournalMetricValue(2000), + }); + // The user cleared water and re-saved. + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(4), + }); + + final back = await LocalDb.journalMetricsForDay('2026-06-01'); + expect(back.keys, ['mood']); + expect( + back.containsKey('water_ml'), + isFalse, + reason: 'a cleared field must not survive as a reading nobody made', + ); + }); + + test('saving one day never touches another', () async { + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(2), + }); + await LocalDb.putJournalMetrics('2026-06-02', { + 'mood': const JournalMetricValue(5), + }); + + expect((await LocalDb.journalMetricsForDay('2026-06-01'))['mood']!.value, 2); + expect((await LocalDb.journalMetricsForDay('2026-06-02'))['mood']!.value, 5); + }); + + test('an empty map clears the day', () async { + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(3), + }); + await LocalDb.putJournalMetrics('2026-06-01', const {}); + expect(await LocalDb.journalMetricsForDay('2026-06-01'), isEmpty); + }); + + test('a day never recorded reads as empty, not as an error', () async { + expect(await LocalDb.journalMetricsForDay('1999-01-01'), isEmpty); + }); + + test('the correlation read groups by day, oldest first', () async { + await LocalDb.putJournalMetrics('2026-06-03', { + 'mood': const JournalMetricValue(1), + }); + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(3), + 'water_ml': const JournalMetricValue(1500), + }); + await LocalDb.putJournalMetrics('2026-06-02', { + 'mood': const JournalMetricValue(2), + }); + + final all = await LocalDb.journalMetricsByDay(); + expect(all.keys.toList(), ['2026-06-01', '2026-06-02', '2026-06-03']); + expect(all['2026-06-01']!.keys.toSet(), {'mood', 'water_ml'}); + + final since = await LocalDb.journalMetricsByDay( + sinceDaysEpoch: '2026-06-02', + ); + expect(since.keys.toList(), ['2026-06-02', '2026-06-03']); + }); + + test('field names are remembered across days', () async { + // So a user-invented field keeps appearing in the editor after the day it + // was invented on. + await LocalDb.putJournalMetrics('2026-06-01', { + 'custom_magnesium': const JournalMetricValue(400), + }); + await LocalDb.putJournalMetrics('2026-06-02', { + 'mood': const JournalMetricValue(3), + }); + expect(await LocalDb.journalMetricFields(), [ + 'custom_magnesium', + 'mood', + ]); + }); + + group('custom field definitions', () { + const spec = JournalFieldSpec( + key: 'custom_magnesium', + label: 'Magnesium', + kind: JournalFieldKind.dose, + unit: 'mg', + max: 1000, + step: 50, + hasTime: true, + custom: true, + ); + + test('round-trip preserves every part of the definition', () async { + await LocalDb.putJournalFieldDef(spec); + final back = (await LocalDb.journalFieldDefs()).single; + expect(back.key, spec.key); + expect(back.label, spec.label); + expect(back.kind, JournalFieldKind.dose); + expect(back.unit, 'mg'); + expect(back.max, 1000); + expect(back.step, 50); + expect(back.hasTime, isTrue); + expect(back.custom, isTrue, reason: 'a stored def is always a custom'); + }); + + test('deleting a definition keeps its readings', () async { + await LocalDb.putJournalFieldDef(spec); + await LocalDb.putJournalMetrics('2026-06-01', { + 'custom_magnesium': const JournalMetricValue(400), + }); + + await LocalDb.deleteJournalFieldDef(spec.key); + + expect(await LocalDb.journalFieldDefs(), isEmpty); + expect( + (await LocalDb.journalMetricsForDay('2026-06-01'))['custom_magnesium'] + ?.value, + 400, + reason: 'forgetting a label must not delete history', + ); + }); + + test('an unknown kind from a newer build degrades instead of throwing', + () async { + final db = await LocalDb.instance; + await db.insert('journal_field_def', { + 'key': 'custom_future', + 'label': 'From the future', + 'kind': 'something_new', + 'unit': 'x', + 'max_value': 10.0, + 'step': 1.0, + 'has_time': 0, + 'created_at': 0, + }); + // One unreadable row must not take the whole journal screen down. + final back = await LocalDb.journalFieldDefs(); + expect(back.single.kind, JournalFieldKind.dose); + }); + }); +} From 1e3c3afa17f428336aaf895e7c82674cc9fac2f1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:50:26 +0530 Subject: [PATCH 2/2] an exported day should say what its custom numbers mean --- lib/data/db.dart | 5 +++ test/journal_metric_store_test.dart | 61 ++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 34eb69d1..a4a85ca6 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3483,6 +3483,11 @@ class LocalDb { whereArgs: [dayId], ); } + // Custom journal field definitions are not day-scoped, so they ride along + // whole. Without them an exported day carries numbers under keys like + // `custom_magnesium` with no label, no unit and no idea what scale they + // are on — the values survive the export and their meaning does not. + await copyRows('journal_field_def'); await out.close(); return dest; } diff --git a/test/journal_metric_store_test.dart b/test/journal_metric_store_test.dart index 0e5aa70a..ea97a01e 100644 --- a/test/journal_metric_store_test.dart +++ b/test/journal_metric_store_test.dart @@ -6,22 +6,50 @@ // surviving an edit becomes a reading nobody made, and it lands in a // correlation as if it were real. +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/data/journal_fields.dart'; import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +/// `exportDaysDb` writes into the temp directory, which is a platform channel. +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + void main() { + late Directory tmp; + setUpAll(() async { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_journal_metric_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); LocalDb.dbName = 'openstrap_journal_metric_test.db'; final dir = await databaseFactory.getDatabasesPath(); await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); }); - tearDownAll(() async => LocalDb.close()); + tearDownAll(() async { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); setUp(() async { final db = await LocalDb.instance; @@ -132,6 +160,37 @@ void main() { ]); }); + test('a day export carries the custom definitions, not just the numbers', + () async { + // The definitions are not day-scoped, so without an explicit copy an + // exported day holds values under `custom_magnesium` with no label, no + // unit and no scale — the numbers survive and their meaning does not. + await LocalDb.putJournalFieldDef(const JournalFieldSpec( + key: 'custom_magnesium', + label: 'Magnesium', + kind: JournalFieldKind.dose, + unit: 'mg', + max: 1000, + step: 50, + custom: true, + )); + await LocalDb.putJournalMetrics('2026-06-01', { + 'custom_magnesium': const JournalMetricValue(400), + }); + + final path = await LocalDb.exportDaysDb({'2026-06-01'}); + final exported = await databaseFactory.openDatabase(path); + try { + final metrics = await exported.query('journal_metric'); + expect(metrics.single['field'], 'custom_magnesium'); + final defs = await exported.query('journal_field_def'); + expect(defs.single['label'], 'Magnesium'); + expect(defs.single['unit'], 'mg'); + } finally { + await exported.close(); + } + }); + group('custom field definitions', () { const spec = JournalFieldSpec( key: 'custom_magnesium',