diff --git a/lib/data/db.dart b/lib/data/db.dart index 2f880d26..a4a85ca6 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( @@ -3407,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; } @@ -3491,6 +3572,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 +3611,8 @@ class LocalDb { 'metric_series', 'sessions', 'journal', + 'journal_metric', + 'journal_field_def', 'cycle_log', 'notifications', 'baselines', @@ -3835,6 +3919,8 @@ class LocalDb { 'baselines', 'sessions', 'journal', + 'journal_metric', + 'journal_field_def', 'cycle_log', 'notifications', 'sync_cursor', @@ -4507,6 +4593,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..ea97a01e --- /dev/null +++ b/test/journal_metric_store_test.dart @@ -0,0 +1,254 @@ +// 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 '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 { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + 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', + ]); + }); + + 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', + 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); + }); + }); +}