feat(programmer): day activity data + run-state provider
Implements DayActivityData, fetchDayActivityData, computeRunStates, clockTickerProvider, todayKeyProvider, programmerActivitySignalProvider, programmerDayDataProvider, and programmerRunStatesProvider in lib/providers/programmer_day_data_provider.dart. Includes four passing tests in test/programmer_day_data_test.dart covering run-state computation and day-scoped activity fetching. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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<String, ProgrammerRunInfo> computeRunStates(
|
||||||
|
List<ProgrammerTask> tasks,
|
||||||
|
Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
|
||||||
|
) {
|
||||||
|
return {for (final t in tasks) t.id: _runInfoFor(t, logsByTask[t.id] ?? [])};
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgrammerRunInfo _runInfoFor(
|
||||||
|
ProgrammerTask task,
|
||||||
|
List<ProgrammerTaskActivityLog> 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<ProgrammerTaskActivityLog> 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<ProgrammerTask> tasks;
|
||||||
|
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
|
||||||
|
final List<ProgrammerTaskWorkLog> 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<List<ProgrammerTaskWorkLog>> _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<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<ProgrammerTaskActivityLog>>> _fetchActivityLogs(
|
||||||
|
dynamic client,
|
||||||
|
List<String> 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 = <String, List<ProgrammerTaskActivityLog>>{};
|
||||||
|
for (final r in raw) {
|
||||||
|
final log = ProgrammerTaskActivityLog.fromMap(r as Map<String, dynamic>);
|
||||||
|
map.putIfAbsent(log.taskId, () => []).add(log);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DayActivityData> fetchDayActivityData(
|
||||||
|
dynamic client, {
|
||||||
|
required DateTime day,
|
||||||
|
required List<ProgrammerTask> 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<DateTime>((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<DateTime>((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<int>((ref) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
final controller = StreamController<int>();
|
||||||
|
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<DayActivityData, DateTime>((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<Map<String, ProgrammerRunInfo>>((ref) {
|
||||||
|
final today = ref.watch(todayKeyProvider);
|
||||||
|
final data = ref.watch(programmerDayDataProvider(today)).valueOrNull;
|
||||||
|
if (data == null) return {};
|
||||||
|
return computeRunStates(data.tasks, data.logsByTask);
|
||||||
|
});
|
||||||
@@ -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<String, List<Map<String, dynamic>>> tables;
|
||||||
|
|
||||||
|
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||||||
|
_FakeQuery(this._client, this._table);
|
||||||
|
|
||||||
|
final _FakeClient _client;
|
||||||
|
final String _table;
|
||||||
|
|
||||||
|
final List<MapEntry<String, dynamic>> _eqs = [];
|
||||||
|
String? _inField;
|
||||||
|
List<String>? _inValues;
|
||||||
|
String? _orderField;
|
||||||
|
bool _orderAsc = true;
|
||||||
|
String? _gteField;
|
||||||
|
dynamic _gteValue;
|
||||||
|
String? _ltField;
|
||||||
|
dynamic _ltValue;
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> get _rows {
|
||||||
|
var rows = List<Map<String, dynamic>>.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<List<Map<String, dynamic>>> 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<dynamic> 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<List<Map<String, dynamic>>> asStream() => _future.asStream();
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> catchError(Function onError,
|
||||||
|
{bool Function(Object error)? test}) =>
|
||||||
|
_future.catchError(onError, test: test);
|
||||||
|
@override
|
||||||
|
Future<R> then<R>(
|
||||||
|
FutureOr<R> Function(List<Map<String, dynamic>>) onValue,
|
||||||
|
{Function? onError}) =>
|
||||||
|
_future.then(onValue, onError: onError);
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> timeout(Duration timeLimit,
|
||||||
|
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
|
||||||
|
_future.timeout(timeLimit, onTimeout: onTimeout);
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>> whenComplete(
|
||||||
|
FutureOr<void> 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user