feat(programmer): day rows builder + work-log workDate
- Add workDate field to ProgrammerTaskWorkLog (defaults to Manila day of createdAt) - Add deltaJsonToPlainText function to quill_field.dart for Delta/plain text conversion - Implement buildDayRows function to generate day sheet rows with proper sorting - Implement DaySheetRow serialization and deserialization - Add comprehensive tests for day rows builder and workDate field - Verify backward compatibility via existing programmer_task_ledger_test Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import '../utils/app_time.dart';
|
import '../utils/app_time.dart';
|
||||||
|
import '../utils/programmer_daily_time.dart';
|
||||||
|
|
||||||
/// A work-log entry on a programmer task (plain, online-first model).
|
/// A work-log entry on a programmer task (plain, online-first model).
|
||||||
///
|
///
|
||||||
@@ -13,6 +14,7 @@ class ProgrammerTaskWorkLog {
|
|||||||
final String description;
|
final String description;
|
||||||
final int? minutes;
|
final int? minutes;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final DateTime workDate;
|
||||||
|
|
||||||
ProgrammerTaskWorkLog({
|
ProgrammerTaskWorkLog({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -21,16 +23,22 @@ class ProgrammerTaskWorkLog {
|
|||||||
required this.description,
|
required this.description,
|
||||||
this.minutes,
|
this.minutes,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
DateTime? workDate,
|
||||||
|
}) : workDate = workDate ?? manilaDay(createdAt);
|
||||||
|
|
||||||
factory ProgrammerTaskWorkLog.fromMap(Map<String, dynamic> map) {
|
factory ProgrammerTaskWorkLog.fromMap(Map<String, dynamic> map) {
|
||||||
|
final createdAt = AppTime.parse(map['created_at'] as String);
|
||||||
|
final workDate = map['work_date'] != null
|
||||||
|
? parseDayKey(map['work_date'] as String)
|
||||||
|
: manilaDay(createdAt);
|
||||||
return ProgrammerTaskWorkLog(
|
return ProgrammerTaskWorkLog(
|
||||||
id: map['id'].toString(),
|
id: map['id'].toString(),
|
||||||
taskId: map['task_id'].toString(),
|
taskId: map['task_id'].toString(),
|
||||||
authorId: map['author_id'].toString(),
|
authorId: map['author_id'].toString(),
|
||||||
description: map['description'] as String? ?? '',
|
description: map['description'] as String? ?? '',
|
||||||
minutes: (map['minutes'] as num?)?.toInt(),
|
minutes: (map['minutes'] as num?)?.toInt(),
|
||||||
createdAt: AppTime.parse(map['created_at'] as String),
|
createdAt: createdAt,
|
||||||
|
workDate: workDate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:tasq/models/programmer_task.model.dart';
|
||||||
|
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||||
|
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||||
|
import 'package:tasq/widgets/quill_field.dart';
|
||||||
|
import 'programmer_daily_time.dart';
|
||||||
|
|
||||||
|
/// A single row in a programmer's day sheet (assignee or helper work).
|
||||||
|
class DaySheetRow {
|
||||||
|
final String taskId;
|
||||||
|
final String title;
|
||||||
|
final String category;
|
||||||
|
final String kind; // 'assignee' or 'helper'
|
||||||
|
final String? projectName;
|
||||||
|
final String? helpedName;
|
||||||
|
final int seconds;
|
||||||
|
final List<String> notes;
|
||||||
|
|
||||||
|
DaySheetRow({
|
||||||
|
required this.taskId,
|
||||||
|
required this.title,
|
||||||
|
required this.category,
|
||||||
|
required this.kind,
|
||||||
|
this.projectName,
|
||||||
|
this.helpedName,
|
||||||
|
required this.seconds,
|
||||||
|
required this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'task_id': taskId,
|
||||||
|
'title': title,
|
||||||
|
'category': category,
|
||||||
|
'project_name': projectName,
|
||||||
|
'kind': kind,
|
||||||
|
'helped_name': helpedName,
|
||||||
|
'seconds': seconds,
|
||||||
|
'notes': notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory DaySheetRow.fromJson(Map<String, dynamic> json) => DaySheetRow(
|
||||||
|
taskId: json['task_id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
category: json['category'] as String,
|
||||||
|
kind: json['kind'] as String,
|
||||||
|
projectName: json['project_name'] as String?,
|
||||||
|
helpedName: json['helped_name'] as String?,
|
||||||
|
seconds: json['seconds'] as int,
|
||||||
|
notes: List<String>.from(json['notes'] as List? ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build day rows for a programmer on a specific day.
|
||||||
|
/// Returns rows for assignee tasks (by seconds desc) then helper tasks (by seconds desc).
|
||||||
|
List<DaySheetRow> buildDayRows({
|
||||||
|
required String userId,
|
||||||
|
required DateTime day,
|
||||||
|
required List<ProgrammerTask> tasks,
|
||||||
|
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
|
||||||
|
required List<ProgrammerTaskWorkLog> workLogs,
|
||||||
|
required Map<String, String> projectNames,
|
||||||
|
required Map<String, String> profileNames,
|
||||||
|
required DateTime now,
|
||||||
|
}) {
|
||||||
|
final taskMap = {for (final t in tasks) t.id: t};
|
||||||
|
final workLogsByTaskAndAuthor = <String, List<ProgrammerTaskWorkLog>>{};
|
||||||
|
|
||||||
|
// Group work logs by task and author for quick lookup
|
||||||
|
for (final log in workLogs) {
|
||||||
|
final key = '${log.taskId}:${log.authorId}';
|
||||||
|
workLogsByTaskAndAuthor.putIfAbsent(key, () => []).add(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rows = <DaySheetRow>[];
|
||||||
|
|
||||||
|
// Process each task
|
||||||
|
for (final task in tasks) {
|
||||||
|
if (!taskMap.containsKey(task.id)) continue;
|
||||||
|
|
||||||
|
final assigneeId = task.assigneeId;
|
||||||
|
|
||||||
|
// Compute daily seconds for this task
|
||||||
|
final logs = logsByTask[task.id] ?? [];
|
||||||
|
final dailyByAssignee = dailySecondsByAssignee(
|
||||||
|
task: task,
|
||||||
|
logs: logs,
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
final userDailySeconds = dailyByAssignee[userId] ?? {};
|
||||||
|
final assigneeSeconds = userDailySeconds[day] ?? 0;
|
||||||
|
|
||||||
|
// Check for assignee row: has seconds that day OR authored notes-only log that day
|
||||||
|
final assigneeLogsKey = '${task.id}:$userId';
|
||||||
|
final assigneeLogsThisDay = workLogsByTaskAndAuthor[assigneeLogsKey]
|
||||||
|
?.where((l) => l.workDate == day && l.minutes == null)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (assigneeSeconds > 0 || assigneeLogsThisDay.isNotEmpty) {
|
||||||
|
final notes = assigneeLogsThisDay
|
||||||
|
.map((l) => deltaJsonToPlainText(l.description))
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
rows.add(DaySheetRow(
|
||||||
|
taskId: task.id,
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
kind: 'assignee',
|
||||||
|
projectName: task.projectId != null ? projectNames[task.projectId] : null,
|
||||||
|
helpedName: null,
|
||||||
|
seconds: assigneeSeconds,
|
||||||
|
notes: notes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for helper row: userId != assigneeId AND has minutes logs that day
|
||||||
|
if (userId != assigneeId) {
|
||||||
|
final helperLogsKey = '${task.id}:$userId';
|
||||||
|
final helperLogs = workLogsByTaskAndAuthor[helperLogsKey]
|
||||||
|
?.where((l) => l.workDate == day && l.minutes != null && l.minutes! > 0)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (helperLogs.isNotEmpty) {
|
||||||
|
final totalMinutes = helperLogs.fold<int>(0, (sum, l) => sum + (l.minutes ?? 0));
|
||||||
|
final seconds = totalMinutes * 60;
|
||||||
|
final notes = helperLogs
|
||||||
|
.map((l) => deltaJsonToPlainText(l.description))
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
rows.add(DaySheetRow(
|
||||||
|
taskId: task.id,
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
kind: 'helper',
|
||||||
|
projectName: task.projectId != null ? projectNames[task.projectId] : null,
|
||||||
|
helpedName: assigneeId != null ? profileNames[assigneeId] : null,
|
||||||
|
seconds: seconds,
|
||||||
|
notes: notes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: assignee rows by seconds desc, then helper rows by seconds desc
|
||||||
|
rows.sort((a, b) {
|
||||||
|
if (a.kind != b.kind) {
|
||||||
|
return a.kind == 'assignee' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.seconds.compareTo(a.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate total seconds from day sheet rows.
|
||||||
|
int totalSeconds(List<DaySheetRow> rows) {
|
||||||
|
return rows.fold(0, (sum, row) => sum + row.seconds);
|
||||||
|
}
|
||||||
@@ -3,6 +3,26 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
|
|
||||||
|
/// Convert a stored Delta JSON or plain text string to trimmed plain text.
|
||||||
|
/// - Delta JSON (starts with '[') is decoded and converted to plain text
|
||||||
|
/// - Non-JSON strings are returned trimmed as-is
|
||||||
|
/// - null returns empty string
|
||||||
|
String deltaJsonToPlainText(String? stored) {
|
||||||
|
if (stored == null) return '';
|
||||||
|
final trimmed = stored.trim();
|
||||||
|
if (trimmed.isEmpty) return '';
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(trimmed);
|
||||||
|
if (decoded is List) {
|
||||||
|
final doc = quill.Document.fromJson(decoded);
|
||||||
|
return doc.toPlainText().trim();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a stored Delta-JSON string into a [quill.QuillController], or returns
|
/// Parses a stored Delta-JSON string into a [quill.QuillController], or returns
|
||||||
/// an empty one. Callers own the controller and must dispose it.
|
/// an empty one. Callers own the controller and must dispose it.
|
||||||
quill.QuillController quillControllerFromDelta(String? deltaJson) {
|
quill.QuillController quillControllerFromDelta(String? deltaJson) {
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
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/models/programmer_task_work_log.model.dart';
|
||||||
|
import 'package:tasq/utils/app_time.dart';
|
||||||
|
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||||
|
import 'package:tasq/widgets/quill_field.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(AppTime.initialize);
|
||||||
|
|
||||||
|
group('deltaJsonToPlainText', () {
|
||||||
|
test('legacy plain text notes pass through', () {
|
||||||
|
expect(deltaJsonToPlainText('fixed bug'), 'fixed bug');
|
||||||
|
expect(deltaJsonToPlainText(' spaces '), 'spaces');
|
||||||
|
expect(deltaJsonToPlainText(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null returns empty string', () {
|
||||||
|
expect(deltaJsonToPlainText(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid JSON falls back to returning trimmed string', () {
|
||||||
|
expect(deltaJsonToPlainText('[{invalid json'), '[{invalid json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delta JSON arrays starting with [ are attempted to be parsed', () {
|
||||||
|
// The function attempts to parse JSON that starts with '['
|
||||||
|
// If parsing fails, it returns the trimmed string
|
||||||
|
// This test verifies the fallback behavior
|
||||||
|
expect(deltaJsonToPlainText('[{"invalid":"json"}]'), '[{"invalid":"json"}]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DaySheetRow', () {
|
||||||
|
test('json round-trip', () {
|
||||||
|
final row = DaySheetRow(
|
||||||
|
taskId: 't1',
|
||||||
|
title: 'Review PR',
|
||||||
|
category: 'Software Support',
|
||||||
|
kind: 'assignee',
|
||||||
|
projectName: 'Project A',
|
||||||
|
helpedName: null,
|
||||||
|
seconds: 3600,
|
||||||
|
notes: ['First note', 'Second note'],
|
||||||
|
);
|
||||||
|
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helper row json round-trip', () {
|
||||||
|
final row = DaySheetRow(
|
||||||
|
taskId: 't2',
|
||||||
|
title: 'Debug issue',
|
||||||
|
category: 'Bug Fix',
|
||||||
|
kind: 'helper',
|
||||||
|
projectName: null,
|
||||||
|
helpedName: 'Ana Cruz',
|
||||||
|
seconds: 2700,
|
||||||
|
notes: ['Helped with testing'],
|
||||||
|
);
|
||||||
|
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ProgrammerTaskWorkLog.workDate', () {
|
||||||
|
test('workDate defaults to Manila day of createdAt', () {
|
||||||
|
final base = {
|
||||||
|
'id': 'l1',
|
||||||
|
'task_id': 't1',
|
||||||
|
'author_id': 'a',
|
||||||
|
'description': 'work',
|
||||||
|
'minutes': null,
|
||||||
|
'created_at': '2026-09-26T16:30:00Z',
|
||||||
|
};
|
||||||
|
final log = ProgrammerTaskWorkLog.fromMap(base);
|
||||||
|
// 2026-09-26 16:30 UTC is 2026-09-27 in Manila (UTC+8)
|
||||||
|
expect(log.workDate, DateTime.utc(2026, 9, 27));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workDate from map when provided', () {
|
||||||
|
final base = {
|
||||||
|
'id': 'l1',
|
||||||
|
'task_id': 't1',
|
||||||
|
'author_id': 'a',
|
||||||
|
'description': 'work',
|
||||||
|
'minutes': null,
|
||||||
|
'created_at': '2026-09-26T16:30:00Z',
|
||||||
|
'work_date': '2026-09-25',
|
||||||
|
};
|
||||||
|
final log = ProgrammerTaskWorkLog.fromMap(base);
|
||||||
|
expect(log.workDate, DateTime.utc(2026, 9, 25));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workDate constructor parameter', () {
|
||||||
|
final log = ProgrammerTaskWorkLog(
|
||||||
|
id: 'l1',
|
||||||
|
taskId: 't1',
|
||||||
|
authorId: 'a',
|
||||||
|
description: 'work',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 26, 16, 30),
|
||||||
|
workDate: DateTime.utc(2026, 9, 25),
|
||||||
|
);
|
||||||
|
expect(log.workDate, DateTime.utc(2026, 9, 25));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildDayRows', () {
|
||||||
|
final day = DateTime.utc(2026, 9, 27);
|
||||||
|
final earlierDay = DateTime.utc(2026, 9, 26);
|
||||||
|
final now = DateTime.utc(2026, 9, 27, 12, 0);
|
||||||
|
|
||||||
|
ProgrammerTask _task(String id, String assigneeId, [String? projectId]) =>
|
||||||
|
ProgrammerTask(
|
||||||
|
id: id,
|
||||||
|
title: 'Task $id',
|
||||||
|
category: 'Software Support',
|
||||||
|
status: 'in_progress',
|
||||||
|
assigneeId: assigneeId,
|
||||||
|
creatorId: 'creator',
|
||||||
|
projectId: projectId,
|
||||||
|
createdAt: DateTime.utc(2026, 9, 1),
|
||||||
|
updatedAt: DateTime.utc(2026, 9, 27),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProgrammerTaskActivityLog _log(String taskId, String type,
|
||||||
|
[Map<String, dynamic>? meta]) =>
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: '$taskId-$type',
|
||||||
|
taskId: taskId,
|
||||||
|
actionType: type,
|
||||||
|
meta: meta,
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProgrammerTaskWorkLog _workLog(
|
||||||
|
String taskId,
|
||||||
|
String authorId,
|
||||||
|
String description, {
|
||||||
|
int? minutes,
|
||||||
|
DateTime? workDate,
|
||||||
|
}) =>
|
||||||
|
ProgrammerTaskWorkLog(
|
||||||
|
id: '$taskId-$authorId-${minutes ?? "notes"}',
|
||||||
|
taskId: taskId,
|
||||||
|
authorId: authorId,
|
||||||
|
description: description,
|
||||||
|
minutes: minutes,
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 10),
|
||||||
|
workDate: workDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('assignee row includes only that-day seconds and notes', () {
|
||||||
|
// Task t1 assigned to 'a'
|
||||||
|
// 'a' worked 1 hour on t1 that day
|
||||||
|
// 'a' also logged a note on t1 that day
|
||||||
|
// 'a' logged a note on t1 the day before (should be excluded)
|
||||||
|
final task = _task('t1', 'a');
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'a', 'Fixed part 1', workDate: day),
|
||||||
|
_workLog('t1', 'a', 'Fixed part 2', workDate: earlierDay),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'a',
|
||||||
|
day: day,
|
||||||
|
tasks: [task],
|
||||||
|
logsByTask: {
|
||||||
|
't1': [
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't1-started',
|
||||||
|
taskId: 't1',
|
||||||
|
actionType: 'started',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
|
||||||
|
),
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't1-paused',
|
||||||
|
taskId: 't1',
|
||||||
|
actionType: 'paused',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {'a': 'Alice Smith'},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows.length, 1);
|
||||||
|
expect(rows[0].kind, 'assignee');
|
||||||
|
expect(rows[0].notes, ['Fixed part 1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helper row: minutes*60 and helpedName', () {
|
||||||
|
// Task t1 assigned to 'a'
|
||||||
|
// 'h' (helper) logs 45 minutes of help on t1 that day
|
||||||
|
final task = _task('t1', 'a');
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'h', 'Helped with debugging', minutes: 45, workDate: day),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'h',
|
||||||
|
day: day,
|
||||||
|
tasks: [task],
|
||||||
|
logsByTask: {'t1': []},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {'a': 'Ana Cruz'},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows.length, 1);
|
||||||
|
expect(rows[0].kind, 'helper');
|
||||||
|
expect(rows[0].seconds, 2700);
|
||||||
|
expect(rows[0].helpedName, 'Ana Cruz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notes-only row when assignee logged but ran no timer', () {
|
||||||
|
// Task t1 assigned to 'a'
|
||||||
|
// 'a' only logged notes (no timer activity)
|
||||||
|
final task = _task('t1', 'a');
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'a', 'Just some notes', workDate: day),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'a',
|
||||||
|
day: day,
|
||||||
|
tasks: [task],
|
||||||
|
logsByTask: {'t1': []},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {'a': 'Alice Smith'},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows.length, 1);
|
||||||
|
expect(rows[0].kind, 'assignee');
|
||||||
|
expect(rows[0].seconds, 0);
|
||||||
|
expect(rows[0].notes, ['Just some notes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows sort: assignee by seconds desc, then helpers by seconds desc', () {
|
||||||
|
// Task t1 assigned to 'a': 1 hour
|
||||||
|
// Task t2 assigned to 'a': 30 minutes
|
||||||
|
// Task t3 assigned to 'b': with 'a' as helper (20 minutes)
|
||||||
|
// Task t4 assigned to 'b': with 'a' as helper (10 minutes)
|
||||||
|
final tasks = [
|
||||||
|
_task('t1', 'a'),
|
||||||
|
_task('t2', 'a'),
|
||||||
|
_task('t3', 'b'),
|
||||||
|
_task('t4', 'b'),
|
||||||
|
];
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t3', 'a', 'helped b', minutes: 20, workDate: day),
|
||||||
|
_workLog('t4', 'a', 'helped b', minutes: 10, workDate: day),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'a',
|
||||||
|
day: day,
|
||||||
|
tasks: tasks,
|
||||||
|
logsByTask: {
|
||||||
|
't1': [
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't1-started',
|
||||||
|
taskId: 't1',
|
||||||
|
actionType: 'started',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
|
||||||
|
),
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't1-paused',
|
||||||
|
taskId: 't1',
|
||||||
|
actionType: 'paused',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
't2': [
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't2-started',
|
||||||
|
taskId: 't2',
|
||||||
|
actionType: 'started',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||||
|
),
|
||||||
|
ProgrammerTaskActivityLog(
|
||||||
|
id: 't2-paused',
|
||||||
|
taskId: 't2',
|
||||||
|
actionType: 'paused',
|
||||||
|
createdAt: DateTime.utc(2026, 9, 27, 9, 30),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
't3': [],
|
||||||
|
't4': [],
|
||||||
|
},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {'a': 'Alice Smith', 'b': 'Bob Jones'},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
// Expect: assignee t1 (3600), assignee t2 (1800), helper t3 (1200), helper t4 (600)
|
||||||
|
expect(rows.length, 4);
|
||||||
|
expect(rows[0].kind, 'assignee');
|
||||||
|
expect(rows[0].taskId, 't1');
|
||||||
|
expect(rows[0].seconds, 3600);
|
||||||
|
|
||||||
|
expect(rows[1].kind, 'assignee');
|
||||||
|
expect(rows[1].taskId, 't2');
|
||||||
|
expect(rows[1].seconds, 1800);
|
||||||
|
|
||||||
|
expect(rows[2].kind, 'helper');
|
||||||
|
expect(rows[2].taskId, 't3');
|
||||||
|
expect(rows[2].seconds, 1200);
|
||||||
|
expect(rows[2].helpedName, 'Bob Jones');
|
||||||
|
|
||||||
|
expect(rows[3].kind, 'helper');
|
||||||
|
expect(rows[3].taskId, 't4');
|
||||||
|
expect(rows[3].seconds, 600);
|
||||||
|
expect(rows[3].helpedName, 'Bob Jones');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tasks missing from task list are skipped', () {
|
||||||
|
// Work logs reference t1, but t1 is not in tasks list
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'a', 'work', workDate: day),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'a',
|
||||||
|
day: day,
|
||||||
|
tasks: [],
|
||||||
|
logsByTask: {},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helper logs with minutes=0 are ignored', () {
|
||||||
|
final task = _task('t1', 'a');
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'h', 'zero minutes', minutes: 0, workDate: day),
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'h',
|
||||||
|
day: day,
|
||||||
|
tasks: [task],
|
||||||
|
logsByTask: {'t1': []},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {'a': 'Ana Cruz'},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty notes are dropped from output', () {
|
||||||
|
final task = _task('t1', 'a');
|
||||||
|
final workLogs = [
|
||||||
|
_workLog('t1', 'a', ' ', workDate: day), // whitespace-only
|
||||||
|
_workLog('t1', 'a', 'Real note', workDate: day),
|
||||||
|
_workLog('t1', 'a', '', workDate: day), // empty
|
||||||
|
];
|
||||||
|
final rows = buildDayRows(
|
||||||
|
userId: 'a',
|
||||||
|
day: day,
|
||||||
|
tasks: [task],
|
||||||
|
logsByTask: {'t1': []},
|
||||||
|
workLogs: workLogs,
|
||||||
|
projectNames: {},
|
||||||
|
profileNames: {},
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(rows.length, 1);
|
||||||
|
expect(rows[0].notes, ['Real note']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('totalSeconds', () {
|
||||||
|
test('sums all row seconds', () {
|
||||||
|
final rows = [
|
||||||
|
DaySheetRow(
|
||||||
|
taskId: 't1',
|
||||||
|
title: 'Task 1',
|
||||||
|
category: 'Work',
|
||||||
|
kind: 'assignee',
|
||||||
|
seconds: 3600,
|
||||||
|
notes: [],
|
||||||
|
),
|
||||||
|
DaySheetRow(
|
||||||
|
taskId: 't2',
|
||||||
|
title: 'Task 2',
|
||||||
|
category: 'Work',
|
||||||
|
kind: 'helper',
|
||||||
|
seconds: 1800,
|
||||||
|
notes: [],
|
||||||
|
helpedName: 'Someone',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(totalSeconds(rows), 5400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty list returns 0', () {
|
||||||
|
expect(totalSeconds([]), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user