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 bareflutter/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 FlutterTabBar. - Explicit
git add(never-A/.). Do NOT run build_runner (no codegen models change;TodayChoresStateis a plain Equatable class, not@JsonSerializable— confirm before assuming). Do NOT stage straystate.g.dart. - Do NOT modify
_tailor()(the lens path). Role personalization runs in the master view (whentailoredId()is null).
Behavior spec (the single rule)
Given the authenticated viewer v (_authenticatedMember, may be null):
vis a manager —v.isAdmin || v.owner || v.kind.isParental(admin role OR owner OR a parent/co-parent) — ORv == null→ pass-through:participants= full assignable roster (the whole family — every member),familyGoals = []. Admins/parents see all.vis 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— addGoalsRepositorydep; add_deriveViewerScope(); wire into_onStarted/_onChoresChanged/_onViewingAsChanged; resolve viewer-relativeisParent.app/lib/inside/blocs/today_chores/state.dart— addList<Goal> familyGoalsfield (defaultconst [], in ctor/copyWith/props).app/lib/inside/routes/authenticated/home/page.dart— passGoalsRepositoryinto the bloc; add a_FamilyGoalsSectionrendered whenstate.familyGoalsis non-empty.app/lib/inside/i18n/strings.dart— family-goals section title string.app/test/unit/today_chores_bloc_test.dart(+ newtoday_chores_role_scope_test.dart) — role-scope + family-goals bloc tests; updatebuild()/setUpfor the new dep.app/test/util/mocks/repositories.dart—MockGoalsRepositoryalready 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(updatebuild()/setUp; assert default)
Interfaces:
-
Consumes:
GoalsRepository(app/lib/outside/repositories/goals/goals_repository.dart) —Future<List<Goal>> getGoals({String? memberId, bool includeArchived = false});GoalwithGoalScope scope(family/member) andString? memberId. -
Produces:
TodayChoresState.familyGoals(List<Goal>, defaultconst []);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.dartExpected: FAIL —familyGoals/goalsRepositoryundefined. -
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 paramrequired GoalsRepository goalsRepository,and fieldfinal 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.dartwrappedRoute()whereTodayChoresBloc(...)is built, addgoalsRepository: context.read<GoalsRepository>(),(confirmGoalsRepositoryis 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.dartsetUp, addlate MockGoalsRepository goals;+goals = MockGoalsRepository();+ a default stubwhen(() => goals.getGoals(memberId: any(named: 'memberId'))).thenAnswer((_) async => const <Goal>[]);and passgoalsRepository: goals,in thebuild()factory. (ImportMockGoalsRepositoryfromtest/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.dartExpected: 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-builtchoreMembersmap (Map<String, List<HouseholdMember>>) +expectations(List<Chore>);_goalsRepository.getGoals(). -
Produces: a private record
({List<HouseholdMember> participants, List<Goal> familyGoals}) _deriveViewerScope(...);_onStartedemits state with those two values. -
Step 1: Write the failing tests — new file
app/test/unit/today_chores_role_scope_test.dart, mirroring thetoday_chores_bloc_test.dartharness (samebuild(), samesetUpstubs, seed viaseedMember/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 expectationhomeworkassigned tokidA.
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.dartExpected: FAIL on the four assertions. -
Step 3: Implement
_deriveViewerScope()inbloc.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. AfterchoreMembers/expectationsare built and_authenticatedMemberis resolved, and in the MASTER-view path (whentailoredId()is null — do NOT run inside_tailor()), replace theparticipantsthat get emitted with the derived scope, and setfamilyGoals:
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.dartExpected: 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.dartExpected: 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
_onChoresChangedand_onViewingAsChanged, at the master-view emit (where they currently setparticipants), call_deriveViewerScope(...)the same way as_onStartedand emitparticipants/familyGoalsfrom it. Extract a tiny privateFuture<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(theisParentTODO) - 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:160final 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.dartExpected: PASS. Update any pre-existing test that assumed household-levelisParent(e.g. a test with a null viewer expecting approvals) — for a null viewershowApprovalsis 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.dartusing the app's widget-test harness (testAppBuilder/MocksContainer— mirror an existingapp/test/widget/today_*_test.dartfile's setup). Pump the Home page with a member viewer whose state carries onefamilyGoal; assert the section title + goal name render; then pump with an admin viewer (emptyfamilyGoals) 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
_FamilyGoalsSectioninpage.dart— a private widget consumingstate.familyGoals, rendered in the page Column after the bounties/approvals sections, guardedif (state.familyGoals.isNotEmpty). Use aDsSection(matching sibling sections) with the titleStrings.todayFamilyGoalsTitleand a row per goal (emoji/name/targetTokensprogress) 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 atpage.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 suite —
cd app && fvm flutter test→ EXPECTAll tests passed!, count ≥ 757 (grows by the Phase-A tests). Run fromapp/. - Step 2: Analyze —
cd app && fvm flutter analyze lib/inside/blocs/today_chores lib/inside/routes/authenticated/home→ EXPECT 0 issues.fvm dart formattouched files. - Step 3: graphify —
graphify update .at the repo root. - Step 4: Confirm staging hygiene —
git statusshows 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.