feat(programmer): per-day worked time split with assignee attribution

Implement daily time tracking and splitting for programmer tasks across
Manila midnights with proper assignee attribution, including support for
pauses, reassignments, and adjustments.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 20:53:49 +08:00
parent dd6730f62d
commit 6beb92d3c6
2 changed files with 459 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
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/utils/app_time.dart';
import 'package:tasq/utils/programmer_daily_time.dart';
void main() {
setUpAll(AppTime.initialize);
/// Helper to create a ProgrammerTaskActivityLog at Manila time d h:m
ProgrammerTaskActivityLog _ev(
String action,
int d,
int h, [
int m = 0,
Map<String, dynamic>? meta,
]) {
return ProgrammerTaskActivityLog(
id: 'ev-$d-$h-$m',
taskId: 'task-1',
actorId: 'actor',
actionType: action,
meta: meta,
createdAt: AppTime.fromComponents(
year: 2026,
month: 9,
day: d,
hour: h,
minute: m,
),
);
}
/// Helper to create a ProgrammerTask
ProgrammerTask _task({
String status = ProgrammerTaskStatus.inProgress,
String? creatorId = 'a',
String? assigneeId = 'a',
DateTime? startedAt,
DateTime? completedAt,
DateTime? cancelledAt,
}) {
return ProgrammerTask(
id: 'task-1',
title: 'Test Task',
category: ProgrammerTaskCategory.softwareDevelopment,
status: status,
creatorId: creatorId,
assigneeId: assigneeId,
createdAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
startedAt: startedAt,
completedAt: completedAt,
cancelledAt: cancelledAt,
updatedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
);
}
test('manilaDay ignores device time zone', () {
// Sep 26 4:30 PM UTC = Sep 27 12:30 AM Manila time
expect(
manilaDay(DateTime.parse('2026-09-26T16:30:00Z')),
DateTime.utc(2026, 9, 27),
);
// Sep 26 3:59 PM UTC = Sep 26 11:59 PM Manila time
expect(
manilaDay(DateTime.parse('2026-09-26T15:59:00Z')),
DateTime.utc(2026, 9, 26),
);
});
test('day key round-trip', () {
expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27');
expect(formatDayKey(parseDayKey('2025-01-05')), '2025-01-05');
});
test('single day with a pause', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15),
);
final logs = [
_ev('started', 27, 9),
_ev('paused', 27, 11),
_ev('resumed', 27, 13),
_ev('completed', 27, 15),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a'], {DateTime.utc(2026, 9, 27): 4 * 3600});
});
test('running across Manila midnight splits by day', () {
final task = _task(
status: ProgrammerTaskStatus.inProgress,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 26, hour: 22),
);
final logs = [
_ev('started', 26, 22),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 2);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 26)], 2 * 3600);
expect(result['a']![DateTime.utc(2026, 9, 27)], 2 * 3600);
});
test('reassignment credits each assignee for their own span', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'b',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12),
);
final logs = [
_ev('started', 27, 9),
_ev('reassigned', 27, 10, 0, {'assignee_id': 'b'}),
_ev('completed', 27, 12),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
expect(result['b']![DateTime.utc(2026, 9, 27)], 2 * 3600);
});
test('initial assignee comes from the first assigned event', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'boss',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
);
final logs = [
_ev('assigned', 27, 8, 0, {'assignee_id': 'a'}),
_ev('started', 27, 9),
_ev('completed', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result.keys.toList(), ['a']);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
});
test('adjustment subtracts on the day created and clamps at zero', () {
// Case 1: adjustment {seconds:1800} at 10:00
final task1 = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
);
final logs1 = [
_ev('started', 27, 9),
_ev('adjustment', 27, 10, 0, {'seconds': 1800}),
_ev('completed', 27, 11),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result1 =
dailySecondsByAssignee(task: task1, logs: logs1, now: now);
expect(result1['a']![DateTime.utc(2026, 9, 27)], 5400); // 7200 - 1800
// Case 2: adjustment {seconds: 99999} -> clamps at 0
final task2 = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
);
final logs2 = [
_ev('started', 27, 9),
_ev('adjustment', 27, 10, 0, {'seconds': 99999}),
_ev('completed', 27, 11),
];
final result2 =
dailySecondsByAssignee(task: task2, logs: logs2, now: now);
expect(result2['a']![DateTime.utc(2026, 9, 27)], 0); // clamped
});
test('falls back to task.startedAt when there is no started log', () {
final task = _task(
status: ProgrammerTaskStatus.completed,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
completedAt:
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
);
final logs = [
_ev('completed', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
});
test('a paused in-progress task stops accruing at the pause', () {
final task = _task(
status: ProgrammerTaskStatus.inProgress,
creatorId: 'a',
assigneeId: 'a',
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
);
final logs = [
_ev('started', 27, 9),
_ev('paused', 27, 10),
];
final now =
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15);
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // not 6 hours
});
}