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:
@@ -0,0 +1,221 @@
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'app_time.dart';
|
||||
|
||||
/// Get the Manila calendar date of an instant as DateTime.utc(y, m, d)
|
||||
DateTime manilaDay(DateTime instant) {
|
||||
final utc = instant.isUtc ? instant : instant.toUtc();
|
||||
final manila = tz.TZDateTime.from(utc, tz.local);
|
||||
return DateTime.utc(manila.year, manila.month, manila.day);
|
||||
}
|
||||
|
||||
/// Get the Manila midnight of a day key as a TZDateTime
|
||||
DateTime manilaDayStart(DateTime dayKey) {
|
||||
return AppTime.fromComponents(
|
||||
year: dayKey.year,
|
||||
month: dayKey.month,
|
||||
day: dayKey.day,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a day key string (e.g., '2026-09-27') to DateTime.utc
|
||||
DateTime parseDayKey(String yyyyMmDd) {
|
||||
final parts = yyyyMmDd.split('-');
|
||||
return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]));
|
||||
}
|
||||
|
||||
/// Format a day key to string (e.g., DateTime.utc(2026, 9, 27) -> '2026-09-27')
|
||||
String formatDayKey(DateTime dayKey) {
|
||||
final y = dayKey.year.toString();
|
||||
final m = dayKey.month.toString().padLeft(2, '0');
|
||||
final d = dayKey.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$d';
|
||||
}
|
||||
|
||||
/// Represents a worked interval for an assignee
|
||||
typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end});
|
||||
|
||||
/// Calculate worked intervals from task and activity logs
|
||||
List<WorkedInterval> workedIntervals({
|
||||
required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
final intervals = <WorkedInterval>[];
|
||||
DateTime? segmentStart;
|
||||
String currentAssignee = _initialAssignee(task, sorted);
|
||||
|
||||
// Check if there's a synthetic started event needed
|
||||
if (!sorted.any((l) => l.actionType == 'started') && task.startedAt != null) {
|
||||
// Inject synthetic started
|
||||
sorted.insert(
|
||||
0,
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 'synthetic-start',
|
||||
taskId: task.id,
|
||||
actionType: 'started',
|
||||
createdAt: task.startedAt!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final log in sorted) {
|
||||
switch (log.actionType) {
|
||||
case 'started':
|
||||
case 'resumed':
|
||||
if (segmentStart == null) {
|
||||
segmentStart = log.createdAt;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'paused':
|
||||
case 'completed':
|
||||
case 'cancelled':
|
||||
if (segmentStart != null) {
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: log.createdAt,
|
||||
));
|
||||
segmentStart = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reassigned':
|
||||
if (segmentStart != null) {
|
||||
// Close segment for old assignee
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: log.createdAt,
|
||||
));
|
||||
}
|
||||
// Update assignee and reopen segment
|
||||
if (log.meta?['assignee_id'] != null) {
|
||||
currentAssignee = log.meta!['assignee_id'] as String;
|
||||
segmentStart = log.createdAt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open segment
|
||||
if (segmentStart != null) {
|
||||
final endTime = task.status == ProgrammerTaskStatus.inProgress
|
||||
? now
|
||||
: (task.completedAt ?? task.cancelledAt ?? now);
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: endTime,
|
||||
));
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
|
||||
/// Calculate daily seconds by assignee
|
||||
Map<String, Map<DateTime, int>> dailySecondsByAssignee({
|
||||
required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final intervals = workedIntervals(task: task, logs: logs, now: now);
|
||||
final result = <String, Map<DateTime, int>>{};
|
||||
|
||||
// Process intervals and split at Manila midnights
|
||||
for (final interval in intervals) {
|
||||
var current = interval.start;
|
||||
final end = interval.end;
|
||||
|
||||
while (current.isBefore(end)) {
|
||||
final currentDay = manilaDay(current);
|
||||
final dayStart = manilaDayStart(currentDay);
|
||||
final nextDayStart = dayStart.add(Duration(days: 1));
|
||||
final segmentEnd = end.isBefore(nextDayStart) ? end : nextDayStart;
|
||||
|
||||
final seconds = segmentEnd.difference(current).inSeconds;
|
||||
|
||||
if (!result.containsKey(interval.assigneeId)) {
|
||||
result[interval.assigneeId] = {};
|
||||
}
|
||||
|
||||
result[interval.assigneeId]![currentDay] =
|
||||
(result[interval.assigneeId]![currentDay] ?? 0) + seconds;
|
||||
|
||||
current = segmentEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply adjustments
|
||||
for (final log in logs) {
|
||||
if (log.actionType == 'adjustment' && log.meta?['seconds'] != null) {
|
||||
final adjustSeconds = (log.meta!['seconds'] as num).toInt();
|
||||
final adjustDay = manilaDay(log.createdAt);
|
||||
|
||||
// Find the assignee at this time
|
||||
final assigneeAtTime = _assigneeAtTime(task, logs, log.createdAt);
|
||||
if (assigneeAtTime != null) {
|
||||
if (!result.containsKey(assigneeAtTime)) {
|
||||
result[assigneeAtTime] = {};
|
||||
}
|
||||
final current =
|
||||
result[assigneeAtTime]![adjustDay] ?? 0;
|
||||
result[assigneeAtTime]![adjustDay] =
|
||||
(current - adjustSeconds).clamp(0, 999999999);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Find the initial assignee based on task and logs
|
||||
String _initialAssignee(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
) {
|
||||
// First: check for 'assigned' event
|
||||
for (final log in logs) {
|
||||
if (log.actionType == 'assigned' && log.meta?['assignee_id'] != null) {
|
||||
return log.meta!['assignee_id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
// Second: if any 'reassigned' event exists, use task.creatorId
|
||||
if (logs.any((l) => l.actionType == 'reassigned') && task.creatorId != null) {
|
||||
return task.creatorId!;
|
||||
}
|
||||
|
||||
// Third: use task.assigneeId
|
||||
return task.assigneeId ?? 'unknown';
|
||||
}
|
||||
|
||||
/// Find the assignee at a specific time
|
||||
String? _assigneeAtTime(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
DateTime time,
|
||||
) {
|
||||
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
var currentAssignee = _initialAssignee(task, sorted);
|
||||
|
||||
for (final log in sorted) {
|
||||
if (log.createdAt.isAfter(time)) break;
|
||||
|
||||
if (log.actionType == 'reassigned' &&
|
||||
log.meta?['assignee_id'] != null) {
|
||||
currentAssignee = log.meta!['assignee_id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
return currentAssignee;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user