Skip to main content

Streaks — feature architecture

BUILT (Wave-2 A4, commit 8165c2f). The weekly active streak is a pure projection function (computeWeeklyStreakCount) over ChoreCompletion timestamps. No new schema. No new SDK verb beyond the Client.getCompletions call already added for the achievement badges. Rendered as MemberStreakSection on the member profile page, sharing the AchievementsCubit already 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

  • AchievementsAchievementsCubit owns and coordinates the streak projection; streakWeeks and the streak_starter badge come from the same call. Read achievements for the full correctness note on completionCount sourcing.
  • Member profileMemberStreakSection is a sub-widget of the member profile page and depends on the AchievementsCubit provided there.
  • Token economy — streak is driven by ChoreCompletion rows, not the token ledger, so expectation chores (zero token, no ledger entry) contribute to the streak correctly.
  • Celebration — the same ChoreCompletion rows the celebration fires on are what feed streak computation; a future mechanic could react to a streak milestone in the celebration overlay.