Streaks — feature architecture
BUILT (Wave-2 A4, commit 8165c2f). The weekly active streak is a pure projection function (
computeWeeklyStreakCount) overChoreCompletiontimestamps. No new schema. No new SDK verb beyond theClient.getCompletionscall already added for the achievement badges. Rendered asMemberStreakSectionon the member profile page, sharing theAchievementsCubitalready provided there.
The loop in one idea
Streak = the number of consecutive ISO weeks (Monday-start, viewer-local timezone) in which the member completed at least one chore, counted backwards from the most recent active week.
The projection is forgiving by construction: a member who completed chores last week but not yet this week shows their prior run count — not zero. Only a fully-elapsed week with zero completions stops the count. Copy invites; it never shames.
completionTimestamps (List<DateTime>, from ChoreCompletion.completedAt)
│
▼
computeWeeklyStreakCount(timestamps, referenceNow: now)
│
│ 1. bucket each timestamp into its Monday-start ISO week (viewer-local)
│ 2. if current week has 0 completions → grace-skip to prior week
│ 3. count consecutive weeks backwards until a gap is found
│
▼
int streakWeeks (0 = no streak)
│
▼
StreakLevel.forWeeks(streakWeeks) (null at 0, else one of 4 levels)
│
▼
MemberStreakSection ──▶ level name + growth glyph + "N weeks going" pill
1 · Correctness invariant: completionTimestamps sources from ChoreCompletion
completionTimestamps comes from client.getCompletions(memberId: memberId),
mapping ChoreCompletion.completedAt — the same source as completionCount in
the achievements projection. This means expectation chores (zero-token) count
toward the streak exactly as they count toward the badge thresholds. A child
who completes household expectations every week maintains their streak even if
they have never earned a token. A ledger-sourced timestamp set would silently
exclude every expectation completion.
Entries with a null completedAt are silently dropped — the field is optional
in the storage schema. Missing entries under-count the streak but never crash.
2 · ISO week definition and timezone handling
Weeks are Monday-start ISO weeks computed in the viewer's local timezone
via DateTime.toLocal().
Rationale: Monday aligns with ISO-8601 week conventions; viewer-local time
means a completion at 11 PM Sunday local counts in the viewer's Sunday week,
not Monday UTC. This matches the ageAsOf convention already established for
HouseholdMember DOB handling. DST transitions are handled correctly because
mondayOfWeek operates on local weekday and day numbers, never UTC.
DateTime mondayOfWeek(DateTime localDate) {
final d = localDate;
return DateTime(d.year, d.month, d.day - (d.weekday - 1));
}
The returned DateTime is midnight (00:00:00) on the Monday of that week.
The Set<DateTime> of active week-Mondays is used for O(1) membership checks.
3 · The forgiving rule (grace skip)
The grace skip happens at most once — to the previous week. It is NOT recursive: if both the current week and the previous week have zero completions, the count starts from the week before that gap and will immediately stop at the first empty week, returning 0 or whatever consecutive run exists before the gap.
referenceNow is injectable for deterministic unit tests. All internal
computation converts to local time before week-key derivation.
4 · StreakLevel — four named levels
enum StreakLevel {
findingMyGroove, // 1–6 consecutive weeks 🌱
gettingStronger, // 7–29 consecutive weeks 🌿
onARoll, // 30–89 consecutive weeks 🌳
habitChampion, // 90+ consecutive weeks ⭐
}
StreakLevel.forWeeks(weeks) returns null at 0 (no streak). The
growth-metaphor glyphs are chosen by MemberStreakSection; the level names
are resolved from Strings (i18n). No red, no shame language anywhere in the
level copy.
5 · MemberStreakSection — the rendered surface
MemberStreakSection
│
└── BlocBuilder<AchievementsCubit, AchievementsState>
│ hidden while loading or on failure
│
└── DsSection(title: Strings.memberStreakSectionTitle)
└── Row
├── growth glyph (ExcludeSemantics — decorative)
│ level != null → _streakLevelGlyph(level)
│ level == null → '🌱' (neutral, invitational)
├── Column
│ ├── level name (Strings.streakLevelX) or Strings.streakLapsedInvite
│ └── "N weeks going" sub-line (only when weeks > 0)
└── DsTag pill showing weeks count (only when weeks > 0)
Semantics label = Strings.streakWeeksGoing(weeks)
The section shares the AchievementsCubit already provided by
MemberProfilePage.wrappedRoute — no separate bloc or provider. memberId is
accepted in the constructor for explicitness and potential future needs (e.g. a
refresh button) but is not used at render time; the cubit was already loaded
with it upstream.
The DsTag pill uses DsTagKind.active and carries a Semantics wrapper
that announces the full "N weeks going" string for assistive technology,
while ExcludeSemantics prevents the tag label from being read twice.
6 · Integration with achievements
AchievementsCubit.load computes streakWeeks and the streak_starter badge
from the same computeWeeklyStreakCount call with the same referenceNow:
final now = DateTime.now();
final streakWeeks = computeWeeklyStreakCount(
data.completionTimestamps,
referenceNow: now,
);
final all = _computeBadges(data, streakWeeks);
This single shared computation ensures the streak display in MemberStreakSection
and the streak_starter badge criteria (≥ 4 weeks) are always in sync —
no two independent projections can diverge.
Relationship to other features
- Achievements —
AchievementsCubitowns and coordinates the streak projection;streakWeeksand thestreak_starterbadge come from the same call. Read achievements for the full correctness note oncompletionCountsourcing. - Member profile —
MemberStreakSectionis a sub-widget of the member profile page and depends on theAchievementsCubitprovided there. - Token economy — streak is driven by
ChoreCompletionrows, not the token ledger, so expectation chores (zero token, no ledger entry) contribute to the streak correctly. - Celebration — the same
ChoreCompletionrows the celebration fires on are what feed streak computation; a future mechanic could react to a streak milestone in the celebration overlay.