Skip to main content

Catalog Restructure — Phase A: Today Role-Personalized Dashboard — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the Today screen a role-personalized high-level dashboard — an admin sees their own items + all children; an oversight admin (no own items) sees just the children; a plain member (incl. kids) sees their own items + the household's family goals.

Architecture: All role logic lives in TodayChoresBloc — a new _deriveViewerScope() step narrows state.participants and populates a new state.familyGoals based on the authenticated viewer, wired into the three sites that emit participants (_onStarted, _onChoresChanged, _onViewingAsChanged). The Today page widget stays a pure renderer (it already renders whatever participants contains; it gains one new section for familyGoals). The existing "viewing-as-someone-else" lens (_tailor()) is left untouched.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), flutter_bloc, mocktail tests, client_sdk models (HouseholdMember, MemberKind, MemberRole, Goal, GoalScope), client_sdk_testing seed factories.

Global Constraints

  • FVM only: cd app && fvm flutter test, fvm flutter analyze. Never bare flutter/dart, never node.
  • One data path (Bloc → Repository → Client facade → Service → Adapter); presentation never imports supabase/drift. Role logic lives in the bloc, not the widget.
  • App suite baseline 757 must not drop (new tests raise it). SDK 1163 / DS 287 untouched.
  • All copy via Strings. Reuse DS atoms; no Flutter TabBar.
  • Explicit git add (never -A/.). Do NOT run build_runner (no codegen models change; TodayChoresState is a plain Equatable class, not @JsonSerializable — confirm before assuming). Do NOT stage stray state.g.dart.
  • Do NOT modify _tailor() (the lens path). Role personalization runs in the master view (when tailoredId() is null).

Behavior spec (the single rule)

Given the authenticated viewer v (_authenticatedMember, may be null):

  • v is a managerv.isAdmin || v.owner || v.kind.isParental (admin role OR owner OR a parent/co-parent) — OR v == null → pass-through: participants = full assignable roster (the whole family — every member), familyGoals = []. Admins/parents see all.
  • v is a plain member (NOT admin, NOT owner, NOT parental — i.e. a child, or a non-admin non-parent adult) → participants = [v], familyGoals = family goals (GoalScope.family, memberId == null).

That is: managers/parents get the whole-family dashboard; a plain member is narrowed to their own row plus the shared family goals. (v.isAdmin = roles.contains(MemberRole.admin); v.kind.isParental = parent || coParent.)

File structure

  • app/lib/inside/blocs/today_chores/bloc.dart — add GoalsRepository dep; add _deriveViewerScope(); wire into _onStarted/_onChoresChanged/_onViewingAsChanged; resolve viewer-relative isParent.
  • app/lib/inside/blocs/today_chores/state.dart — add List<Goal> familyGoals field (default const [], in ctor/copyWith/props).
  • app/lib/inside/routes/authenticated/home/page.dart — pass GoalsRepository into the bloc; add a _FamilyGoalsSection rendered when state.familyGoals is non-empty.
  • app/lib/inside/i18n/strings.dart — family-goals section title string.
  • app/test/unit/today_chores_bloc_test.dart (+ new today_chores_role_scope_test.dart) — role-scope + family-goals bloc tests; update build()/setUp for the new dep.
  • app/test/util/mocks/repositories.dartMockGoalsRepository already exists (line 44); no change unless a stub helper is added.

Task 1: Inject GoalsRepository + add familyGoals to state (scaffolding, no behavior change)

Files:

  • Modify: app/lib/inside/blocs/today_chores/state.dart (add field)
  • Modify: app/lib/inside/blocs/today_chores/bloc.dart (constructor param + field)
  • Modify: app/lib/inside/routes/authenticated/home/page.dart (pass the repo at construction)
  • Test: app/test/unit/today_chores_bloc_test.dart (update build()/setUp; assert default)

Interfaces:

  • Consumes: GoalsRepository (app/lib/outside/repositories/goals/goals_repository.dart) — Future<List<Goal>> getGoals({String? memberId, bool includeArchived = false}); Goal with GoalScope scope (family/member) and String? memberId.

  • Produces: TodayChoresState.familyGoals (List<Goal>, default const []); TodayChoresBloc({..., required GoalsRepository goalsRepository}).

  • Step 1: Add the failing test — in today_chores_bloc_test.dart, add:

test('familyGoals defaults to empty on load', () async {
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream
.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.familyGoals, isEmpty);
await bloc.close();
});

(This will FAIL to COMPILE first because familyGoals and the goalsRepository ctor param don't exist yet — that is the red state.)

  • Step 2: Run it — expect compile failure Run: cd app && fvm flutter test test/unit/today_chores_bloc_test.dart Expected: FAIL — familyGoals / goalsRepository undefined.

  • Step 3: Add the state field. In state.dart, add to the class + constructor + copyWith + props:

final List<Goal> familyGoals;

Constructor: this.familyGoals = const <Goal>[],. In copyWith: List<Goal>? familyGoals,familyGoals: familyGoals ?? this.familyGoals,. Add familyGoals to the props list. Add import 'package:client_sdk/client_sdk.dart'; if Goal isn't already visible (it exports Goal/GoalScope).

  • Step 4: Add the bloc dependency. In bloc.dart, add constructor param required GoalsRepository goalsRepository, and field final GoalsRepository _goalsRepository; (assign in the initializer list). Import the repo: import '../../../outside/repositories/goals/goals_repository.dart'; (match sibling repo import style).

  • Step 5: Wire the construction site. In home/page.dart wrappedRoute() where TodayChoresBloc(...) is built, add goalsRepository: context.read<GoalsRepository>(), (confirm GoalsRepository is provided above the Home route — it is used by the Money/Goals surfaces; if not in scope, add it to the same provider block the other repos come from).

  • Step 6: Update the test harness. In today_chores_bloc_test.dart setUp, add late MockGoalsRepository goals; + goals = MockGoalsRepository(); + a default stub when(() => goals.getGoals(memberId: any(named: 'memberId'))).thenAnswer((_) async => const <Goal>[]); and pass goalsRepository: goals, in the build() factory. (Import MockGoalsRepository from test/util/mocks/repositories.dart — it already exists there.)

  • Step 7: Run — expect PASS Run: cd app && fvm flutter test test/unit/today_chores_bloc_test.dart Expected: PASS (all existing + the new default test).

  • Step 8: Commit

git add app/lib/inside/blocs/today_chores/state.dart app/lib/inside/blocs/today_chores/bloc.dart app/lib/inside/routes/authenticated/home/page.dart app/test/unit/today_chores_bloc_test.dart
git commit -m "feat: inject GoalsRepository + add familyGoals to Today state (Phase A scaffolding)"

Task 2: _deriveViewerScope() — role-based participants + family goals in _onStarted

Files:

  • Modify: app/lib/inside/blocs/today_chores/bloc.dart (add helper; call in _onStarted)
  • Test: app/test/unit/today_chores_role_scope_test.dart (new)

Interfaces:

  • Consumes: _authenticatedMember (HouseholdMember?), _participants, _members, and the freshly-built choreMembers map (Map<String, List<HouseholdMember>>) + expectations (List<Chore>); _goalsRepository.getGoals().

  • Produces: a private record ({List<HouseholdMember> participants, List<Goal> familyGoals}) _deriveViewerScope(...); _onStarted emits state with those two values.

  • Step 1: Write the failing tests — new file app/test/unit/today_chores_role_scope_test.dart, mirroring the today_chores_bloc_test.dart harness (same build(), same setUp stubs, seed via seedMember/seedChore). Seed a household: admin (parent, roles {admin}, owner true), coParent (coParent, roles {member} — parental but NOT admin), kidA (child, roles {member}), kidB (child, roles {member}); an expectation homework assigned to kidA.

test('admin viewer sees the whole family, no goals section', () async {
when(currentMember.current).thenAnswer((_) async => admin);
when(() => goals.getGoals(memberId: any(named: 'memberId')))
.thenAnswer((_) async => [familyGoal]); // must be ignored for a manager
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.participants.map((m) => m.id),
containsAll([admin.id, coParent.id, kidA.id, kidB.id]));
expect(s.familyGoals, isEmpty);
await bloc.close();
});

test('non-admin parent (co-parent) also sees the whole family', () async {
when(currentMember.current).thenAnswer((_) async => coParent); // parental, role member
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.participants.map((m) => m.id),
containsAll([admin.id, coParent.id, kidA.id, kidB.id]));
expect(s.familyGoals, isEmpty);
await bloc.close();
});

test('plain member viewer (child) sees only self + family goals', () async {
when(currentMember.current).thenAnswer((_) async => kidA); // child, non-admin, non-parental
when(() => goals.getGoals(memberId: any(named: 'memberId')))
.thenAnswer((_) async => [familyGoal, memberGoal]); // only the family goal surfaces
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.participants.map((m) => m.id), [kidA.id]);
expect(s.familyGoals.map((g) => g.id), [familyGoal.id]);
await bloc.close();
});

test('null viewer passes through the full roster (unchanged), no goals', () async {
when(currentMember.current).thenAnswer((_) async => null);
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.participants.map((m) => m.id),
containsAll([admin.id, coParent.id, kidA.id, kidB.id]));
expect(s.familyGoals, isEmpty);
await bloc.close();
});

Seed helpers for goals: familyGoal = Goal(id: 'g-fam', householdId: hhId, memberId: null, name: 'Trip', targetTokens: 100, scope: GoalScope.family, status: GoalStatus.active, ...); memberGoal with scope: GoalScope.member, memberId: kidA.id. (Match the real Goal ctor — check goal.dart for required fields.)

  • Step 2: Run — expect FAIL (participants not yet scoped; goals not loaded) Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart Expected: FAIL on the four assertions.

  • Step 3: Implement _deriveViewerScope() in bloc.dart:

Future<({List<HouseholdMember> participants, List<Goal> familyGoals})>
_deriveViewerScope({
required HouseholdMember? viewer,
required List<HouseholdMember> allParticipants,
}) async {
// Managers (admin/owner/parent) AND the null-viewer default see the whole
// family — the full roster, no goals section.
final isManager = viewer == null ||
viewer.isAdmin ||
viewer.owner ||
viewer.kind.isParental;
if (isManager) {
return (participants: allParticipants, familyGoals: const <Goal>[]);
}
// Plain member (child or non-admin non-parent adult): self + family goals.
final goals = await _goalsRepository.getGoals();
final family = goals
.where((g) => g.scope == GoalScope.family && g.memberId == null)
.toList();
return (participants: [viewer], familyGoals: family);
}

(viewer.isAdmin is the HouseholdMemberAccessX extension = roles.contains(MemberRole.admin); viewer.kind.isParental = parent || coParent.)

  • Step 4: Call it in _onStarted. After choreMembers/expectations are built and _authenticatedMember is resolved, and in the MASTER-view path (when tailoredId() is null — do NOT run inside _tailor()), replace the participants that get emitted with the derived scope, and set familyGoals:
final scope = await _deriveViewerScope(
viewer: _authenticatedMember,
allParticipants: _participants,
);
// emit(state.copyWith(participants: scope.participants, familyGoals: scope.familyGoals, ...))

Keep masterParticipants = the un-narrowed _participants (print menu must stay full — do NOT narrow the master* sets).

  • Step 5: Run — expect PASS Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart Expected: PASS (all four).

  • Step 6: Run the whole Today suite — no regressions Run: cd app && fvm flutter test test/unit/today_chores_bloc_test.dart test/unit/today_chores_lens_test.dart Expected: PASS. (If a pre-existing test assumed the full roster for an authenticated admin, update it to the new scoped expectation and note it in the commit body.)

  • Step 7: Commit

git add app/lib/inside/blocs/today_chores/bloc.dart app/test/unit/today_chores_role_scope_test.dart
git commit -m "feat: role-scope Today participants + family goals in _onStarted (Phase A)"

Task 3: Wire the same scope into re-emit paths (_onChoresChanged, _onViewingAsChanged)

Files:

  • Modify: app/lib/inside/blocs/today_chores/bloc.dart
  • Test: app/test/unit/today_chores_role_scope_test.dart (extend)

Interfaces: Consumes _deriveViewerScope (Task 2). Produces: consistent participants/familyGoals after TodayChoresChanged and TodayViewingAsChanged re-emits.

  • Step 1: Failing test — extend the role-scope test (the meaningful case is a plain member staying narrowed across a re-emit; managers always see all):
test('member scope survives a chores re-emit (stays narrowed to self + goals)', () async {
when(currentMember.current).thenAnswer((_) async => kidA); // plain member
when(() => goals.getGoals(memberId: any(named: 'memberId')))
.thenAnswer((_) async => [familyGoal]);
final bloc = build();
bloc.add(const TodayChoresStarted());
await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
bloc.add(TodayChoresChanged(chores: [homework])); // watch re-emit
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.participants.map((m) => m.id), [kidA.id]);
expect(s.familyGoals.map((g) => g.id), [familyGoal.id]);
await bloc.close();
});
  • Step 2: Run — expect FAIL (re-emit path still emits the full roster) Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart

  • Step 3: Implement — in _onChoresChanged and _onViewingAsChanged, at the master-view emit (where they currently set participants), call _deriveViewerScope(...) the same way as _onStarted and emit participants/familyGoals from it. Extract a tiny private Future<void> _emitScoped(...) if it reduces duplication (DRY), else inline the identical call.

  • Step 4: Run — expect PASS Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart

  • Step 5: Commit

git add app/lib/inside/blocs/today_chores/bloc.dart app/test/unit/today_chores_role_scope_test.dart
git commit -m "feat: apply Today role-scope on chores/viewing-as re-emit (Phase A)"

Task 4: Viewer-relative isParent (approvals visibility per viewer)

Files:

  • Modify: app/lib/inside/blocs/today_chores/bloc.dart:156-160 (the isParent TODO)
  • Test: app/test/unit/today_chores_role_scope_test.dart (extend)

Interfaces: Produces: state.isParent = _authenticatedMember?.kind.isParental ?? false (master view), which drives state.showApprovals.

  • Step 1: Failing tests:
test('admin parent viewer sees approvals section', () async {
when(currentMember.current).thenAnswer((_) async => admin); // parent
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.showApprovals, isTrue);
await bloc.close();
});

test('plain child member viewer does not see approvals', () async {
when(currentMember.current).thenAnswer((_) async => kidA); // child, non-parental
final bloc = build();
bloc.add(const TodayChoresStarted());
final s = await bloc.stream.firstWhere((s) => s.status == TodayChoresStatus.loadSuccess);
expect(s.showApprovals, isFalse);
await bloc.close();
});
  • Step 2: Run — expect FAIL (isParent is currently household-level: any parental member exists → true even for a kid viewer) Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart

  • Step 3: Implement — replace bloc.dart:160 final isParent = members.any((m) => m.kind.isParental); with:

final isParent = _authenticatedMember?.kind.isParental ?? false;

Remove the now-stale step-3 TODO comment (bloc.dart:156-159).

  • Step 4: Run — expect PASS, then the full Today suite: Run: cd app && fvm flutter test test/unit/today_chores_role_scope_test.dart test/unit/today_chores_bloc_test.dart test/unit/today_chores_lens_test.dart Expected: PASS. Update any pre-existing test that assumed household-level isParent (e.g. a test with a null viewer expecting approvals) — for a null viewer showApprovals is now false in master view; adjust the seed to set an authenticated parent where the test intends the parent master view. Note each such change in the commit body.

  • Step 5: Commit

git add app/lib/inside/blocs/today_chores/bloc.dart app/test/unit/today_chores_role_scope_test.dart app/test/unit/today_chores_bloc_test.dart
git commit -m "feat: viewer-relative isParent for Today approvals (Phase A)"

Task 5: Render family goals on the Today page for members

Files:

  • Modify: app/lib/inside/routes/authenticated/home/page.dart (add _FamilyGoalsSection)
  • Modify: app/lib/inside/i18n/strings.dart (title string)
  • Test: app/test/widget/today_family_goals_test.dart (new widget test)

Interfaces: Consumes TodayChoresState.familyGoals. Produces: a section rendered only when familyGoals is non-empty.

  • Step 1: Failing widget test — new app/test/widget/today_family_goals_test.dart using the app's widget-test harness (testAppBuilder/MocksContainer — mirror an existing app/test/widget/today_*_test.dart file's setup). Pump the Home page with a member viewer whose state carries one familyGoal; assert the section title + goal name render; then pump with an admin viewer (empty familyGoals) and assert the section is absent (findsNothing).
testWidgets('family goals section shows for a member with family goals', (t) async {
// seed state.familyGoals = [familyGoal] via the mocked TodayChoresBloc / repo stubs
await t.pumpWidget(/* testAppBuilder Home with member viewer */);
await t.pumpAndSettle();
expect(find.text(Strings.todayFamilyGoalsTitle), findsOneWidget);
expect(find.text('Trip'), findsOneWidget);
});

testWidgets('family goals section is absent for an admin viewer', (t) async {
await t.pumpWidget(/* Home with admin viewer, familyGoals empty */);
await t.pumpAndSettle();
expect(find.text(Strings.todayFamilyGoalsTitle), findsNothing);
});
  • Step 2: Run — expect FAIL (no section, no string) Run: cd app && fvm flutter test test/widget/today_family_goals_test.dart

  • Step 3: Add the string in strings.dart: static const String todayFamilyGoalsTitle = 'Family goals'; (match the existing Strings idiom/section).

  • Step 4: Implement _FamilyGoalsSection in page.dart — a private widget consuming state.familyGoals, rendered in the page Column after the bounties/approvals sections, guarded if (state.familyGoals.isNotEmpty). Use a DsSection (matching sibling sections) with the title Strings.todayFamilyGoalsTitle and a row per goal (emoji/name/targetTokens progress) reusing existing goal-display atoms if present (check the Money/Goals surface for a reusable goal tile; if none is cheaply reusable, a simple DS row is fine). Add it to the master Column at page.dart:301-318.

  • Step 5: Run — expect PASS Run: cd app && fvm flutter test test/widget/today_family_goals_test.dart

  • Step 6: Commit

git add app/lib/inside/routes/authenticated/home/page.dart app/lib/inside/i18n/strings.dart app/test/widget/today_family_goals_test.dart
git commit -m "feat: family goals section on Today for members (Phase A)"

Task 6: Phase-A verification + graphify

Files: none (verification)

  • Step 1: Full app suitecd app && fvm flutter test → EXPECT All tests passed!, count ≥ 757 (grows by the Phase-A tests). Run from app/.
  • Step 2: Analyzecd app && fvm flutter analyze lib/inside/blocs/today_chores lib/inside/routes/authenticated/home → EXPECT 0 issues. fvm dart format touched files.
  • Step 3: graphifygraphify update . at the repo root.
  • Step 4: Confirm staging hygienegit status shows no stray generated files staged.

Self-review (spec coverage)

  • Manager (admin/owner/parent) → whole family — Task 2 (admin test + non-admin co-parent test both assert the full roster). ✅
  • plain member (child / non-admin non-parent adult) → own + family goals — Task 2 (child test) + Task 5 (render). ✅
  • role logic in bloc, widget stays pure — Tasks 2-4 (bloc), Task 5 (widget only renders familyGoals). ✅
  • _tailor() untouched — Global Constraints + Task 2 Step 4 note. ✅
  • approvals per viewer — Task 4. ✅
  • Baseline holds / graphify / hygiene — Task 6. ✅

Confirmed by owner (2026-07-22): admins/parents see the whole family on Today; only a plain non-admin non-parent member is narrowed to self + family goals.