6beb92d3c6
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>
222 lines
6.4 KiB
Dart
222 lines
6.4 KiB
Dart
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;
|
|
}
|