Files
tasq/docs/superpowers/plans/2026-09-27-programmer-oversight.md
T
2026-09-27 18:22:05 +08:00

70 KiB
Raw Blame History

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> means flatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter <args>'.
  • Never run dart format on 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) — never git add -A / git add .. Every commit message ends with Co-Authored-By: claude-flow <ruv@ruv.net>.
  • Supabase DDL and edge-function deploys are done by the user. Do not call mcp__supabase__apply_migration or deploy_edge_function; read-only checks via mcp__supabase__execute_sql inside begin; … rollback; are fine.
  • Time zone is Asia/Manila via AppTime. Day keys are DateTime.utc(y, m, d) of the Manila calendar date, produced only by manilaDay() / parseDayKey() (Task 1). DB date values are yyyy-MM-dd strings via formatDayKey(). ReportDateRange uses local DateTime(y, m, d) with an exclusive end; convert with DateTime.utc(r.start.year, r.start.month, r.start.day).
  • "Admin" = profiles.role == 'admin' via isStrictAdminProvider (Task 10) and SQL role = 'admin'. Never use isAdminProvider (it includes programmers) for approval or monitor gating.
  • Status color only via AppStatusColors / StatusKind (success, warning, danger, info, neutral) / StatusPill.kind. Section titles AppSectionHeader(title, trailing:); KPI tiles AppMetricCard(title:, value:, icon:, trailing:); codes/durations TechChip; cards M3Card. No hardcoded status hex.
  • Every new file < 500 lines.
  • Day sheets and work logs are online-only. When isOnlineProvider is false, day-sheet actions are disabled and a banner reads: You're offline — day sheet actions need a connection.
  • Tests live in test/, call setUpAll(AppTime.initialize), and follow the fake-client pattern of test/programmer_tasks_controller_test.dart (controller constructed with fake as dynamic). Widget tests wrap in ProviderScope + 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 in dashboard_metrics_provider_test.

Review Focus

  1. Device time zone ≠ Manila (browser set to UTC): an event at 2026-09-26T16:30Z belongs to Manila day 2026-09-27, and a day sheet's time never leaks across Manila midnight. → Task 1 test manilaDay ignores device time zone.
  2. 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.
  3. Offline-replayed task with no started log but started_at set: its time is still counted from started_at. → Task 1 test falls back to task.startedAt.
  4. 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.
  5. 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 test empty 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 Manila 2026-09-d h:m (via AppTime.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 createdAt order (input may be any order).
    • Initial assignee = meta assignee_id of the first assigned event; else, if any reassigned event exists, task.creatorId; else task.assigneeId.
    • started/resumed open a segment (if none open); paused/completed/cancelled close it; reassigned closes the open segment for the old assignee and, if it was open, reopens it for meta['assignee_id'].
    • If no started event exists and task.startedAt != null, treat startedAt as a synthetic started.
    • An open segment at the end closes at now only when task.status == in_progress; otherwise at completedAt ?? cancelledAt ?? now.
    • Split each interval at Manila midnights using manilaDayStart(nextDayKey); adjustments subtract meta['seconds'] from the bucket of the assignee-at-that-time on manilaDay(event.createdAt); clamp each bucket to ≥ 0.
  • 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 (add workDate)
  • 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] > 0 or userId authored a minutes == null work log on it with workDate == day. A helper row exists for a task when userId != task.assigneeId and userId authored work logs with minutes > 0 and workDate == day; seconds = sum(minutes) * 60, helpedName = profileNames[task.assigneeId]. notes = deltaJsonToPlainText of that user's work logs on that task that day, oldest first, empty strings dropped. Tasks missing from tasks are 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 — message feat(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 _FakeClient in the test following the controller-test pattern, supporting select, 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, or startedAt != null && startedAt < dayEnd && (completedAt ?? cancelledAt ?? now) >= dayStart. Work logs: programmer_task_work_logs with created_at >= dayStart.toUtc() and < dayEnd.toUtc() (Task 15 switches this to work_date); their task_ids join the candidates. Activity logs: one select … inFilter('task_id', ids) order created_at desc, grouped by task. Run state: in_progress whose latest of started/paused/resumed is paused → paused (since = that event); otherwise running (since = latest started/resumed, else startedAt). The signal provider subscribes one channel programmer_activity_signal with two onPostgresChanges(event: PostgresChangeEvent.insert, …) handlers and removes it on dispose. programmerDayDataProvider watches programmerTasksProvider, 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 pending or approved. Walk back from today: a non-qualifying today is skipped (not a break); Sat/Sun and leaveDays are 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 with assigneeId == userId, status == completed, completedAt in this week.
    • firstPassRate: sheets with workDate in 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 of kind == 'helper' rows (month of workDate).
    • hasCleanWeek: some Mon–Fri where every weekday is in leaveDays or isFirstPassApproved, 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_outlined Complete your first task
      finisher Finisher 10/50/100 completedTotal no Icons.task_alt Complete 10 / 50 / 100 tasks
      bug_squasher Bug Squasher 10 bugFixCompleted no Icons.bug_report_outlined Complete 10 Bug Fix tasks
      deep_work Deep Work 100/500 approvedTotal.inHours yes Icons.timer_outlined Log 100 / 500 approved hours
      on_a_roll On a Roll 5/10/20 bestStreak yes Icons.local_fire_department_outlined Reach a 5 / 10 / 20-day streak
      team_player Team Player 10 helpGivenTotal.inHours yes Icons.handshake_outlined Give 10 hours of help to teammates
      clean_week Clean Week 1 hasCleanWeek ? 1 : 0 yes Icons.verified_outlined A Mon–Fri week with every day approved first time
  • 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 programmerRunStatesProvider with {'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, else programmerTaskStatusKind(task.status) + ProgrammerTaskStatus.label. Start/Resume shown only when isMine && !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 names In 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, TechChip category, Today <h m> · Total <h m> (formatDurationHm; today = dailySecondsByAssignee(...)[userId][today], total = sum over all days for userId), buttons Pause (pauseTask(taskId:)) and Complete (completeProgrammerTaskWithConfirm). Nothing running → text Nothing running and, if nextUp != null, Start via startProgrammerTaskWithPausePrompt. readOnly hides all buttons. Live values re-render on clockTickerProvider.

  • 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 now included, pending excluded; active pass slip included; approvedLeaveDays for 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 — value formatDurationHm(todayFocus), trailing: a 40 px CircularProgressIndicator(value: min(1, focus / 8h)) with / 8h label (pattern reports/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 — icon Icons.local_fire_department_outlined, value N days (1 day), trailing Best M.
    • FIRST-PASS APPROVAL — value NN% or —, trailing Last 30 days.
    • HELP GIVEN — value formatDurationHm, trailing This month.
    • showSheetStats = the dashboard user's role is programmer; when false only the first two tiles render and computeBadges(includeSheetBadges: false).
    • Badges: horizontal list of chips; earned = AppStatusColors success container; locked = neutral + current/target; tap → AlertDialog with title + description.
    • Dashboard feeds sheets: const <ProgrammerDaySheet>[] until Task 20 wires daySheetsForUserProvider; 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 → working with that currentTask; only paused → paused with the most recent since; none → idle; sort order working, paused, idle, passSlip, onLeave, then name; helped/helpedBy edges; todayTotal equals totalSeconds(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 (query tab, route programmers/:userId placed 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 → warning Paused; idle → neutral Idle; onLeave → info On leave; passSlip → info Pass slip.
    • Monitor tab: AppStatusSummaryRow with Working, 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 chips Helped → <name> · <task> · <m>m and Helped 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>, from programmerDayDataProvider(day); empty → No help logged this day.
    • Drill-down: AppBar title = programmer name; body ProgrammerDashboard(userId, readOnly: true). Non-admins see AppEmptyView(icon: Icons.lock_outline, title: 'Admins only').
    • The list screen builds its TabController from programmerTasksTabsFor, selects parseProgrammerTasksTab(initialTab) if present in the list; router passes state.uri.queryParameters['tab'].
  • 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; tables programmer_day_sheets, programmer_day_sheet_events exactly as spec §5.2 (checks included; events body non-empty for disapproved/justified); programmer_task_work_logs.work_date; notifications.day_sheet_id; ensure_day_sheet(p_programmer uuid, p_date date) returns uuid; error code work_date_locked.

  • Step 1: Write the SQL. Every function set search_path = public. Pinned behaviors:

    1. manila_today() = (now() at time zone 'Asia/Manila')::date, stable.
    2. Tables + unique(programmer_id, work_date), index (status, work_date); updated_at trigger reusing set_programmer_tasks_updated_at().
    3. 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).
    4. ensure_day_sheet (SECURITY DEFINER; revoke execute … from public, anon, authenticated): returns null unless the profile's role is programmer; inserts draft on conflict do nothing; if the row is approved and p_date = manila_today() → set pending and insert event kind='amended', actor_id null, body 'New work was logged after approval.'.
    5. 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 status pending or disapproved, else raise exception 'work_date_locked'.
    6. AFTER INSERT on work logs → ensure_day_sheet(new.author_id, new.work_date).
    7. AFTER INSERT on programmer_task_activity_logs when action_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.
    8. 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.
    9. alter table notifications add column if not exists day_sheet_id uuid references programmer_day_sheets(id) on delete cascade + index.
    10. 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 authenticated except auto_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, authenticated
    

    Single-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 update before status checks):

    • submit: caller role programmer; p_date > manila_today() → future_date; upsert draft row for (caller, p_date); status ≠ draft → already_submitted; set pending, submitted_at = now(), auto_submitted = false; event submitted; notify every role = 'admin' profile with type 'day_sheet_submitted', actor_id = caller, day_sheet_id.
    • disapprove: caller admin else not_authorized; btrim(p_remarks) = '' → remarks_required; status ≠ pending → already_reviewed; set disapproved, reviewed_by, reviewed_at; event disapproved with body + flags; notify the programmer day_sheet_disapproved.
    • justify: caller = owner else not_authorized; blank body → body_required; status ≠ disapproved → already_reviewed; set pending, resubmissions + 1, submitted_at = now(); event justified; notify reviewed_by (if not null) day_sheet_justified.
    • approve: caller admin; each item's snapshot must be an object with a rows array else invalid_snapshot; status in (pending,disapproved) → set approved, reviewed_by, reviewed_at, approved_snapshot, event approved, notify programmer day_sheet_approved, append to approved; otherwise append to skipped_ids (no error).
    • auto_submit: (1) for each in_progress task whose latest of started/paused/resumed is not paused, with a programmer assignee: ensure_day_sheet(assignee, manila_today() - 1); if that sheet is approved → pending + event amended body 'Timer was still running at the end of the day.'. (2) every draft with work_date < manila_today() → pending, submitted_at = now(), auto_submitted = true, event auto_submitted. (3) if any sheet is pending: for each admin insert notifications(user_id, actor_id null, type 'day_sheet_digest') and scheduled_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_ref on scheduled_notifications, keeping its current five references and adding or notify_type = 'day_sheet_digest'.
    • Cron in a DO $$ … EXCEPTION WHEN others THEN RAISE NOTICE … $$ block: unschedule programmer_day_sheet_autosubmit (ignore errors), then cron.schedule('programmer_day_sheet_autosubmit', '5 16 * * *', 'SELECT public.auto_submit_day_sheets();') (16:05 UTC = 00:05 Manila).
  • Step 2: Commit — feat(db): day sheet RPCs, auto-submit cron, digest queue.

  • Step 3: Ask the user to apply 20260927150000_…sql then 20260927150100_…sql (dashboard SQL editor or supabase db push). Wait for confirmation.

  • Step 4: Run the SQL checklist with mcp__supabase__execute_sql, each item in begin; … rollback;, impersonating via set 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 = any role = 'admin' id. Setup rows for past dates are inserted as postgres before switching role.

    # Action Expected
    1 programmer submit_day_sheet(manila_today()) returns jsonb; sheet pending
    2 repeat 1 error already_submitted
    3 programmer disapprove_day_sheet(id,'x') error not_authorized
    4 admin disapprove with ' ', then 'Missing notes' remarks_required; then disapproved + a day_sheet_disapproved notification for the programmer
    5 admin disapprove again already_reviewed
    6 programmer inserts a work log with work_date = manila_today()-1 where no sheet exists; then on a disapproved yesterday sheet work_date_locked; then succeeds
    7 programmer justify '', then 'Added notes' body_required; then pending, resubmissions = 1
    8 admin approve_day_sheets with a valid snapshot, then again id in approved; then id in skipped_ids
    9 today's sheet approved; programmer inserts a work log today sheet pending + amended event
    10 postgres: draft sheet dated yesterday; select auto_submit_day_sheets() twice pending, auto_submitted = true; one day_sheet_digest scheduled row per admin (no duplicate)
    11 a second programmer selects programmer_day_sheets only 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 (new case 'day_sheet_digest' in the notify_type switch)

  • Step 1: Implement. Count programmer_day_sheets with status = 'pending' (select('id', { count: 'exact', head: true })). If 0 → mark the row processed and skip sending. Else title = '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) a scheduled_notifications row for an admin with notify_type 'day_sheet_digest', scheduled_for now(), epoch 1; within ~1 minute processed = true and last_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 asc
    

    Error messages (exact): alreadyReviewed This sheet was already reviewed. · alreadySubmitted This day sheet was already submitted. · notAuthorized You are not allowed to do that. · remarksRequired Remarks are required to disapprove. · bodyRequired Please write a justification. · workDateLocked That day is locked — you can only add entries to pending or disapproved days. · invalidSnapshot Could not approve: the sheet data was invalid. · unknown Something went wrong. Please try again. Push copy (dates via AppTime.formatDate, e.g. Sep 28, 2026; data always includes type and navigate_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
  • Step 1: Failing tests (local fake client with rpc(String name, {Map<String, dynamic>? params}); a recording PushFn):

    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 and debugPrinted. Provider wires push to ref.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 the addWorkLog group), 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)) inserts work_date: '2026-09-25'; without workDate the key is absent; fetchDayActivityData now selects work logs with eq('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. WorkLogSection with workDate != null: header AppSectionHeader('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); tap Change, tap preset Yesterday → captured.single.label == 'Yesterday' and container.read(reportDateRangeProvider) still has label Last 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/onApply come from value/onChanged when 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 (route day-sheets/:id under /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) when resubmissions > 0.
    • Flags = flaggedTaskIds of the latest disapproved event; shown while status ≠ approved.
    • Thread labels: submitted Submitted · auto_submitted Auto-submitted at end of day · disapproved Disapproved · justified Justified · approved Approved · amended Reopened — new work after approval.
    • Owner when status is disapproved (or pending for a past day): each row shows an Add correction icon → dialog with WorkLogSection(task, isAssignee: task.assigneeId == me, workDate: sheet.workDate). Owner when disapproved: Justify & resubmit → showJustifyDialog → controller.justify.
    • Admin when pending/disapproved: row checkboxes → Disapprove → showDisapproveDialog(flaggedRows) → controller.disapprove; Approve → if the sheet day is today and programmerRunStatesProvider has 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.
  • 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; update test/programmer_tabs_test.dart

Interfaces:

  • Consumes: Tasks 14, 16, 17.
  • Produces:
    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
    
    Tabs now: admin [monitor, approvals, mine, all], programmer [mine, all, daySheets].
  • Step 1: Failing tests — updated programmerTasksTabsFor expectations; groupSheetsByDate order; approvalCounts; widget: ApprovalsTab with overridden allDaySheetsProvider (3 pending) → select two cards → button text Approve 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 buildDayRows for me, total, status chip — missing sheet shows Draft), Submit day enabled when status is draft/missing and rows are non-empty → SnackBar Day submitted; Needs your attention (my disapproved sheets); history with AppStatusSummaryRow filters 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 (default thisMonthRange); cards grouped by date with name, TechChip(formatDurationHm(total)), <n> tasks, badge Auto if autoSubmitted else Early, Round N; checkboxes on pending cards; Approve selected (n) → buildSnapshotForSheet each → approve → SnackBar Approved <a> plus , <s> already reviewed when skipped > 0.
  • 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 (add final 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):
    String notificationTitle(String type, String actorName);
    IconData notificationIcon(String type);
    
    New cases: 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 new lib/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 (sheetStatusByUser from allDaySheetsProvider for today + summary chip Sheets 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 shows Recent day sheets and 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 workDate in [start, end) and days with ≥ 1 row are included. helpSeconds / secondsByCategory sum 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 labels Name:, Position:, Period: (MMMM d, yyyy – MMMM d, yyyy, inclusive end); table headers DATE, TASK / PROJECT, ACCOMPLISHMENT, TIME; notes joined with \n, (no notes) when empty; helper rows Helped <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 labels Days reported, Total time, Tasks completed, Help given, Time by category; signatures Prepared by: (programmer name + position) and Approved by: (approver name or a blank line). A4 MultiPage, margin 32, footer Page 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, toggle Include unapproved days (marked) off by default; as admin with no programmerId: picker lists only role == '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_date in range; for unapproved days (only when the toggle is on) builds live rows via fetchDayActivityData + buildDayRows; then showPdfPreviewDialog(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 --session per rebuild; programmer pao@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.