From f9b8831c712f0dbbc7432f516b08f7d6f74b13d9 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 21:04:23 +0800 Subject: [PATCH] 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 --- .../programmer_task_work_log.model.dart | 12 +- lib/utils/programmer_day_rows.dart | 160 +++++++ lib/widgets/quill_field.dart | 20 + test/programmer_day_rows_test.dart | 401 ++++++++++++++++++ 4 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 lib/utils/programmer_day_rows.dart create mode 100644 test/programmer_day_rows_test.dart diff --git a/lib/models/programmer_task_work_log.model.dart b/lib/models/programmer_task_work_log.model.dart index 39c28c33..3b4b7ba7 100644 --- a/lib/models/programmer_task_work_log.model.dart +++ b/lib/models/programmer_task_work_log.model.dart @@ -1,4 +1,5 @@ import '../utils/app_time.dart'; +import '../utils/programmer_daily_time.dart'; /// A work-log entry on a programmer task (plain, online-first model). /// @@ -13,6 +14,7 @@ class ProgrammerTaskWorkLog { final String description; final int? minutes; final DateTime createdAt; + final DateTime workDate; ProgrammerTaskWorkLog({ required this.id, @@ -21,16 +23,22 @@ class ProgrammerTaskWorkLog { required this.description, this.minutes, required this.createdAt, - }); + DateTime? workDate, + }) : workDate = workDate ?? manilaDay(createdAt); factory ProgrammerTaskWorkLog.fromMap(Map 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( id: map['id'].toString(), taskId: map['task_id'].toString(), authorId: map['author_id'].toString(), description: map['description'] as String? ?? '', minutes: (map['minutes'] as num?)?.toInt(), - createdAt: AppTime.parse(map['created_at'] as String), + createdAt: createdAt, + workDate: workDate, ); } } diff --git a/lib/utils/programmer_day_rows.dart b/lib/utils/programmer_day_rows.dart new file mode 100644 index 00000000..896bcf90 --- /dev/null +++ b/lib/utils/programmer_day_rows.dart @@ -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 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 toJson() => { + 'task_id': taskId, + 'title': title, + 'category': category, + 'project_name': projectName, + 'kind': kind, + 'helped_name': helpedName, + 'seconds': seconds, + 'notes': notes, + }; + + factory DaySheetRow.fromJson(Map 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.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 buildDayRows({ + required String userId, + required DateTime day, + required List tasks, + required Map> logsByTask, + required List workLogs, + required Map projectNames, + required Map profileNames, + required DateTime now, +}) { + final taskMap = {for (final t in tasks) t.id: t}; + final workLogsByTaskAndAuthor = >{}; + + // 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 = []; + + // 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(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 rows) { + return rows.fold(0, (sum, row) => sum + row.seconds); +} diff --git a/lib/widgets/quill_field.dart b/lib/widgets/quill_field.dart index 65597b65..64153cc4 100644 --- a/lib/widgets/quill_field.dart +++ b/lib/widgets/quill_field.dart @@ -3,6 +3,26 @@ import 'dart:convert'; import 'package:flutter/material.dart'; 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 /// an empty one. Callers own the controller and must dispose it. quill.QuillController quillControllerFromDelta(String? deltaJson) { diff --git a/test/programmer_day_rows_test.dart b/test/programmer_day_rows_test.dart new file mode 100644 index 00000000..60cb7639 --- /dev/null +++ b/test/programmer_day_rows_test.dart @@ -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? 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); + }); + }); +}