Add implementation plan: programmer oversight

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 18:22:05 +08:00
parent 7a319b6e16
commit dd6730f62d
@@ -0,0 +1,909 @@
# 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:
```dart
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})`.
```dart
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.**
```bash
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:
```dart
// 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.**
```dart
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:
```dart
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`.
```dart
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_id`s 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:
```dart
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.)
```dart
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:
```dart
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: …)}`.
```dart
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:
```dart
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.**
```dart
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:
```dart
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:
```dart
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.**
```dart
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:
```dart
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:
```dart
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.**
```dart
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`):
```sql
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:
```dart
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`):
```dart
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 `debugPrint`ed. 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:
```dart
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:
```dart
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:
```dart
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.**
```dart
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:
```dart
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):
```dart
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:
```dart
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.**
```dart
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:
```dart
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:
```dart
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.