Files
tasq/lib/utils/programmer_daily_time.dart
T
redz1029 f071cc722d fix(programmer): reassignment while paused must not open new segment
Capture whether segment was running before reassignment, and only reopen
for new assignee if it was. A paused task that gets reassigned should not
start accruing time for the new assignee until explicitly resumed.

Also add test: reassignment while paused does not open segment.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 20:57:55 +08:00

226 lines
6.5 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':
final wasRunning = segmentStart != null;
if (segmentStart != null) {
// Close segment for old assignee
intervals.add((
assigneeId: currentAssignee,
start: segmentStart,
end: log.createdAt,
));
segmentStart = null;
}
// Update assignee and reopen segment only if it was running
if (log.meta?['assignee_id'] != null) {
currentAssignee = log.meta!['assignee_id'] as String;
if (wasRunning) {
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;
}