Files
tasq/lib/utils/programmer_daily_time.dart
T
redz1029 6beb92d3c6 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>
2026-09-27 20:53:49 +08:00

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;
}