Co-Authored-By: claude-flow <ruv@ruv.net>
70 KiB
Programmer Oversight Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add daily Day-Sheet approvals (with remarks ↔ justification loop), a live admin Monitor, a PDF Accomplishment Report, and a gamified Mine dashboard to the Programmer Tasks module.
Architecture: Pure Dart utilities compute per-day worked time (clipped to Manila midnights, credited to the assignee at the time) and build "day rows"; every view (dashboard, monitor, day sheet, report) renders those rows. Day-sheet state lives in Supabase (programmer_day_sheets + append-only programmer_day_sheet_events), changed only through SECURITY DEFINER RPCs and triggers; a pg_cron job auto-submits past drafts. Approval stores a JSON snapshot so approved days are fixed records.
Tech Stack: Flutter 3.47.5 / Dart 3.13, Riverpod 2.6, supabase_flutter 2.x (Postgres, RLS, realtime, pg_cron, Deno edge functions), pdf 3.12 + printing 5.14 + pdfrx 2.6, flutter_quill 11.6.
Spec: docs/superpowers/specs/2026-09-27-programmer-oversight-design.md
Global Constraints
- Run Flutter on the host:
$FLUTTER <args>meansflatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter <args>'. - Never run
dart formaton existing files (the repo is not formatted; it causes diff churn). New files may be formatted individually. - Stage files explicitly (
git add <paths>). The working tree has unrelated modified files (.claude/,.swarm/,ruvector.db,.mcp.json) — nevergit add -A/git add .. Every commit message ends withCo-Authored-By: claude-flow <ruv@ruv.net>. - Supabase DDL and edge-function deploys are done by the user. Do not call
mcp__supabase__apply_migrationordeploy_edge_function; read-only checks viamcp__supabase__execute_sqlinsidebegin; … rollback;are fine. - Time zone is Asia/Manila via
AppTime. Day keys areDateTime.utc(y, m, d)of the Manila calendar date, produced only bymanilaDay()/parseDayKey()(Task 1). DBdatevalues areyyyy-MM-ddstrings viaformatDayKey().ReportDateRangeuses localDateTime(y, m, d)with an exclusiveend; convert withDateTime.utc(r.start.year, r.start.month, r.start.day). - "Admin" =
profiles.role == 'admin'viaisStrictAdminProvider(Task 10) and SQLrole = 'admin'. Never useisAdminProvider(it includes programmers) for approval or monitor gating. - Status color only via
AppStatusColors/StatusKind(success, warning, danger, info, neutral) /StatusPill.kind. Section titlesAppSectionHeader(title, trailing:); KPI tilesAppMetricCard(title:, value:, icon:, trailing:); codes/durationsTechChip; cardsM3Card. No hardcoded status hex. - Every new file < 500 lines.
- Day sheets and work logs are online-only. When
isOnlineProvideris false, day-sheet actions are disabled and a banner reads:You're offline — day sheet actions need a connection. - Tests live in
test/, callsetUpAll(AppTime.initialize), and follow the fake-client pattern oftest/programmer_tasks_controller_test.dart(controller constructed withfake as dynamic). Widget tests wrap inProviderScope+MaterialApp(theme: AppTheme.light()). - Known unrelated failures when running the full suite:
theme_overhaul_test,profile_screen_test,user_management_screen_test, and time-of-day-dependent cases indashboard_metrics_provider_test.
Review Focus
- Device time zone ≠ Manila (browser set to UTC): an event at
2026-09-26T16:30Zbelongs to Manila day 2026-09-27, and a day sheet's time never leaks across Manila midnight. → Task 1 testmanilaDay ignores device time zone. - Legacy plain-text work-log descriptions (stored before Quill, not Delta JSON): shown verbatim in rows, sheet, and PDF — never blank. → Task 2 test
legacy plain text notes pass through. - Offline-replayed task with no
startedlog butstarted_atset: its time is still counted fromstarted_at. → Task 1 testfalls back to task.startedAt. - Concurrent review — two admins act on one sheet, or a bulk approval includes a sheet someone already reviewed: single actions fail with "This sheet was already reviewed."; bulk approves the rest and reports the skipped ones. → Task 14 tests + Task 12 SQL checklist items 5 and 8.
- Report period with zero approved days: the PDF still renders the letterhead, the line
No approved accomplishments in this period., and the excluded-days footnote. → Task 21 testempty period, Task 22 test.
Part A — Slice 1: Mine dashboard (no schema change)
Task 1: Day keys and per-day time split
Files:
- Create:
lib/utils/programmer_daily_time.dart - Test:
test/programmer_daily_time_test.dart
Interfaces:
-
Consumes:
ProgrammerTask(lib/models/programmer_task.model.dart),ProgrammerTaskActivityLog(actionType,meta,createdAt),AppTime.fromComponents. -
Produces:
DateTime manilaDay(DateTime instant); // DateTime.utc(y,m,d) of the Manila date DateTime manilaDayStart(DateTime dayKey); // Manila midnight instant (TZDateTime) DateTime parseDayKey(String yyyyMmDd); // '2026-09-27' -> DateTime.utc(2026,9,27) String formatDayKey(DateTime dayKey); // -> '2026-09-27' typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end}); List<WorkedInterval> workedIntervals({required ProgrammerTask task, required List<ProgrammerTaskActivityLog> logs, required DateTime now}); /// assigneeId -> dayKey -> seconds (adjustments applied, each bucket >= 0) Map<String, Map<DateTime, int>> dailySecondsByAssignee({required ProgrammerTask task, required List<ProgrammerTaskActivityLog> logs, required DateTime now}); -
Step 1: Write the failing tests. Build fixtures with a helper
_ev(String action, int d, int h, [int m = 0, Map<String, dynamic>? meta])creating a log at Manila2026-09-d h:m(viaAppTime.fromComponents), and a helper_task({status, creatorId, assigneeId, startedAt}).test('manilaDay ignores device time zone', () { expect(manilaDay(DateTime.parse('2026-09-26T16:30:00Z')), DateTime.utc(2026, 9, 27)); expect(manilaDay(DateTime.parse('2026-09-26T15:59:00Z')), DateTime.utc(2026, 9, 26)); }); test('day key round-trip', () { expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27'); }); test('single day with a pause', () { // started 09:00, paused 11:00, resumed 13:00, completed 15:00 on Sep 27; creator=assignee='a' expect(dailySecondsByAssignee(task: t, logs: logs, now: now), {'a': {DateTime.utc(2026, 9, 27): 4 * 3600}}); }); test('running across Manila midnight splits by day', () { // started Sep 26 22:00, status in_progress, now = Sep 27 02:00 expect(result['a'], {DateTime.utc(2026, 9, 26): 7200, DateTime.utc(2026, 9, 27): 7200}); }); test('reassignment credits each assignee for their own span', () { // creator 'a', current assignee 'b'; started 09:00, reassigned {assignee_id:'b'} 10:00, completed 12:00 expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); expect(result['b']![DateTime.utc(2026, 9, 27)], 7200); }); test('initial assignee comes from the first assigned event', () { // creator 'boss', assigned {assignee_id:'a'}, started 09:00, completed 10:00 expect(result.keys, ['a']); }); test('adjustment subtracts on the day created and clamps at zero', () { // started 09:00, adjustment {seconds:1800} 10:00, completed 11:00 -> 5400 // second case: adjustment {seconds: 99999} -> 0 }); test('falls back to task.startedAt when there is no started log', () { // no 'started' log, task.startedAt = Sep 27 09:00, completed 10:00 -> 3600 }); test('a paused in-progress task stops accruing at the pause', () { // started 09:00, paused 10:00, status in_progress, now 15:00 -> 3600 }); -
Step 2: Run to verify failure. Run:
$FLUTTER test test/programmer_daily_time_test.dart— Expected: FAIL (file/functions not defined). -
Step 3: Implement. Rules the tests don't fully pin:
- Walk logs in ascending
createdAtorder (input may be any order). - Initial assignee = meta
assignee_idof the firstassignedevent; else, if anyreassignedevent exists,task.creatorId; elsetask.assigneeId. started/resumedopen a segment (if none open);paused/completed/cancelledclose it;reassignedcloses the open segment for the old assignee and, if it was open, reopens it formeta['assignee_id'].- If no
startedevent exists andtask.startedAt != null, treatstartedAtas a syntheticstarted. - An open segment at the end closes at
nowonly whentask.status == in_progress; otherwise atcompletedAt ?? cancelledAt ?? now. - Split each interval at Manila midnights using
manilaDayStart(nextDayKey); adjustments subtractmeta['seconds']from the bucket of the assignee-at-that-time onmanilaDay(event.createdAt); clamp each bucket to ≥ 0.
- Walk logs in ascending
-
Step 4: Run to verify pass. Run:
$FLUTTER test test/programmer_daily_time_test.dart— Expected: all PASS. -
Step 5: Commit.
git add lib/utils/programmer_daily_time.dart test/programmer_daily_time_test.dart git commit -m "feat(programmer): per-day worked time split with assignee attribution Co-Authored-By: claude-flow <ruv@ruv.net>"
Task 2: Day rows, Quill plain text, work-log workDate
Files:
- Create:
lib/utils/programmer_day_rows.dart - Modify:
lib/widgets/quill_field.dart(add a top-level function),lib/models/programmer_task_work_log.model.dart(addworkDate) - Test:
test/programmer_day_rows_test.dart
Interfaces:
-
Consumes: Task 1 (
manilaDay,parseDayKey,dailySecondsByAssignee). -
Produces:
// quill_field.dart String deltaJsonToPlainText(String? stored); // Delta JSON -> trimmed text; non-JSON -> stored.trim(); null -> '' // programmer_task_work_log.model.dart final DateTime workDate; // ctor param `DateTime? workDate` -> `workDate ?? manilaDay(createdAt)`; // fromMap: map['work_date'] != null ? parseDayKey(...) : manilaDay(createdAt) // programmer_day_rows.dart class DaySheetRow { final String taskId, title, category, kind; // kind: 'assignee' | 'helper' final String? projectName, helpedName; final int seconds; final List<String> notes; Map<String, dynamic> toJson(); // keys: task_id,title,category,project_name,kind,helped_name,seconds,notes factory DaySheetRow.fromJson(Map<String, dynamic> json); } 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}); int totalSeconds(List<DaySheetRow> rows); -
Step 1: Write the failing tests.
test('assignee row: that day seconds + that day notes only', ...); // seconds from Task 1 split; a note from Sep 26 excluded test('helper row: minutes*60 and helpedName', () { // work log by 'h' on task assigned to 'a', minutes 45, Sep 27 expect(row.kind, 'helper'); expect(row.seconds, 2700); expect(row.helpedName, 'Ana Cruz'); }); test('notes-only row when the assignee logged but ran no timer', () { expect(row.seconds, 0); }); test('legacy plain text notes pass through', () { expect(deltaJsonToPlainText('fixed bug'), 'fixed bug'); expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello'); expect(deltaJsonToPlainText(null), ''); }); test('rows sort: assignee by seconds desc, then helpers by seconds desc', ...); test('DaySheetRow json round-trip', () { expect(DaySheetRow.fromJson(r.toJson()).toJson(), r.toJson()); }); test('workDate falls back to the Manila day of createdAt', () { expect(ProgrammerTaskWorkLog.fromMap({...base, 'created_at': '2026-09-26T16:30:00Z'}).workDate, DateTime.utc(2026, 9, 27)); expect(ProgrammerTaskWorkLog.fromMap({...base, 'work_date': '2026-09-25'}).workDate, DateTime.utc(2026, 9, 25)); }); -
Step 2: Run to verify failure. Run:
$FLUTTER test test/programmer_day_rows_test.dart— Expected: FAIL. -
Step 3: Implement. Row rules: an assignee row exists for a task when
dailySecondsByAssignee(...)[userId]?[day] > 0oruserIdauthored aminutes == nullwork log on it withworkDate == day. A helper row exists for a task whenuserId != task.assigneeIdanduserIdauthored work logs withminutes > 0andworkDate == day;seconds = sum(minutes) * 60,helpedName = profileNames[task.assigneeId].notes=deltaJsonToPlainTextof that user's work logs on that task that day, oldest first, empty strings dropped. Tasks missing fromtasksare skipped. -
Step 4: Run to verify pass. Run:
$FLUTTER test test/programmer_day_rows_test.dart test/programmer_task_ledger_test.dart— Expected: PASS (ledger test proves the model change is backward compatible). -
Step 5: Commit
lib/utils/programmer_day_rows.dart lib/widgets/quill_field.dart lib/models/programmer_task_work_log.model.dart test/programmer_day_rows_test.dart— messagefeat(programmer): day rows builder + work-log workDate.
Task 3: Day activity data, run states, clock ticker
Files:
- Create:
lib/providers/programmer_day_data_provider.dart - Test:
test/programmer_day_data_test.dart
Interfaces:
-
Consumes: Task 1, Task 2 (
ProgrammerTaskWorkLog.fromMap),programmerTasksProvider,supabaseClientProvider. -
Produces:
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled } typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since}); Map<String, ProgrammerRunInfo> computeRunStates(List<ProgrammerTask> tasks, Map<String, List<ProgrammerTaskActivityLog>> logsByTask); // pure class DayActivityData { final DateTime day; final List<ProgrammerTask> tasks; final Map<String, List<ProgrammerTaskActivityLog>> logsByTask; final List<ProgrammerTaskWorkLog> workLogs; } Future<DayActivityData> fetchDayActivityData(dynamic client, {required DateTime day, required List<ProgrammerTask> tasks, required DateTime now}); final clockTickerProvider = StreamProvider<DateTime>(...); // AppTime.now() immediately, then every 30 s final todayKeyProvider = Provider<DateTime>(...); // manilaDay(ticker value ?? AppTime.now()) final programmerActivitySignalProvider = StreamProvider<int>(...); // +1 per realtime INSERT on either log table final programmerDayDataProvider = FutureProvider.family<DayActivityData, DateTime>(...); // key = day key final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>(...); // from today's day data -
Step 1: Write the failing tests. Define a local
_FakeClientin the test following the controller-test pattern, supportingselect,eq,inFilter,gte,lt,order.test('computeRunStates: queued -> notStarted; latest paused -> paused since pause time; resumed -> running; completed', ...); test('fetchDayActivityData includes in-progress tasks and tasks completed that day', ...); test('fetchDayActivityData excludes a task completed two days earlier', ...); test('fetchDayActivityData includes a task referenced only by a helper work log that day', ...); -
Step 2: Run to verify failure. Run:
$FLUTTER test test/programmer_day_data_test.dart— Expected: FAIL. -
Step 3: Implement. Candidate tasks for a day =
status == in_progress, orstartedAt != null && startedAt < dayEnd && (completedAt ?? cancelledAt ?? now) >= dayStart. Work logs:programmer_task_work_logswithcreated_at >= dayStart.toUtc()and< dayEnd.toUtc()(Task 15 switches this towork_date); theirtask_ids join the candidates. Activity logs: oneselect … inFilter('task_id', ids) order created_at desc, grouped by task. Run state:in_progresswhose latest ofstarted/paused/resumedispaused→paused(since= that event); otherwiserunning(since= latest started/resumed, elsestartedAt). The signal provider subscribes one channelprogrammer_activity_signalwith twoonPostgresChanges(event: PostgresChangeEvent.insert, …)handlers and removes it on dispose.programmerDayDataProviderwatchesprogrammerTasksProvider,programmerActivitySignalProvider,supabaseClientProvider. -
Step 4: Run to verify pass. Run:
$FLUTTER test test/programmer_day_data_test.dart— Expected: PASS. -
Step 5: Commit —
feat(programmer): day activity data + run-state provider.
Task 4: Day-sheet model, stats, streak, badges (pure)
Files:
- Create:
lib/models/programmer_day_sheet.model.dart,lib/utils/programmer_stats.dart - Test:
test/programmer_stats_test.dart
Interfaces:
-
Consumes: Task 1 (
parseDayKey,manilaDay), Task 2 (DaySheetRow). -
Produces:
class DaySheetStatus { static const draft = 'draft', pending = 'pending', disapproved = 'disapproved', approved = 'approved'; static String label(String s); } // 'Draft' | 'Pending' | 'Disapproved' | 'Approved' class DaySheetSnapshot { final int totalSeconds; final List<DaySheetRow> rows; Map<String, dynamic> toJson(); // {'v':1,'total_seconds':..,'rows':[..]} factory DaySheetSnapshot.fromJson(Map<String, dynamic> j); factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows); } class ProgrammerDaySheet { final String id, programmerId, status; final DateTime workDate; // day key final DateTime? submittedAt, reviewedAt; final bool autoSubmitted; final String? reviewedBy; final int resubmissions; final DaySheetSnapshot? approvedSnapshot; final DateTime createdAt, updatedAt; factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m); bool get isFirstPassApproved; } // status == approved && resubmissions == 0 class ProgrammerStats { final Duration todayFocus, helpGivenThisMonth, approvedTotal, helpGivenTotal; final int completedThisWeek, completedLastWeek, currentStreak, bestStreak, completedTotal, bugFixCompleted; final double? firstPassRate; final bool hasCleanWeek; } ProgrammerStats computeProgrammerStats({required String userId, required DateTime now, required List<ProgrammerTask> tasks, required List<ProgrammerDaySheet> sheets, required Set<DateTime> leaveDays, required Duration todayFocus}); ({int current, int best}) computeStreak({required List<ProgrammerDaySheet> sheets, required Set<DateTime> leaveDays, required DateTime today}); class BadgeProgress { final String id, title, description; final IconData icon; final int current, target; final bool earned; } List<BadgeProgress> computeBadges(ProgrammerStats stats, {required bool includeSheetBadges}); -
Step 1: Write the failing tests. (2026-09-27 is a Sunday; Mon 21 … Fri 25, Mon 28.)
test('fromMap parses work_date and snapshot', ...); test('streak counts weekdays back from today, skipping weekends', () { // today Mon 28 (no sheet); Wed 23, Thu 24, Fri 25 pending/approved expect(computeStreak(sheets: s, leaveDays: {}, today: DateTime.utc(2026, 9, 28)).current, 3); }); test('a disapproved day breaks the streak', () { /* Fri 25 disapproved -> current 0 */ }); test('approved leave days are neutral', () { /* Thu 24 in leaveDays, no sheet; Wed 23 + Fri 25 approved -> 2 */ }); test('best streak finds the longest earlier run', () { /* earlier run of 4 -> best 4 */ }); test('first-pass rate over 30 days', () { // approved(r=0), approved(r=0), approved(r=1), disapproved -> 0.5 ; none reviewed -> null }); test('completedThisWeek uses a Monday-start week', ...); test('tiered badge shows highest earned tier and next target', () { final b = computeBadges(statsWith(completedTotal: 12), includeSheetBadges: false) .firstWhere((b) => b.id == 'finisher'); expect(b.earned, isTrue); expect(b.title, 'Finisher · 10'); expect(b.target, 50); }); test('includeSheetBadges false hides sheet-based badges', () { expect(ids, isNot(containsAll(['deep_work', 'on_a_roll', 'team_player', 'clean_week']))); }); -
Step 2: Run to verify failure. Run:
$FLUTTER test test/programmer_stats_test.dart— Expected: FAIL. -
Step 3: Implement. Pinned rules:
- Streak: a day qualifies if its sheet is
pendingorapproved. Walk back fromtoday: a non-qualifyingtodayis skipped (not a break); Sat/Sun andleaveDaysare skipped; a qualifying day counts; any other day stops. Best = longest run scanning from the earliest sheet day to today with the same neutral rules. - Weeks start Monday.
completedThisWeek= tasks withassigneeId == userId,status == completed,completedAtin this week. - firstPassRate: sheets with
workDatein the last 30 days (today−29 … today); reviewed = approved ∨ disapproved ∨resubmissions > 0; null when none reviewed. - Approved sums from
approvedSnapshot:approvedTotal= Σ totalSeconds;helpGivenTotal/helpGivenThisMonth= Σ seconds ofkind == 'helper'rows (month ofworkDate). - hasCleanWeek: some Mon–Fri where every weekday is in
leaveDaysorisFirstPassApproved, with ≥ 1 approved. - Badges (tiers ascending;
target= lowest unreached tier or top tier when all reached;earned= current ≥ first tier; tiered earned title ='<Title> · <highest reached tier>'):id title tiers current sheet-based icon description first_finish First Finish 1 completedTotal no Icons.flag_outlinedComplete your first task finisher Finisher 10/50/100 completedTotal no Icons.task_altComplete 10 / 50 / 100 tasks bug_squasher Bug Squasher 10 bugFixCompleted no Icons.bug_report_outlinedComplete 10 Bug Fix tasks deep_work Deep Work 100/500 approvedTotal.inHours yes Icons.timer_outlinedLog 100 / 500 approved hours on_a_roll On a Roll 5/10/20 bestStreak yes Icons.local_fire_department_outlinedReach a 5 / 10 / 20-day streak team_player Team Player 10 helpGivenTotal.inHours yes Icons.handshake_outlinedGive 10 hours of help to teammates clean_week Clean Week 1 hasCleanWeek ? 1 : 0 yes Icons.verified_outlinedA Mon–Fri week with every day approved first time
- Streak: a day qualifies if its sheet is
-
Step 4: Run to verify pass. Run:
$FLUTTER test test/programmer_stats_test.dart— Expected: PASS. -
Step 5: Commit —
feat(programmer): day-sheet model + stats, streak and badges.
Task 5: Shared task card with run-state pill; shared complete action
Files:
- Create:
lib/screens/programmer_tasks/widgets/programmer_task_card.dart - Modify:
lib/screens/programmer_tasks/programmer_tasks_list_screen.dart(delete_TaskCard, use the new card),lib/screens/programmer_tasks/programmer_task_actions.dart(receive moved function),lib/screens/programmer_tasks/programmer_task_detail_screen.dart(_complete→ call moved function) - Test:
test/programmer_task_card_test.dart
Interfaces:
-
Consumes: Task 3 (
programmerRunStatesProvider,ProgrammerRunState). -
Produces:
class ProgrammerTaskCard extends ConsumerWidget { const ProgrammerTaskCard({super.key, required this.task, this.assignee, required this.isMine, this.readOnly = false}); } Future<void> completeProgrammerTaskWithConfirm(BuildContext context, WidgetRef ref, ProgrammerTask task); // body moved verbatim from detail `_complete` -
Step 1: Write the failing test. Override
programmerRunStatesProviderwith{'t1': (state: ProgrammerRunState.paused, since: …)}.testWidgets('paused pill shows for any assignee; Resume only for mine and not read-only', (tester) async { // isMine: false -> find.text('Paused') findsOneWidget, find.text('Resume') findsNothing // isMine: true -> find.text('Resume') findsOneWidget // isMine: true, readOnly: true -> find.text('Resume') findsNothing }); -
Step 2: Run to verify failure.
$FLUTTER test test/programmer_task_card_test.dart— FAIL. -
Step 3: Implement — same layout as the old
_TaskCard; pill =StatusPill.kind(StatusKind.warning, label: 'Paused')when run state is paused, elseprogrammerTaskStatusKind(task.status)+ProgrammerTaskStatus.label. Start/Resume shown only whenisMine && !readOnly. -
Step 4: Verify.
$FLUTTER test test/programmer_task_card_test.dart test/programmer_tasks_controller_test.dart— PASS;$FLUTTER analyze lib— no new issues. -
Step 5: Commit —
refactor(programmer): shared task card driven by run states.
Task 6: ProgrammerDashboard — hero, sections, Mine tab
Files:
- Create:
lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart,dashboard/now_working_card.dart,dashboard/task_sections.dart - Modify:
lib/screens/programmer_tasks/programmer_tasks_list_screen.dart(Mine tab body →ProgrammerDashboard(userId: me)) - Test:
test/programmer_dashboard_sections_test.dart
Interfaces:
-
Consumes: Tasks 1, 3, 5 (
dailySecondsByAssignee,programmerDayDataProvider,programmerRunStatesProvider,todayKeyProvider,clockTickerProvider,ProgrammerTaskCard,completeProgrammerTaskWithConfirm),startProgrammerTaskWithPausePrompt,programmerTasksControllerProvider.pauseTask. -
Produces:
class ProgrammerDashboard extends ConsumerWidget { const ProgrammerDashboard({super.key, required this.userId, this.readOnly = false}); } typedef TaskSectionGroups = ({List<ProgrammerTask> running, List<ProgrammerTask> paused, List<ProgrammerTask> notStarted, List<ProgrammerTask> completed, List<ProgrammerTask> cancelled}); TaskSectionGroups groupTasksForDashboard(List<ProgrammerTask> tasks, Map<String, ProgrammerRunInfo> runStates, String userId); // only tasks assigned to userId class TaskSections extends StatelessWidget { const TaskSections({super.key, required this.groups, required this.profileById, required this.readOnly}); } class NowWorkingCard extends ConsumerWidget { const NowWorkingCard({super.key, required this.running, required this.nextUp, required this.todayOnTask, required this.totalOnTask, required this.readOnly}); } -
Step 1: Write the failing tests.
test('groupTasksForDashboard splits by run state and excludes other assignees', ...); test('completed sorted by completedAt desc, cancelled by cancelledAt desc', ...); testWidgets('Completed and Cancelled start collapsed', (t) async { // 12 completed: header 'Completed' visible, first completed title not visible // tap header -> 10 titles visible + text 'Show all (12)' }); testWidgets('empty sections are hidden', ...); // no 'Cancelled' header when none -
Step 2: Run to verify failure.
$FLUTTER test test/programmer_dashboard_sections_test.dart— FAIL. -
Step 3: Implement. Section headers
AppSectionHeader('<Name>', trailing: Text('<count>'))with namesIn progress,Paused,Not started,Completed,Cancelled. When all groups are empty show the existing empty view (No tasks assigned to you). Hero: running task → title,TechChipcategory,Today <h m> · Total <h m>(formatDurationHm; today =dailySecondsByAssignee(...)[userId][today], total = sum over all days foruserId), buttons Pause (pauseTask(taskId:)) and Complete (completeProgrammerTaskWithConfirm). Nothing running → textNothing runningand, ifnextUp != null, Start viastartProgrammerTaskWithPausePrompt.readOnlyhides all buttons. Live values re-render onclockTickerProvider. -
Step 4: Verify.
$FLUTTER test test/programmer_dashboard_sections_test.dart— PASS;$FLUTTER analyze lib— clean. -
Step 5: Commit —
feat(programmer): Mine dashboard hero + task sections.
Task 7: Staff presence helpers (leave / pass slip)
Files:
- Create:
lib/utils/staff_presence.dart - Modify:
lib/screens/dashboard/dashboard_screen.dart:371-386(replace the two inline indexing loops with calls) - Test:
test/staff_presence_test.dart
Interfaces:
- Produces:
Map<String, LeaveOfAbsence> activeLeaveByUser(List<LeaveOfAbsence> leaves, DateTime now); // status approved && start <= now < end Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips); // slip.isActive Set<DateTime> approvedLeaveDays(List<LeaveOfAbsence> leaves, String userId); // Manila day keys covered - Step 1: Failing tests — approved leave spanning
nowincluded, pending excluded; active pass slip included;approvedLeaveDaysfor a leave Sep 24 08:00 → Sep 25 17:00 =={utc(2026,9,24), utc(2026,9,25)}. - Step 2:
$FLUTTER test test/staff_presence_test.dart— FAIL. - Step 3: Implement and swap the dashboard loops (behavior unchanged).
- Step 4:
$FLUTTER test test/staff_presence_test.dart test/dashboard_metrics_provider_test.dart— PASS (ignore the known time-of-day cases);$FLUTTER analyze lib— clean. - Step 5: Commit —
refactor: extract staff presence helpers.
Task 8: Stat tiles and badges strip
Files:
- Create:
lib/screens/programmer_tasks/dashboard/stat_tiles.dart,dashboard/badges_strip.dart - Modify:
dashboard/programmer_dashboard.dart(insert tiles + badges between hero and sections) - Test:
test/programmer_stat_tiles_test.dart
Interfaces:
- Consumes: Task 4 (
computeProgrammerStats,computeBadges), Task 7 (approvedLeaveDays),leavesProvider,profilesProvider. - Produces:
class StatTiles extends StatelessWidget { const StatTiles({super.key, required this.stats, required this.showSheetStats}); } class BadgesStrip extends StatelessWidget { const BadgesStrip({super.key, required this.badges}); } - Step 1: Failing tests.
testWidgets('sheet stats hidden when showSheetStats is false', ...); // find.text('STREAK') findsNothing testWidgets('streak tile shows days and best', ...); // '6 days', 'Best 9' testWidgets('locked badge shows progress', ...); // '7/10' - Step 2:
$FLUTTER test test/programmer_stat_tiles_test.dart— FAIL. - Step 3: Implement. Tiles (
AppMetricCard,Wrap), exact copy:TODAY'S FOCUS— valueformatDurationHm(todayFocus), trailing: a 40 pxCircularProgressIndicator(value: min(1, focus / 8h))with/ 8hlabel (patternreports/widgets/conversion_rate_card.dart:43-55).COMPLETED THIS WEEK— value count; trailing↑N vs last week/↓N vs last week/Same as last week.STREAK— iconIcons.local_fire_department_outlined, valueN days(1 day), trailingBest M.FIRST-PASS APPROVAL— valueNN%or—, trailingLast 30 days.HELP GIVEN— valueformatDurationHm, trailingThis month.showSheetStats= the dashboard user's role isprogrammer; when false only the first two tiles render andcomputeBadges(includeSheetBadges: false).- Badges: horizontal list of chips; earned =
AppStatusColorssuccess container; locked = neutral +current/target; tap →AlertDialogwith title + description. - Dashboard feeds
sheets: const <ProgrammerDaySheet>[]until Task 20 wiresdaySheetsForUserProvider;todayFocus=totalSeconds(buildDayRows(userId, today, …)).
- Step 4:
$FLUTTER test test/programmer_stat_tiles_test.dart— PASS;$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(programmer): dashboard stat tiles + badges.
Slice 1 checkpoint: $FLUTTER test (only known failures) and $FLUTTER build web succeed.
Part B — Slice 2: Admin Monitor
Task 9: Monitor view-model (pure)
Files:
- Create:
lib/screens/programmer_tasks/monitor/monitor_model.dart - Test:
test/programmer_monitor_model_test.dart
Interfaces:
- Consumes: Tasks 2, 3, 7.
- Produces:
enum MonitorState { working, paused, idle, onLeave, passSlip } class HelpEdge { final String helperId, assigneeId, taskId, taskTitle; final int minutes; final DateTime at; } List<HelpEdge> buildHelpEdges({required List<ProgrammerTaskWorkLog> workLogs, required Map<String, ProgrammerTask> taskById, required DateTime day}); // author != assignee, minutes > 0, workDate == day; newest first class ProgrammerMonitorEntry { final Profile programmer; final MonitorState state; final ProgrammerTask? currentTask; final Duration currentTaskToday, todayTotal; final int inProgress, paused, notStarted; final String? sheetStatus; final List<HelpEdge> helped, helpedBy; } List<ProgrammerMonitorEntry> buildMonitorEntries({required List<Profile> programmers, required DayActivityData today, required Map<String, ProgrammerRunInfo> runStates, required Map<String, LeaveOfAbsence> leaves, required Map<String, PassSlip> passSlips, required Map<String, String> sheetStatusByUser, required Map<String, String> projectNames, required Map<String, String> profileNames, required DateTime now}); - Step 1: Failing tests — leave overrides a running task (
onLeave); pass slip →passSlip; running →workingwith thatcurrentTask; only paused →pausedwith the most recentsince; none →idle; sort order working, paused, idle, passSlip, onLeave, then name;helped/helpedByedges;todayTotalequalstotalSeconds(buildDayRows(...)). - Step 2:
$FLUTTER test test/programmer_monitor_model_test.dart— FAIL. - Step 3: Implement (counts:
inProgress= running tasks,paused,notStarted= queued, all for tasks assigned to that programmer). - Step 4: same command — PASS.
- Step 5: Commit —
feat(programmer): monitor view-model.
Task 10: Strict admin, role tabs, Monitor tab, drill-down
Files:
- Modify:
lib/providers/profile_provider.dart(add provider),lib/screens/programmer_tasks/programmer_tasks_list_screen.dart(role tabs +initialTab),lib/routing/app_router.dart(querytab, routeprogrammers/:userIdplaced before:id) - Create:
lib/screens/programmer_tasks/monitor/monitor_tab.dart,monitor/programmer_monitor_card.dart,monitor/help_panel.dart,lib/screens/programmer_tasks/programmer_drill_down_screen.dart,lib/screens/programmer_tasks/programmer_tasks_tabs.dart - Test:
test/programmer_tabs_test.dart
Interfaces:
- Consumes: Tasks 6, 7, 9.
- Produces:
final isStrictAdminProvider = Provider<bool>(...); // currentProfileProvider role == 'admin' enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets } List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}); ProgrammerTasksTab? parseProgrammerTasksTab(String? q); // 'monitor'|'approvals'|'mine'|'all'|'day-sheets' String programmerTasksTabLabel(ProgrammerTasksTab t); // 'Monitor'|'Approvals'|'Mine'|'All'|'Day Sheets' class ProgrammerTasksListScreen { const ProgrammerTasksListScreen({super.key, this.initialTab}); final String? initialTab; } class ProgrammerDrillDownScreen extends ConsumerWidget { const ProgrammerDrillDownScreen({super.key, required this.userId}); } // route /programmer-tasks/programmers/:userId class ProgrammerMonitorCard extends StatelessWidget { const ProgrammerMonitorCard({super.key, required this.entry, required this.onTap}); } - Step 1: Failing tests.
test('tabs in this slice', () { expect(programmerTasksTabsFor(isStrictAdmin: true), [ProgrammerTasksTab.monitor, ProgrammerTasksTab.mine, ProgrammerTasksTab.all]); expect(programmerTasksTabsFor(isStrictAdmin: false), [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]); }); test('parse tab query', () { expect(parseProgrammerTasksTab('day-sheets'), ProgrammerTasksTab.daySheets); expect(parseProgrammerTasksTab('x'), isNull); }); testWidgets('monitor card pill labels', ...); // onLeave -> 'On leave', passSlip -> 'Pass slip', working -> 'Working' - Step 2:
$FLUTTER test test/programmer_tabs_test.dart— FAIL. - Step 3: Implement.
- Pills: working → success
Working; paused → warningPaused; idle → neutralIdle; onLeave → infoOn leave; passSlip → infoPass slip. - Monitor tab:
AppStatusSummaryRowwithWorking,Paused,Idle,Away(leave + pass slip) counts; grid columns by width (< 700 → 1, < 1100 → 2, else 3). Card body per spec §7 (current task +running <h m> today,Today <h m>, counts, help chipsHelped → <name> · <task> · <m>mandHelped by ← <name> · <m>m). Tap →context.go('/programmer-tasks/programmers/<id>'). - Help panel:
AppSectionHeader("Who's helping whom"), day stepper (← date →, cannot pass today), rows<helper> → <assignee> · <task> · <m>m · <time>, fromprogrammerDayDataProvider(day); empty →No help logged this day. - Drill-down: AppBar title = programmer name; body
ProgrammerDashboard(userId, readOnly: true). Non-admins seeAppEmptyView(icon: Icons.lock_outline, title: 'Admins only'). - The list screen builds its
TabControllerfromprogrammerTasksTabsFor, selectsparseProgrammerTasksTab(initialTab)if present in the list; router passesstate.uri.queryParameters['tab'].
- Pills: working → success
- Step 4:
$FLUTTER test test/programmer_tabs_test.dart— PASS;$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(programmer): admin monitor tab + drill-down.
Slice 2 checkpoint: full $FLUTTER test + $FLUTTER build web; QA harness as admin shows the Monitor with Working/Paused/Idle.
Part C — Slice 3: Day Sheets
Task 11: Migration — schema, triggers, RLS
Files:
- Create:
supabase/migrations/20260927150000_add_programmer_day_sheets.sql(the spec's single migration is split into this file + Task 12's file for reviewability)
Interfaces:
-
Produces (DB):
manila_today() returns date; tablesprogrammer_day_sheets,programmer_day_sheet_eventsexactly as spec §5.2 (checks included; events body non-empty fordisapproved/justified);programmer_task_work_logs.work_date;notifications.day_sheet_id;ensure_day_sheet(p_programmer uuid, p_date date) returns uuid; error codework_date_locked. -
Step 1: Write the SQL. Every function
set search_path = public. Pinned behaviors:manila_today()=(now() at time zone 'Asia/Manila')::date,stable.- Tables +
unique(programmer_id, work_date), index(status, work_date);updated_attrigger reusingset_programmer_tasks_updated_at(). work_date: add nullable → backfill(created_at at time zone 'Asia/Manila')::date→set default manila_today()→set not null; index(author_id, work_date).ensure_day_sheet(SECURITY DEFINER;revoke execute … from public, anon, authenticated): returns null unless the profile's role isprogrammer; insertsdrafton conflict do nothing; if the row isapprovedandp_date = manila_today()→ setpendingand insert eventkind='amended',actor_id null,body 'New work was logged after approval.'.- BEFORE INSERT on work logs (SECURITY DEFINER):
work_date := coalesce(work_date, manila_today()); future date →raise exception 'work_date_locked'; past date allowed only when the author's sheet for that date has statuspendingordisapproved, elseraise exception 'work_date_locked'. - AFTER INSERT on work logs →
ensure_day_sheet(new.author_id, new.work_date). - AFTER INSERT on
programmer_task_activity_logswhenaction_type in ('started','paused','resumed','completed','cancelled','adjustment')→ensure_day_sheet(<task's assignee_id>, (new.created_at at time zone 'Asia/Manila')::date)if assignee not null. - RLS on both tables. Sheets SELECT:
programmer_id = auth.uid() or exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin'). Events SELECT:exists (select 1 from programmer_day_sheets s where s.id = sheet_id). No INSERT/UPDATE/DELETE policies. alter table notifications add column if not exists day_sheet_id uuid references programmer_day_sheets(id) on delete cascade+ index.alter publication supabase_realtime add table programmer_day_sheets, programmer_day_sheet_events;
-
Step 2: Self-check. Re-read against spec §5.2–5.4; confirm every trigger function that writes sheets is SECURITY DEFINER.
-
Step 3: Commit —
feat(db): programmer day sheets schema, triggers, RLS. (Applied together with Task 12.)
Task 12: Migration — RPCs, auto-submit cron, digest queue; apply + SQL checklist
Files:
- Create:
supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql
Interfaces:
-
Consumes: Task 11.
-
Produces (all SECURITY DEFINER,
set search_path = public,grant execute … to authenticatedexceptauto_submit_day_sheets):submit_day_sheet(p_date date) returns jsonb disapprove_day_sheet(p_sheet uuid, p_remarks text, p_flagged_task_ids uuid[] default '{}') returns jsonb justify_day_sheet(p_sheet uuid, p_body text) returns jsonb approve_day_sheets(p_items jsonb) returns jsonb -- [{sheet_id, snapshot}] auto_submit_day_sheets() returns void -- revoked from public, anon, authenticatedSingle-sheet RPCs return
{sheet_id, work_date, programmer_id, programmer_name, notify_user_ids}; approve returns{approved: [{sheet_id, programmer_id, work_date}], skipped_ids: [...]}. Error codes (raise exception '<code>'):not_authorized,already_reviewed,already_submitted,remarks_required,body_required,invalid_snapshot,future_date. -
Step 1: Write the SQL. Pinned behaviors (lock the sheet row
for updatebefore status checks):- submit: caller role
programmer;p_date > manila_today()→future_date; upsert draft row for (caller, p_date); status ≠draft→already_submitted; setpending,submitted_at = now(),auto_submitted = false; eventsubmitted; notify everyrole = 'admin'profile withtype 'day_sheet_submitted',actor_id = caller,day_sheet_id. - disapprove: caller admin else
not_authorized;btrim(p_remarks) = ''→remarks_required; status ≠pending→already_reviewed; setdisapproved,reviewed_by,reviewed_at; eventdisapprovedwith body + flags; notify the programmerday_sheet_disapproved. - justify: caller = owner else
not_authorized; blank body →body_required; status ≠disapproved→already_reviewed; setpending,resubmissions + 1,submitted_at = now(); eventjustified; notifyreviewed_by(if not null)day_sheet_justified. - approve: caller admin; each item's
snapshotmust be an object with arowsarray elseinvalid_snapshot; status in (pending,disapproved) → setapproved,reviewed_by,reviewed_at,approved_snapshot, eventapproved, notify programmerday_sheet_approved, append toapproved; otherwise append toskipped_ids(no error). - auto_submit: (1) for each
in_progresstask whose latest of started/paused/resumed is notpaused, with aprogrammerassignee:ensure_day_sheet(assignee, manila_today() - 1); if that sheet isapproved→pending+ eventamendedbody'Timer was still running at the end of the day.'. (2) everydraftwithwork_date < manila_today()→pending,submitted_at = now(),auto_submitted = true, eventauto_submitted. (3) if any sheet ispending: for each admin insertnotifications(user_id, actor_id null, type 'day_sheet_digest')andscheduled_notifications(user_id, notify_type 'day_sheet_digest', scheduled_for ((manila_today() + time '08:00') at time zone 'Asia/Manila'), epoch to_char(manila_today(), 'YYYYMMDD')::int) on conflict do nothing. - Replace
chk_at_least_one_refonscheduled_notifications, keeping its current five references and addingor notify_type = 'day_sheet_digest'. - Cron in a
DO $$ … EXCEPTION WHEN others THEN RAISE NOTICE … $$block: unscheduleprogrammer_day_sheet_autosubmit(ignore errors), thencron.schedule('programmer_day_sheet_autosubmit', '5 16 * * *', 'SELECT public.auto_submit_day_sheets();')(16:05 UTC = 00:05 Manila).
- submit: caller role
-
Step 2: Commit —
feat(db): day sheet RPCs, auto-submit cron, digest queue. -
Step 3: Ask the user to apply
20260927150000_…sqlthen20260927150100_…sql(dashboard SQL editor orsupabase db push). Wait for confirmation. -
Step 4: Run the SQL checklist with
mcp__supabase__execute_sql, each item inbegin; … rollback;, impersonating viaset local role authenticated; select set_config('request.jwt.claims', json_build_object('sub','<uuid>','role','authenticated')::text, true);. Programmer =pao@mail.com's profile id; admin = anyrole = 'admin'id. Setup rows for past dates are inserted aspostgresbefore switching role.# Action Expected 1 programmer submit_day_sheet(manila_today())returns jsonb; sheet pending2 repeat 1 error already_submitted3 programmer disapprove_day_sheet(id,'x')error not_authorized4 admin disapprove with ' ', then'Missing notes'remarks_required; thendisapproved+ aday_sheet_disapprovednotification for the programmer5 admin disapprove again already_reviewed6 programmer inserts a work log with work_date = manila_today()-1where no sheet exists; then on adisapprovedyesterday sheetwork_date_locked; then succeeds7 programmer justify '', then'Added notes'body_required; thenpending,resubmissions = 18 admin approve_day_sheetswith a valid snapshot, then againid in approved; then id inskipped_ids9 today's sheet approved; programmer inserts a work log today sheet pending+amendedevent10 postgres: draft sheet dated yesterday; select auto_submit_day_sheets()twicepending,auto_submitted = true; oneday_sheet_digestscheduled row per admin (no duplicate)11 a second programmer selects programmer_day_sheetsonly own rows 12 mcp__supabase__get_advisors(type: 'security')no new ERROR-level lint on the new tables/functions
Task 13: Edge function — digest push
Files:
-
Modify:
supabase/functions/process_scheduled_notifications/index.ts(newcase 'day_sheet_digest'in thenotify_typeswitch) -
Step 1: Implement. Count
programmer_day_sheetswithstatus = 'pending'(select('id', { count: 'exact', head: true })). If 0 → mark the row processed and skip sending. Elsetitle = 'Day sheets awaiting review',body = \${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.`,data.navigate_to = '/programmer-tasks?tab=approvals'`. -
Step 2: Commit —
feat(edge): day sheet digest push. -
Step 3: Ask the user to redeploy
process_scheduled_notifications. Verify: insert (as postgres, not rolled back, then delete) ascheduled_notificationsrow for an admin withnotify_type 'day_sheet_digest',scheduled_for now(),epoch 1; within ~1 minuteprocessed = trueandlast_error is null.
Task 14: Day-sheet event model, providers, controller
Files:
- Create:
lib/models/programmer_day_sheet_event.model.dart,lib/providers/programmer_day_sheets_provider.dart - Test:
test/programmer_day_sheets_controller_test.dart
Interfaces:
-
Consumes: Task 4 (
ProgrammerDaySheet,DaySheetSnapshot), Task 1 (formatDayKey,parseDayKey),NotificationsController.sendPush. -
Produces:
class ProgrammerDaySheetEvent { final String id, sheetId, kind; final String? actorId, body; final List<String> flaggedTaskIds; final DateTime createdAt; factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m); } enum DaySheetError { alreadyReviewed, alreadySubmitted, notAuthorized, remarksRequired, bodyRequired, workDateLocked, invalidSnapshot, unknown } class DaySheetActionException implements Exception { final DaySheetError code; String get message; } DaySheetError mapDaySheetError(Object error); // substring match on the codes from Tasks 11–12 typedef PushFn = Future<void> Function({required List<String> userIds, required String title, required String body, Map<String, dynamic>? data}); class BulkApproveResult { final List<String> approvedIds, skippedIds; } class DaySheetsController { DaySheetsController(dynamic client, {required PushFn push}); Future<String> submit(DateTime dayKey); Future<void> disapprove({required String sheetId, required String remarks, required List<String> flaggedTaskIds}); Future<void> justify({required String sheetId, required String body}); Future<BulkApproveResult> approve(List<({String sheetId, DaySheetSnapshot snapshot})> items); } final daySheetsControllerProvider = Provider<DaySheetsController>(...); final myDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // programmer_id = me, work_date desc final daySheetsForUserProvider = StreamProvider.family<List<ProgrammerDaySheet>, String>(...); final allDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // RLS-scoped, work_date desc final daySheetByIdProvider = StreamProvider.family<ProgrammerDaySheet?, String>(...); final daySheetEventsProvider = StreamProvider.family<List<ProgrammerDaySheetEvent>, String>(...); // created_at ascError messages (exact): alreadyReviewed
This sheet was already reviewed.· alreadySubmittedThis day sheet was already submitted.· notAuthorizedYou are not allowed to do that.· remarksRequiredRemarks are required to disapprove.· bodyRequiredPlease write a justification.· workDateLockedThat day is locked — you can only add entries to pending or disapproved days.· invalidSnapshotCould not approve: the sheet data was invalid.· unknownSomething went wrong. Please try again.Push copy (dates viaAppTime.formatDate, e.g.Sep 28, 2026;dataalways includestypeandnavigate_to: '/programmer-tasks/day-sheets/<id>'):- submitted →
Day sheet submitted/<name> submitted their day sheet for <date> - disapproved →
Day sheet disapproved/Your day sheet for <date> needs changes: <remarks, max 80 chars + '…'> - justified →
Day sheet resubmitted/<name> justified their day sheet for <date> - approved →
Day sheet approved/Your day sheet for <date> was approved
- submitted →
-
Step 1: Failing tests (local fake client with
rpc(String name, {Map<String, dynamic>? params}); a recordingPushFn):test('submit calls submit_day_sheet with p_date and pushes admins', () async { // rpc returns {'sheet_id':'s1','work_date':'2026-09-28','programmer_name':'Paola Ross','notify_user_ids':['adm']} expect(fake.calls.single, ('submit_day_sheet', {'p_date': '2026-09-28'})); expect(push.calls.single.title, 'Day sheet submitted'); expect(push.calls.single.body, 'Paola Ross submitted their day sheet for Sep 28, 2026'); }); test('blank remarks fail fast without an rpc call', ...); // throws DaySheetActionException(remarksRequired); fake.calls isEmpty test('already_reviewed maps to a friendly message', ...); // message 'This sheet was already reviewed.' test('approve reports approved and skipped ids and pushes per approved sheet', ...); test('a failing push does not fail the action', ...); -
Step 2:
$FLUTTER test test/programmer_day_sheets_controller_test.dart— FAIL. -
Step 3: Implement. Controller trims inputs and validates remarks/body before calling the RPC; wraps RPC errors with
mapDaySheetError; push errors are caught anddebugPrinted. Provider wirespushtoref.read(notificationsControllerProvider).sendPush. -
Step 4: same command — PASS.
-
Step 5: Commit —
feat(programmer): day sheet providers + controller.
Task 15: Dated work logs (corrections); day data by work_date
Files:
- Modify:
lib/providers/programmer_task_work_logs_provider.dart,lib/screens/programmer_tasks/widgets/work_log_section.dart,lib/providers/programmer_day_data_provider.dart - Test:
test/programmer_tasks_controller_test.dart(extend theaddWorkLoggroup),test/programmer_day_data_test.dart(update fake filtering)
Interfaces:
- Consumes: Task 14 (
DaySheetActionException,mapDaySheetError). - Produces:
Future<void> addWorkLog({required String taskId, required String description, int? minutes, DateTime? workDate}); // sends 'work_date': formatDayKey(workDate) only when workDate != null; 'work_date_locked' -> DaySheetActionException(workDateLocked) const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate}); - Step 1: Failing tests —
addWorkLog(workDate: utc(2026,9,25))insertswork_date: '2026-09-25'; withoutworkDatethe key is absent;fetchDayActivityDatanow selects work logs witheq('work_date', '2026-09-27'). - Step 2:
$FLUTTER test test/programmer_tasks_controller_test.dart test/programmer_day_data_test.dart— FAIL. - Step 3: Implement.
WorkLogSectionwithworkDate != null: headerAppSectionHeader('Correction for <date>'), and skip the pause / deduct prompts after saving. - Step 4: same command — PASS.
- Step 5: Commit —
feat(programmer): dated work-log corrections.
Task 16: ReportDateFilter.controlled
Files:
- Modify:
lib/screens/reports/report_date_filter.dart - Test:
test/report_date_filter_test.dart
Interfaces:
- Produces:
const ReportDateFilter({super.key}); // unchanged: binds reportDateRangeProvider const ReportDateFilter.controlled({super.key, required ReportDateRange value, required ValueChanged<ReportDateRange> onChanged}); ReportDateRange thisMonthRange(DateTime now); // start = DateTime(y,m,1), end = DateTime(y,m+1,1), label 'This Month' - Step 1: Failing test — pump
ReportDateFilter.controlled(value: thisMonthRange(now), onChanged: captured.add); tapChange, tap presetYesterday→captured.single.label == 'Yesterday'andcontainer.read(reportDateRangeProvider)still has labelLast 30 Days. - Step 2:
$FLUTTER test test/report_date_filter_test.dart— FAIL. - Step 3: Implement. Both constructors share the build; the dialog's
current/onApplycome fromvalue/onChangedwhen controlled, otherwise from the provider. - Step 4: same command — PASS.
- Step 5: Commit —
feat(reports): controlled ReportDateFilter.
Task 17: Day-sheet detail screen, dialogs, route
Files:
- Create:
lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart,day_sheets/day_sheet_dialogs.dart,day_sheets/day_sheet_rows_table.dart,day_sheets/day_sheet_thread.dart - Modify:
lib/routing/app_router.dart(routeday-sheets/:idunder/programmer-tasks, before:id) - Test:
test/day_sheet_dialogs_test.dart
Interfaces:
- Consumes: Tasks 2, 3, 14, 15.
- Produces:
class DaySheetDetailScreen extends ConsumerStatefulWidget { const DaySheetDetailScreen({super.key, required this.sheetId}); } Future<String?> showDisapproveDialog(BuildContext context, {required List<DaySheetRow> flaggedRows}); Future<String?> showJustifyDialog(BuildContext context); class DaySheetRowsTable extends StatelessWidget { const DaySheetRowsTable({super.key, required this.rows, required this.flaggedTaskIds, this.selectedTaskIds, this.onSelectionChanged, this.onAddCorrection}); } class DaySheetThread extends StatelessWidget { const DaySheetThread({super.key, required this.events, required this.profileNames}); } List<DaySheetRow> rowsForSheet(ProgrammerDaySheet sheet, List<DaySheetRow> liveRows); // approved -> snapshot rows, else liveRows - Step 1: Failing tests.
testWidgets('disapprove dialog requires remarks', (t) async { // tap 'Disapprove' with empty field -> 'Remarks are required' visible, dialog stays // enter 'Missing notes' -> dialog returns 'Missing notes' }); testWidgets('justify dialog requires a justification', ...); // 'Please write a justification' test('rowsForSheet uses the snapshot once approved', ...); - Step 2:
$FLUTTER test test/day_sheet_dialogs_test.dart— FAIL. - Step 3: Implement per spec §6.3:
- Header: date, programmer,
StatusPill.kind(draft→neutral, pending→info, disapproved→warning, approved→success), total,Round N(N = resubmissions + 1) whenresubmissions > 0. - Flags =
flaggedTaskIdsof the latestdisapprovedevent; shown while status ≠ approved. - Thread labels: submitted
Submitted· auto_submittedAuto-submitted at end of day· disapprovedDisapproved· justifiedJustified· approvedApproved· amendedReopened — new work after approval. - Owner when status is
disapproved(orpendingfor a past day): each row shows an Add correction icon → dialog withWorkLogSection(task, isAssignee: task.assigneeId == me, workDate: sheet.workDate). Owner whendisapproved: Justify & resubmit →showJustifyDialog→controller.justify. - Admin when
pending/disapproved: row checkboxes → Disapprove →showDisapproveDialog(flaggedRows)→controller.disapprove; Approve → if the sheet day is today andprogrammerRunStatesProviderhas a running task assigned to that programmer, confirm<name> has a timer running. Work logged after approval will reopen this sheet. Approve anyway?→controller.approve([(sheetId: sheet.id, snapshot: DaySheetSnapshot.fromRows(rows))]). DaySheetActionException.message→ SnackBar; offline banner per Global Constraints.
- Header: date, programmer,
- Step 4:
$FLUTTER test test/day_sheet_dialogs_test.dart— PASS;$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(programmer): day sheet detail screen.
Task 18: Day Sheets tab, Approvals tab, tab wiring
Files:
- Create:
lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart,day_sheets/approvals_tab.dart,day_sheets/day_sheet_snapshot_builder.dart - Modify:
lib/screens/programmer_tasks/programmer_tasks_tabs.dart(programmerTasksTabsFor),programmer_tasks_list_screen.dart(tab bodies) - Test:
test/day_sheet_tabs_test.dart; updatetest/programmer_tabs_test.dart
Interfaces:
- Consumes: Tasks 14, 16, 17.
- Produces:
Tabs now: admin
Future<DaySheetSnapshot> buildSnapshotForSheet(WidgetRef ref, ProgrammerDaySheet sheet); Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(List<ProgrammerDaySheet> sheets); // keys descending ({int pending, int awaiting, int approved}) approvalCounts(List<ProgrammerDaySheet> sheets); // awaiting = disapproved[monitor, approvals, mine, all], programmer[mine, all, daySheets]. - Step 1: Failing tests — updated
programmerTasksTabsForexpectations;groupSheetsByDateorder;approvalCounts; widget:ApprovalsTabwith overriddenallDaySheetsProvider(3 pending) → select two cards → button textApprove selected (2). - Step 2:
$FLUTTER test test/day_sheet_tabs_test.dart test/programmer_tabs_test.dart— FAIL. - Step 3: Implement per spec §6.2 / §6.4:
- Day Sheets tab: Today card (live
buildDayRowsfor me, total, status chip — missing sheet showsDraft), Submit day enabled when status is draft/missing and rows are non-empty → SnackBarDay submitted; Needs your attention (mydisapprovedsheets); history withAppStatusSummaryRowfilters All / Pending / Disapproved / Approved; tapping →/programmer-tasks/day-sheets/<id>. - Approvals tab: summary chips
Pending,Awaiting justification,Approved(Pending selected by default); programmer dropdown (All programmers+ names);ReportDateFilter.controlled(defaultthisMonthRange); cards grouped by date with name,TechChip(formatDurationHm(total)),<n> tasks, badgeAutoifautoSubmittedelseEarly,Round N; checkboxes on pending cards; Approve selected (n) →buildSnapshotForSheeteach →approve→ SnackBarApproved <a>plus, <s> already reviewedwhen skipped > 0.
- Day Sheets tab: Today card (live
- Step 4: same command — PASS;
$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(programmer): day sheets + approvals tabs.
Task 19: Notifications for day sheets
Files:
- Modify:
lib/models/notification_item.model.dart(addfinal String? daySheetId;←day_sheet_id), Brick generated files (regenerated),lib/screens/notifications/notifications_screen.dart,lib/services/notification_bridge.dart - Test:
test/notification_titles_test.dart
Interfaces:
- Produces (moved out of the screen's State so they are testable; existing cases unchanged):
New cases:
String notificationTitle(String type, String actorName); IconData notificationIcon(String type);day_sheet_submitted→'$actorName submitted a day sheet'/Icons.assignment_turned_in_outlined;day_sheet_disapproved→'Day sheet disapproved'/Icons.report_outlined;day_sheet_justified→'$actorName justified a day sheet'/Icons.reply_outlined;day_sheet_approved→'Day sheet approved'/Icons.verified_outlined;day_sheet_digest→'Day sheets awaiting review'/Icons.inbox_outlined. Banner labels:Day sheet submitted,Day sheet disapproved,Day sheet justified,Day sheet approved,Day sheets awaiting review. Tap:daySheetId != null→/programmer-tasks/day-sheets/<id>;day_sheet_digest→/programmer-tasks?tab=approvals. - Step 1: Failing tests for the five titles/icons and one existing case (
'assignment'→'X assigned you') to guard the move. - Step 2:
$FLUTTER test test/notification_titles_test.dart— FAIL. - Step 3: Implement, then regenerate Brick:
$FLUTTER pub run build_runner build --delete-conflicting-outputs; commit the regenerated adapter,schema.g.dart, and newlib/brick/db/*.migration.dart. - Step 4:
$FLUTTER test test/notification_titles_test.dart test/notification_bridge_test.dart— PASS;$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(notifications): day sheet notification types.
Task 20: Wire sheet data into dashboard, monitor, drill-down
Files:
-
Modify:
dashboard/programmer_dashboard.dart(sheets←daySheetsForUserProvider(userId)),monitor/monitor_tab.dart(sheetStatusByUserfromallDaySheetsProviderfor today + summary chipSheets pending),programmer_drill_down_screen.dart(AppSectionHeader('Recent day sheets')+ last 10 sheets → detail route) -
Test:
test/programmer_drill_down_test.dart -
Step 1: Failing test — drill-down with overridden
daySheetsForUserProvider('u1')(2 sheets) and admin profile showsRecent day sheetsand both dates. -
Step 2:
$FLUTTER test test/programmer_drill_down_test.dart— FAIL. -
Step 3: Implement.
-
Step 4: same command — PASS;
$FLUTTER analyze lib— clean. -
Step 5: Commit —
feat(programmer): sheet-driven stats, monitor status, drill-down history.
Slice 3 checkpoint: full $FLUTTER test, $FLUTTER build web; QA harness: programmer submits early → admin disapproves with a flag → programmer adds a correction + justifies → admin approves; notifications render and route.
Part D — Slice 4: Accomplishment Report
Task 21: Report data builder (pure)
Files:
- Create:
lib/screens/programmer_tasks/report/accomplishment_report_data.dart - Test:
test/accomplishment_report_data_test.dart
Interfaces:
- Consumes: Tasks 2, 4.
- Produces:
class ReportDay { final DateTime day; final String? statusTag; final List<DaySheetRow> rows; } // statusTag null when approved; 'Pending' | 'Disapproved' class AccomplishmentReportData { final String programmerName, positionLabel; // positionLabel: 'Programmer' | 'Administrator' final DateTime periodStart, periodEndInclusive; // day keys final List<ReportDay> days; // ascending final int excludedPending, excludedDisapproved; final int daysReported, totalSeconds, tasksCompleted, helpSeconds; final Map<String, int> secondsByCategory; final String? approverName; } AccomplishmentReportData buildAccomplishmentReportData({required Profile programmer, required ReportDateRange range, required bool includeUnapproved, required List<ProgrammerDaySheet> sheets, required Map<DateTime, List<DaySheetRow>> liveRowsByDay, required List<ProgrammerTask> tasks, required Map<String, String> profileNames}); - Step 1: Failing tests.
test('approved-only uses snapshots and counts excluded days', ...); // 2 approved, 1 pending, 1 disapproved -> days 2, excludedPending 1, excludedDisapproved 1 test('include unapproved tags days and uses live rows', ...); // statusTag 'Pending' / 'Disapproved'; draft counts as 'Pending' test('period end is inclusive', ...); // range Sep 1 -> Oct 1 (exclusive) -> periodEndInclusive utc(2026,9,30) test('approver name only when one reviewer approved every included day', ...); test('tasksCompleted counts tasks completed in range for the programmer', ...); test('empty period', () { expect(data.days, isEmpty); expect(data.daysReported, 0); }); - Step 2:
$FLUTTER test test/accomplishment_report_data_test.dart— FAIL. - Step 3: Implement. Only sheets with
workDatein[start, end)and days with ≥ 1 row are included.helpSeconds/secondsByCategorysum over included rows (helper rows count toward help and their category). - Step 4: same command — PASS.
- Step 5: Commit —
feat(report): accomplishment report data.
Task 22: Shared PDF helpers + report PDF
Files:
- Create:
lib/utils/pdf/pdf_letterhead.dart,lib/widgets/pdf_preview_dialog.dart,lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart - Test:
test/accomplishment_report_pdf_test.dart
Interfaces:
- Consumes: Task 21.
- Produces:
class PdfBrand { final pw.Font regular, bold; final pw.ImageProvider logo; static Future<PdfBrand> load(); // assets/fonts/Roboto-*.ttf + assets/crmc_logo.png pw.Widget letterhead(); } // same four lines + 60x60 logo as dtr_pdf.dart `_header` Future<void> showPdfPreviewDialog(BuildContext context, {required String title, required String filename, required Future<Uint8List> Function() buildBytes}); // modeled on _DtrPdfDialog: pdfrx PdfViewer.data, Print (Printing.layoutPdf), Download (Printing.sharePdf) Future<Uint8List> buildAccomplishmentReportPdf(AccomplishmentReportData data); - Step 1: Failing tests (
TestWidgetsFlutterBinding.ensureInitialized()): a populated report builds non-empty bytes starting with%PDF; an empty report builds too (Review Focus 5). - Step 2:
$FLUTTER test test/accomplishment_report_pdf_test.dart— FAIL. - Step 3: Implement layout per spec §9. Exact copy: title
ACCOMPLISHMENT REPORT; info labelsName:,Position:,Period:(MMMM d, yyyy – MMMM d, yyyy, inclusive end); table headersDATE,TASK / PROJECT,ACCOMPLISHMENT,TIME; notes joined with\n,(no notes)when empty; helper rowsHelped <name> — <task>; status tag in brackets after the date ([Pending]); empty →No approved accomplishments in this period.; footnote<n> day(s) in this period are not included (<p> pending, <d> disapproved).(only when approved-only and n > 0); summary labelsDays reported,Total time,Tasks completed,Help given,Time by category; signaturesPrepared by:(programmer name + position) andApproved by:(approver name or a blank line). A4MultiPage, margin 32, footerPage x of y. - Step 4: same command — PASS.
- Step 5: Commit —
feat(report): accomplishment report PDF + shared PDF helpers.
Task 23: Generate dialog + entry points
Files:
- Create:
lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart - Modify:
day_sheets/day_sheets_tab.dart(app-bar action),day_sheets/approvals_tab.dart(action),programmer_drill_down_screen.dart(button) - Test:
test/accomplishment_report_dialog_test.dart
Interfaces:
- Consumes: Tasks 16, 21, 22,
fetchDayActivityData,buildDayRows. - Produces:
Future<void> showAccomplishmentReportDialog(BuildContext context, WidgetRef ref, {String? programmerId}); Future<AccomplishmentReportData> loadAccomplishmentReport(WidgetRef ref, {required Profile programmer, required ReportDateRange range, required bool includeUnapproved}); - Step 1: Failing tests — as programmer: no programmer picker, title
Accomplishment Report, toggleInclude unapproved days (marked)off by default; as admin with noprogrammerId: picker lists onlyrole == 'programmer'profiles and Generate is disabled until one is chosen. - Step 2:
$FLUTTER test test/accomplishment_report_dialog_test.dart— FAIL. - Step 3: Implement. Loader selects sheets for the programmer with
work_datein range; for unapproved days (only when the toggle is on) builds live rows viafetchDayActivityData+buildDayRows; thenshowPdfPreviewDialog(title: 'Accomplishment Report – <name>', filename: 'Accomplishment Report - <name>.pdf'). Entry labels:Accomplishment Report(Day Sheets / Approvals app bars),Generate Accomplishment Report(drill-down). - Step 4: same command — PASS;
$FLUTTER analyze lib— clean. - Step 5: Commit —
feat(report): accomplishment report dialog + entry points.
Task 24: Final verification
- Step 1:
$FLUTTER analyze lib— no issues. - Step 2:
$FLUTTER test— only the known unrelated failures. - Step 3:
$FLUTTER build web— succeeds. - Step 4: QA harness (
~/.cache/tasq-qa/, fresh--sessionper rebuild; programmerpao@mail.com, then an admin; light and dark): log work → Mine dashboard hero/tiles/sections → submit early → admin Monitor shows Working/Paused/On leave and help edges → admin flags + disapproves → programmer adds a correction + justifies → admin approves (single and bulk) → both generate the PDF (approved-only and marked) → notifications render and route. Save screenshots to~/.cache/tasq-qa/shots/. - Step 5: Report results to the user; fix regressions via superpowers:systematic-debugging before claiming completion.