Add Programmer Tasks module (work-log + time tracking)
Separate feature for the `programmer` role to record dev work by category, distinct from the IT help-desk `tasks` feature. Modeled on the IT Service Requests pattern (Brick offline-first model -> Riverpod stream/controller -> list/detail screens); seed of a future Project Management workflow. - programmer_tasks + programmer_task_activity_logs (category enum, RLS, realtime) via supabase migration - single assignee, self-logged or lead-assigned; assign/reassign logged - pause-on-switch: starting a task while another runs prompts to pause the running one, keeping one active task per user for accurate time attribution - worked-duration math shared with help-desk tasks via a generic core in task_duration.dart (existing callers unchanged) - list distinguishes running vs paused with a per-task Resume shortcut - accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer - unit/widget tests: controller lifecycle, duration math, pause-switch dialog Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/utils/task_duration.dart';
|
||||
|
||||
DurationEvent _ev(DateTime at, String action) =>
|
||||
(createdAt: at, actionType: action);
|
||||
|
||||
void main() {
|
||||
final base = DateTime.utc(2026, 1, 1, 9, 0);
|
||||
|
||||
group('computeEffectiveDuration', () {
|
||||
test('no pause events → full span from fallbackStartedAt to endAt', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base,
|
||||
endAt: base.add(const Duration(hours: 2)),
|
||||
events: const [],
|
||||
);
|
||||
expect(worked, const Duration(hours: 2));
|
||||
});
|
||||
|
||||
test('resolves start from the "started" event over the fallback', () {
|
||||
final started = base.add(const Duration(minutes: 30));
|
||||
// events are newest-first (DESC)
|
||||
final events = [_ev(started, 'started')];
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base, // earlier fallback should be ignored
|
||||
endAt: started.add(const Duration(hours: 1)),
|
||||
events: events,
|
||||
);
|
||||
expect(worked, const Duration(hours: 1));
|
||||
});
|
||||
|
||||
test('subtracts a completed paused interval', () {
|
||||
final started = base;
|
||||
final paused = base.add(const Duration(minutes: 20));
|
||||
final resumed = base.add(const Duration(minutes: 50)); // 30m paused
|
||||
final end = base.add(const Duration(hours: 2));
|
||||
// DESC order
|
||||
final events = [
|
||||
_ev(resumed, 'resumed'),
|
||||
_ev(paused, 'paused'),
|
||||
_ev(started, 'started'),
|
||||
];
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: started,
|
||||
endAt: end,
|
||||
events: events,
|
||||
);
|
||||
expect(worked, const Duration(hours: 2) - const Duration(minutes: 30));
|
||||
});
|
||||
|
||||
test('counts an open pause interval still active at endAt', () {
|
||||
final started = base;
|
||||
final paused = base.add(const Duration(minutes: 40));
|
||||
final end = base.add(const Duration(hours: 1)); // 20m still paused
|
||||
final events = [
|
||||
_ev(paused, 'paused'),
|
||||
_ev(started, 'started'),
|
||||
];
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: started,
|
||||
endAt: end,
|
||||
events: events,
|
||||
);
|
||||
expect(worked, const Duration(minutes: 40));
|
||||
});
|
||||
|
||||
test('returns zero when there is no resolvable start', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: null,
|
||||
endAt: base,
|
||||
events: const [],
|
||||
);
|
||||
expect(worked, Duration.zero);
|
||||
});
|
||||
|
||||
test('returns zero when endAt is before start', () {
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: base,
|
||||
endAt: base.subtract(const Duration(minutes: 5)),
|
||||
events: const [],
|
||||
);
|
||||
expect(worked, Duration.zero);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/providers/programmer_tasks_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake Supabase client — only the surface ProgrammerTasksController uses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeUser {
|
||||
_FakeUser(this.id);
|
||||
final String id;
|
||||
}
|
||||
|
||||
class _FakeAuth {
|
||||
_FakeAuth(this._id);
|
||||
final String _id;
|
||||
_FakeUser? get currentUser => _FakeUser(_id);
|
||||
}
|
||||
|
||||
class _FakeClient {
|
||||
_FakeClient(this.userId);
|
||||
final String userId;
|
||||
int _seq = 0;
|
||||
|
||||
final Map<String, List<Map<String, dynamic>>> tables = {
|
||||
'programmer_tasks': [],
|
||||
'programmer_task_activity_logs': [],
|
||||
};
|
||||
|
||||
_FakeAuth get auth => _FakeAuth(userId);
|
||||
|
||||
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||
|
||||
String nextTimestamp() {
|
||||
_seq += 1;
|
||||
return DateTime.utc(2026, 1, 1).add(Duration(seconds: _seq)).toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
int? _limit;
|
||||
Map<String, dynamic>? _insertPayload;
|
||||
Map<String, dynamic>? _updatePayload;
|
||||
|
||||
List<Map<String, dynamic>> get _rows {
|
||||
var rows = List<Map<String, dynamic>>.from(client.tables[table] ?? []);
|
||||
if (_inField != null && _inValues != null) {
|
||||
rows = rows
|
||||
.where((r) => _inValues!.contains(r[_inField]?.toString()))
|
||||
.toList();
|
||||
}
|
||||
for (final eq in _eqs) {
|
||||
rows = rows.where((r) => r[eq.key] == eq.value).toList();
|
||||
}
|
||||
if (_orderField != null) {
|
||||
rows.sort((a, b) {
|
||||
final av = '${a[_orderField]}';
|
||||
final bv = '${b[_orderField]}';
|
||||
return _orderAsc ? av.compareTo(bv) : bv.compareTo(av);
|
||||
});
|
||||
}
|
||||
if (_limit != null && rows.length > _limit!) {
|
||||
rows = rows.sublist(0, _limit!);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> get _future async {
|
||||
if (_insertPayload != null) {
|
||||
final row = Map<String, dynamic>.from(_insertPayload!);
|
||||
row['id'] ??= '${table}_${client.tables[table]!.length + 1}';
|
||||
row['created_at'] ??= client.nextTimestamp();
|
||||
if (table == 'programmer_tasks') {
|
||||
row['updated_at'] ??= row['created_at'];
|
||||
}
|
||||
client.tables[table]!.add(row);
|
||||
return [row];
|
||||
}
|
||||
return _rows;
|
||||
}
|
||||
|
||||
_FakeQuery select([String? _]) => this;
|
||||
|
||||
_FakeQuery insert(Map<String, dynamic> payload) {
|
||||
_insertPayload = Map<String, dynamic>.from(payload);
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery update(Map<String, dynamic> payload) {
|
||||
_updatePayload = Map<String, dynamic>.from(payload);
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery inFilter(String field, List<dynamic> values) {
|
||||
_inField = field;
|
||||
_inValues = values.map((v) => v.toString()).toList();
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery order(String field, {bool ascending = true}) {
|
||||
_orderField = field;
|
||||
_orderAsc = ascending;
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery limit(int n) {
|
||||
_limit = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery eq(String field, dynamic value) {
|
||||
if (_updatePayload != null) {
|
||||
final idx =
|
||||
client.tables[table]!.indexWhere((r) => r[field] == value);
|
||||
if (idx >= 0) {
|
||||
client.tables[table]![idx] = {
|
||||
...client.tables[table]![idx],
|
||||
..._updatePayload!,
|
||||
};
|
||||
}
|
||||
_updatePayload = null;
|
||||
return this;
|
||||
}
|
||||
_eqs.add(MapEntry(field, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> maybeSingle() async {
|
||||
final rows = _rows;
|
||||
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
|
||||
}
|
||||
|
||||
// Future delegation.
|
||||
@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() a) =>
|
||||
_future.whenComplete(a);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _logs(_FakeClient c) =>
|
||||
c.tables['programmer_task_activity_logs']!;
|
||||
Map<String, dynamic> _task(_FakeClient c, String id) =>
|
||||
c.tables['programmer_tasks']!.firstWhere((t) => t['id'] == id);
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('ProgrammerTasksController.createTask', () {
|
||||
test('self-logged task: assignee = creator, no "assigned" log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
|
||||
final id = await c.createTask(
|
||||
title: 'Fix login bug',
|
||||
category: ProgrammerTaskCategory.bugFix,
|
||||
);
|
||||
|
||||
final row = _task(fake, id);
|
||||
expect(row['assignee_id'], 'u1');
|
||||
expect(row['creator_id'], 'u1');
|
||||
expect(row['status'], ProgrammerTaskStatus.queued);
|
||||
expect(_logs(fake).where((l) => l['action_type'] == 'created').length, 1);
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'assigned'), isFalse);
|
||||
});
|
||||
|
||||
test('assigned to another programmer records an "assigned" log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
|
||||
final id = await c.createTask(
|
||||
title: 'Build report export',
|
||||
category: ProgrammerTaskCategory.softwareDevelopment,
|
||||
assigneeId: 'u2',
|
||||
);
|
||||
|
||||
expect(_task(fake, id)['assignee_id'], 'u2');
|
||||
final assigned =
|
||||
_logs(fake).where((l) => l['action_type'] == 'assigned').toList();
|
||||
expect(assigned.length, 1);
|
||||
expect(assigned.first['meta'], {'assignee_id': 'u2'});
|
||||
});
|
||||
});
|
||||
|
||||
group('findRunningTaskForCurrentUser (pause-on-switch detection)', () {
|
||||
test('returns null when the user has no in_progress task', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
fake.tables['programmer_tasks']!.add({
|
||||
'id': 't1',
|
||||
'assignee_id': 'u1',
|
||||
'status': ProgrammerTaskStatus.queued,
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
expect(await c.findRunningTaskForCurrentUser(), isNull);
|
||||
});
|
||||
|
||||
test('returns an in_progress task whose latest event is "started"', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
fake.tables['programmer_tasks']!.add({
|
||||
'id': 't1',
|
||||
'title': 'Running task',
|
||||
'category': ProgrammerTaskCategory.others,
|
||||
'assignee_id': 'u1',
|
||||
'status': ProgrammerTaskStatus.inProgress,
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
_logs(fake).add({
|
||||
'task_id': 't1',
|
||||
'action_type': 'started',
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
|
||||
final running = await c.findRunningTaskForCurrentUser();
|
||||
expect(running, isNotNull);
|
||||
expect(running!.id, 't1');
|
||||
});
|
||||
|
||||
test('a paused in_progress task does NOT count as running', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
fake.tables['programmer_tasks']!.add({
|
||||
'id': 't1',
|
||||
'title': 'Paused task',
|
||||
'category': ProgrammerTaskCategory.others,
|
||||
'assignee_id': 'u1',
|
||||
'status': ProgrammerTaskStatus.inProgress,
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
_logs(fake).add({
|
||||
'task_id': 't1',
|
||||
'action_type': 'started',
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
_logs(fake).add({
|
||||
'task_id': 't1',
|
||||
'action_type': 'paused',
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
|
||||
expect(await c.findRunningTaskForCurrentUser(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('lifecycle transitions', () {
|
||||
test('startTask sets in_progress + started_at + a "started" log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.meeting,
|
||||
);
|
||||
|
||||
await c.startTask(taskId: id);
|
||||
|
||||
final row = _task(fake, id);
|
||||
expect(row['status'], ProgrammerTaskStatus.inProgress);
|
||||
expect(row['started_at'], isNotNull);
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'started'), isTrue);
|
||||
});
|
||||
|
||||
test('pauseTask is idempotent while already paused', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
await c.startTask(taskId: id);
|
||||
|
||||
await c.pauseTask(taskId: id);
|
||||
await c.pauseTask(taskId: id); // second call is a no-op
|
||||
|
||||
expect(_logs(fake).where((l) => l['action_type'] == 'paused').length, 1);
|
||||
});
|
||||
|
||||
test('resumeTask logs a "resumed" event after a pause', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
await c.startTask(taskId: id);
|
||||
await c.pauseTask(taskId: id);
|
||||
|
||||
await c.resumeTask(taskId: id);
|
||||
|
||||
expect(_logs(fake).where((l) => l['action_type'] == 'resumed').length, 1);
|
||||
});
|
||||
|
||||
test('completeTask sets completed status + timestamp + log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
await c.startTask(taskId: id);
|
||||
|
||||
await c.completeTask(taskId: id);
|
||||
|
||||
final row = _task(fake, id);
|
||||
expect(row['status'], ProgrammerTaskStatus.completed);
|
||||
expect(row['completed_at'], isNotNull);
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'completed'), isTrue);
|
||||
});
|
||||
|
||||
test('cancelTask stores the reason and a "cancelled" log', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
|
||||
await c.cancelTask(taskId: id, reason: 'Descoped');
|
||||
|
||||
final row = _task(fake, id);
|
||||
expect(row['status'], ProgrammerTaskStatus.cancelled);
|
||||
expect(row['cancellation_reason'], 'Descoped');
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'cancelled'), isTrue);
|
||||
});
|
||||
|
||||
test('reassign updates assignee and logs a "reassigned" event', () async {
|
||||
final fake = _FakeClient('u1');
|
||||
final c = ProgrammerTasksController(fake as dynamic);
|
||||
final id = await c.createTask(
|
||||
title: 't',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
);
|
||||
|
||||
await c.reassign(taskId: id, newAssigneeId: 'u2');
|
||||
|
||||
expect(_task(fake, id)['assignee_id'], 'u2');
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'reassigned'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('startProgrammerTaskWithPausePrompt (pause-on-switch UX)', () {
|
||||
_FakeClient seededSwitchState() {
|
||||
final fake = _FakeClient('u1');
|
||||
// Task A is actively running for u1.
|
||||
fake.tables['programmer_tasks']!.add({
|
||||
'id': 'A',
|
||||
'title': 'Task A',
|
||||
'category': ProgrammerTaskCategory.others,
|
||||
'assignee_id': 'u1',
|
||||
'status': ProgrammerTaskStatus.inProgress,
|
||||
'started_at': fake.nextTimestamp(),
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
_logs(fake).add({
|
||||
'task_id': 'A',
|
||||
'action_type': 'started',
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
// Task B is queued and about to be started.
|
||||
fake.tables['programmer_tasks']!.add({
|
||||
'id': 'B',
|
||||
'title': 'Task B',
|
||||
'category': ProgrammerTaskCategory.others,
|
||||
'assignee_id': 'u1',
|
||||
'status': ProgrammerTaskStatus.queued,
|
||||
'created_at': fake.nextTimestamp(),
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
ProgrammerTask taskB() => ProgrammerTask(
|
||||
id: 'B',
|
||||
title: 'Task B',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
status: ProgrammerTaskStatus.queued,
|
||||
assigneeId: 'u1',
|
||||
creatorId: 'u1',
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
Widget harness(_FakeClient fake) => ProviderScope(
|
||||
overrides: [
|
||||
programmerTasksControllerProvider.overrideWithValue(
|
||||
ProgrammerTasksController(fake as dynamic),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => ElevatedButton(
|
||||
onPressed: () =>
|
||||
startProgrammerTaskWithPausePrompt(context, ref, taskB()),
|
||||
child: const Text('Start B'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('prompts, then pauses A and starts B on confirm', (tester) async {
|
||||
final fake = seededSwitchState();
|
||||
await tester.pumpWidget(harness(fake));
|
||||
|
||||
await tester.tap(find.text('Start B'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The prompt names the currently-running task.
|
||||
expect(find.text('Pause current task?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Pause & Start'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
_logs(fake).any((l) => l['task_id'] == 'A' && l['action_type'] == 'paused'),
|
||||
isTrue,
|
||||
);
|
||||
expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.inProgress);
|
||||
});
|
||||
|
||||
testWidgets('cancel leaves both tasks untouched', (tester) async {
|
||||
final fake = seededSwitchState();
|
||||
await tester.pumpWidget(harness(fake));
|
||||
|
||||
await tester.tap(find.text('Start B'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_logs(fake).any((l) => l['action_type'] == 'paused'), isFalse);
|
||||
expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user