diff --git a/lib/providers/programmer_day_data_provider.dart b/lib/providers/programmer_day_data_provider.dart new file mode 100644 index 00000000..51bae744 --- /dev/null +++ b/lib/providers/programmer_day_data_provider.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/programmer_task.model.dart'; +import '../models/programmer_task_activity_log.model.dart'; +import '../models/programmer_task_work_log.model.dart'; +import '../utils/app_time.dart'; +import '../utils/programmer_daily_time.dart'; +import 'programmer_tasks_provider.dart'; +import 'supabase_provider.dart'; + +enum ProgrammerRunState { running, paused, notStarted, completed, cancelled } + +typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since}); + +/// Pure function — no providers. +Map computeRunStates( + List tasks, + Map> logsByTask, +) { + return {for (final t in tasks) t.id: _runInfoFor(t, logsByTask[t.id] ?? [])}; +} + +ProgrammerRunInfo _runInfoFor( + ProgrammerTask task, + List logs, +) { + switch (task.status) { + case ProgrammerTaskStatus.queued: + return (state: ProgrammerRunState.notStarted, since: null); + case ProgrammerTaskStatus.completed: + return (state: ProgrammerRunState.completed, since: null); + case ProgrammerTaskStatus.cancelled: + return (state: ProgrammerRunState.cancelled, since: null); + default: + return _inProgressInfo(task, logs); + } +} + +ProgrammerRunInfo _inProgressInfo( + ProgrammerTask task, + List logs, +) { + const types = ['started', 'paused', 'resumed']; + final relevant = logs.where((l) => types.contains(l.actionType)).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + if (relevant.isEmpty) { + return (state: ProgrammerRunState.running, since: task.startedAt); + } + final latest = relevant.first; + if (latest.actionType == 'paused') { + return (state: ProgrammerRunState.paused, since: latest.createdAt); + } + return (state: ProgrammerRunState.running, since: latest.createdAt); +} + +class DayActivityData { + const DayActivityData({ + required this.day, + required this.tasks, + required this.logsByTask, + required this.workLogs, + }); + + final DateTime day; + final List tasks; + final Map> logsByTask; + final List workLogs; +} + +bool _isTaskActiveOnDay( + ProgrammerTask t, + DateTime dayStart, + DateTime dayEnd, + DateTime now, +) { + if (t.status == ProgrammerTaskStatus.inProgress) return true; + if (t.startedAt == null) return false; + final terminal = t.completedAt ?? t.cancelledAt ?? now; + return t.startedAt!.isBefore(dayEnd) && !terminal.isBefore(dayStart); +} + +Future> _fetchWorkLogs( + dynamic client, + DateTime day, +) async { + final raw = await client + .from('programmer_task_work_logs') + .select() + .eq('work_date', formatDayKey(day)) as List; + return raw + .map((r) => ProgrammerTaskWorkLog.fromMap(r as Map)) + .toList(); +} + +Future>> _fetchActivityLogs( + dynamic client, + List ids, +) async { + final raw = await client + .from('programmer_task_activity_logs') + .select() + .inFilter('task_id', ids) + .order('created_at', ascending: false) as List; + final map = >{}; + for (final r in raw) { + final log = ProgrammerTaskActivityLog.fromMap(r as Map); + map.putIfAbsent(log.taskId, () => []).add(log); + } + return map; +} + +Future fetchDayActivityData( + dynamic client, { + required DateTime day, + required List tasks, + required DateTime now, +}) async { + final dayStart = manilaDayStart(day); + final dayEnd = dayStart.add(const Duration(days: 1)); + final candidates = + tasks.where((t) => _isTaskActiveOnDay(t, dayStart, dayEnd, now)).toList(); + + final workLogs = await _fetchWorkLogs(client, day); + + final cIds = {for (final t in candidates) t.id}; + candidates.addAll( + tasks.where((t) => workLogs.any((l) => l.taskId == t.id) && !cIds.contains(t.id)), + ); + + if (candidates.isEmpty) { + return DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: workLogs); + } + + final logsByTask = + await _fetchActivityLogs(client, candidates.map((t) => t.id).toList()); + return DayActivityData( + day: day, + tasks: candidates, + logsByTask: logsByTask, + workLogs: workLogs, + ); +} + +/// Emits [AppTime.now()] immediately then every 30 seconds. +final clockTickerProvider = StreamProvider((ref) async* { + yield AppTime.now(); + yield* Stream.periodic(const Duration(seconds: 30), (_) => AppTime.now()); +}); + +/// Manila calendar day derived from the clock ticker. +final todayKeyProvider = Provider((ref) { + final tick = ref.watch(clockTickerProvider).valueOrNull; + return manilaDay(tick ?? AppTime.now()); +}); + +/// Increments by 1 for each INSERT on either log table. +final programmerActivitySignalProvider = StreamProvider((ref) { + final client = ref.watch(supabaseClientProvider); + final controller = StreamController(); + var count = 0; + + void onInsert(PostgresChangePayload _) { + count++; + if (!controller.isClosed) controller.add(count); + } + + final channel = client + .channel('programmer_activity_signal') + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'programmer_task_work_logs', + callback: onInsert, + ) + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'programmer_task_activity_logs', + callback: onInsert, + ); + channel.subscribe(); + + ref.onDispose(() { + client.removeChannel(channel); + controller.close(); + }); + + return controller.stream; +}); + +/// Fetches activity data for a given day key. Invalidated by the activity signal. +final programmerDayDataProvider = + FutureProvider.family((ref, day) async { + ref.watch(programmerActivitySignalProvider); + final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? []; + final client = ref.watch(supabaseClientProvider); + return fetchDayActivityData(client, day: day, tasks: tasks, now: AppTime.now()); +}); + +/// Run states for today's tasks, derived from today's [programmerDayDataProvider]. +final programmerRunStatesProvider = Provider>((ref) { + final today = ref.watch(todayKeyProvider); + final data = ref.watch(programmerDayDataProvider(today)).valueOrNull; + if (data == null) return {}; + return computeRunStates(data.tasks, data.logsByTask); +}); diff --git a/test/programmer_day_data_test.dart b/test/programmer_day_data_test.dart new file mode 100644 index 00000000..8c18be12 --- /dev/null +++ b/test/programmer_day_data_test.dart @@ -0,0 +1,330 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/models/programmer_task_activity_log.model.dart'; +import 'package:tasq/providers/programmer_day_data_provider.dart'; +import 'package:tasq/utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Minimal fake Supabase client — only the surface fetchDayActivityData uses. +// --------------------------------------------------------------------------- + +class _FakeClient { + _FakeClient({required this.tables}); + final Map>> tables; + + _FakeQuery from(String table) => _FakeQuery(this, table); +} + +class _FakeQuery implements Future>> { + _FakeQuery(this._client, this._table); + + final _FakeClient _client; + final String _table; + + final List> _eqs = []; + String? _inField; + List? _inValues; + String? _orderField; + bool _orderAsc = true; + String? _gteField; + dynamic _gteValue; + String? _ltField; + dynamic _ltValue; + + List> get _rows { + var rows = List>.from(_client.tables[_table] ?? []); + for (final eq in _eqs) { + rows = rows.where((r) => r[eq.key] == eq.value).toList(); + } + if (_inField != null && _inValues != null) { + rows = rows + .where((r) => _inValues!.contains(r[_inField]?.toString())) + .toList(); + } + if (_gteField != null) { + rows = rows.where((r) { + final v = r[_gteField]; + return v != null && v.toString().compareTo(_gteValue.toString()) >= 0; + }).toList(); + } + if (_ltField != null) { + rows = rows.where((r) { + final v = r[_ltField]; + return v != null && v.toString().compareTo(_ltValue.toString()) < 0; + }).toList(); + } + if (_orderField != null) { + rows.sort((a, b) { + final av = '${a[_orderField]}'; + final bv = '${b[_orderField]}'; + return _orderAsc ? av.compareTo(bv) : bv.compareTo(av); + }); + } + return rows; + } + + Future>> get _future async => _rows; + + _FakeQuery select([String? _]) => this; + + _FakeQuery eq(String field, dynamic value) { + _eqs.add(MapEntry(field, value)); + return this; + } + + _FakeQuery inFilter(String field, List values) { + _inField = field; + _inValues = values.map((v) => v.toString()).toList(); + return this; + } + + _FakeQuery gte(String field, dynamic value) { + _gteField = field; + _gteValue = value; + return this; + } + + _FakeQuery lt(String field, dynamic value) { + _ltField = field; + _ltValue = value; + return this; + } + + _FakeQuery order(String field, {bool ascending = true}) { + _orderField = field; + _orderAsc = ascending; + return this; + } + + @override + Stream>> asStream() => _future.asStream(); + @override + Future>> catchError(Function onError, + {bool Function(Object error)? test}) => + _future.catchError(onError, test: test); + @override + Future then( + FutureOr Function(List>) onValue, + {Function? onError}) => + _future.then(onValue, onError: onError); + @override + Future>> timeout(Duration timeLimit, + {FutureOr>> Function()? onTimeout}) => + _future.timeout(timeLimit, onTimeout: onTimeout); + @override + Future>> whenComplete( + FutureOr Function() action) => + _future.whenComplete(action); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ProgrammerTask _task({ + required String id, + required String status, + DateTime? startedAt, + DateTime? completedAt, + DateTime? cancelledAt, +}) { + return ProgrammerTask( + id: id, + title: 'Task $id', + category: ProgrammerTaskCategory.others, + status: status, + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + startedAt: startedAt, + completedAt: completedAt, + cancelledAt: cancelledAt, + ); +} + +ProgrammerTaskActivityLog _log({ + required String taskId, + required String actionType, + required DateTime createdAt, +}) { + return ProgrammerTaskActivityLog( + id: '${taskId}_${actionType}_${createdAt.millisecondsSinceEpoch}', + taskId: taskId, + actionType: actionType, + createdAt: createdAt, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + setUpAll(AppTime.initialize); + + group('computeRunStates', () { + test( + 'queued->notStarted; latest paused->paused since pause time; ' + 'resumed->running; completed', () { + final startedAt = DateTime.utc(2026, 9, 27, 8, 0); + final pauseAt = DateTime.utc(2026, 9, 27, 10, 0); + final resumeAt = DateTime.utc(2026, 9, 27, 11, 0); + + final tasks = [ + _task(id: 'q', status: ProgrammerTaskStatus.queued), + _task( + id: 'p', + status: ProgrammerTaskStatus.inProgress, + startedAt: startedAt, + ), + _task( + id: 'r', + status: ProgrammerTaskStatus.inProgress, + startedAt: DateTime.utc(2026, 9, 27, 9, 0), + ), + _task( + id: 'c', + status: ProgrammerTaskStatus.completed, + startedAt: startedAt, + completedAt: DateTime.utc(2026, 9, 27, 12, 0), + ), + ]; + + final logsByTask = { + 'p': [ + _log(taskId: 'p', actionType: 'started', createdAt: startedAt), + _log(taskId: 'p', actionType: 'paused', createdAt: pauseAt), + ], + 'r': [ + _log(taskId: 'r', actionType: 'started', createdAt: DateTime.utc(2026, 9, 27, 9, 0)), + _log(taskId: 'r', actionType: 'paused', createdAt: DateTime.utc(2026, 9, 27, 10, 30)), + _log(taskId: 'r', actionType: 'resumed', createdAt: resumeAt), + ], + }; + + final states = computeRunStates(tasks, logsByTask); + + // queued -> notStarted + expect(states['q']!.state, ProgrammerRunState.notStarted); + expect(states['q']!.since, isNull); + + // latest action is paused -> paused since pause time + expect(states['p']!.state, ProgrammerRunState.paused); + expect(states['p']!.since, pauseAt); + + // latest action is resumed -> running since resume time + expect(states['r']!.state, ProgrammerRunState.running); + expect(states['r']!.since, resumeAt); + + // completed -> completed + expect(states['c']!.state, ProgrammerRunState.completed); + expect(states['c']!.since, isNull); + }); + }); + + group('fetchDayActivityData', () { + // Target day: 2026-09-27 Manila. + // dayStart (Manila midnight) = 2026-09-26T16:00:00Z UTC. + // dayEnd = 2026-09-27T16:00:00Z UTC. + final day = DateTime.utc(2026, 9, 27); + final now = DateTime.utc(2026, 9, 27, 12, 0); + + test('includes in-progress tasks and tasks completed that day', () async { + final tActive = _task( + id: 'active', + status: ProgrammerTaskStatus.inProgress, + startedAt: DateTime.utc(2026, 9, 27, 0, 0), + ); + // Completed at 11:00 UTC on the 27th — still within the Manila day + final tDone = _task( + id: 'done', + status: ProgrammerTaskStatus.completed, + startedAt: DateTime.utc(2026, 9, 27, 0, 0), + completedAt: DateTime.utc(2026, 9, 27, 3, 0), + ); + + final client = _FakeClient(tables: { + 'programmer_task_work_logs': [], + 'programmer_task_activity_logs': [ + { + 'id': 'log-1', + 'task_id': 'active', + 'action_type': 'started', + 'created_at': '2026-09-27T00:00:00Z', + }, + ], + }); + + final result = await fetchDayActivityData( + client, + day: day, + tasks: [tActive, tDone], + now: now, + ); + + expect(result.tasks.map((t) => t.id), containsAll(['active', 'done'])); + }); + + test('excludes a task completed two days earlier', () async { + // Completed well before the Manila day boundary (2026-09-26T16:00Z). + final tOld = _task( + id: 'old', + status: ProgrammerTaskStatus.completed, + startedAt: DateTime.utc(2026, 9, 24, 8, 0), + completedAt: DateTime.utc(2026, 9, 25, 10, 0), + ); + + final client = _FakeClient(tables: { + 'programmer_task_work_logs': [], + 'programmer_task_activity_logs': [], + }); + + final result = await fetchDayActivityData( + client, + day: day, + tasks: [tOld], + now: now, + ); + + expect(result.tasks, isEmpty); + }); + + test('includes a task referenced only by a helper work log that day', + () async { + // Task was completed long before; not active on 2026-09-27 by dates alone. + final tHelper = _task( + id: 'helper-task', + status: ProgrammerTaskStatus.completed, + startedAt: DateTime.utc(2026, 9, 1, 8, 0), + completedAt: DateTime.utc(2026, 9, 1, 17, 0), + ); + + final client = _FakeClient(tables: { + 'programmer_task_work_logs': [ + { + 'id': 'wl-1', + 'task_id': 'helper-task', + 'author_id': 'u2', + 'description': 'helped out', + 'minutes': 30, + 'created_at': '2026-09-27T01:00:00Z', + 'work_date': '2026-09-27', + }, + ], + 'programmer_task_activity_logs': [], + }); + + final result = await fetchDayActivityData( + client, + day: day, + tasks: [tHelper], + now: now, + ); + + expect(result.tasks.map((t) => t.id), contains('helper-task')); + expect(result.workLogs, hasLength(1)); + expect(result.workLogs.first.taskId, 'helper-task'); + }); + }); +}