feat(achievements): definitions for v1.4.18 expanded set + hidden Easter-eggs

Lifts the roster from 38 to 59. Adds three buckets:

- mood (4 badges): first entry, 7-day diarist, 30-day diarist,
  brighter-week (mood mean improved by ≥1.0 vs prior 7-day mean)
- vitals counts (7): first/50/200 weigh-ins, first/50/200 BP readings,
  first pulse log
- engagement (4): consistent month (≥25 distinct days in a calendar
  month), entry-streak 7d/30d, weekend warrior

Plus 6 hidden Easter-eggs: night-owl, early-bird, leap-day,
doctor-PDF export, locale-flip, bug-buddy (≥5 reports). Hidden
achievements never leak their conditions to the DOM — locked cards
paint a generic "Hidden achievement" placeholder.

Adds `EarnabilityFlags` + `applyDiscoveryFilter` so a public locked
achievement only renders when the user has the underlying data
(e.g. a mood-streak badge stays hidden until they log their first
mood). Hidden Easter-eggs ignore the flags. Already-unlocked badges
always render so deleting data doesn't make completed achievements
disappear.

EN+DE strings for every new badge plus the hidden-card placeholder
copy and the hidden-unlock toast strings. CATEGORY_LABEL_KEY now
covers mood + hidden so the discriminated-union check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:34:32 +02:00
1 parent e73813ad7c
commit 19b08442b4
8 files changed
+1183 -67

No files matched your search

+96 -1
View File
@@ -2131,6 +2131,90 @@
"pulseGreen360": {
"title": "Puls im Grün · 360T",
"description": "Halte deinen Ruhepuls 360 Tage in Folge im Normalbereich."
},
"moodFirst": {
"title": "Erste Stimmung",
"description": "Erfasse deinen ersten Stimmungseintrag."
},
"moodStreak7": {
"title": "Stimmungstagebuch 7T",
"description": "Trage an 7 aufeinanderfolgenden Tagen eine Stimmung ein."
},
"moodStreak30": {
"title": "Stimmungstagebuch 30T",
"description": "Trage an 30 aufeinanderfolgenden Tagen eine Stimmung ein."
},
"moodUp7": {
"title": "Hellere Woche",
"description": "Dein 7-Tage-Stimmungsmittel hat sich gegenüber der Vorwoche um mindestens 1,0 Punkt verbessert."
},
"weightFirst": {
"title": "Erstes Gewicht",
"description": "Erfasse deine erste Gewichtsmessung."
},
"weight50": {
"title": "Fünfzig Wiegungen",
"description": "Erfasse 50 Gewichtsmessungen."
},
"weight200": {
"title": "Zweihundert Wiegungen",
"description": "Erfasse 200 Gewichtsmessungen."
},
"bpFirst": {
"title": "Erster Blutdruck",
"description": "Erfasse deine erste Blutdruckmessung."
},
"bp50": {
"title": "Fünfzig Blutdruckmessungen",
"description": "Erfasse 50 Blutdruckmessungen."
},
"bp200": {
"title": "Zweihundert Blutdruckmessungen",
"description": "Erfasse 200 Blutdruckmessungen."
},
"pulseFirst": {
"title": "Erster Puls",
"description": "Erfasse deine erste Pulsmessung."
},
"consistentMonth": {
"title": "Konsequenter Monat",
"description": "Trage in einem Kalendermonat an mindestens 25 verschiedenen Tagen Daten ein."
},
"entryStreak7": {
"title": "Tracking-Serie 7T",
"description": "Trage an 7 aufeinanderfolgenden Tagen mindestens einen Eintrag ein."
},
"entryStreak30": {
"title": "Tracking-Serie 30T",
"description": "Trage an 30 aufeinanderfolgenden Tagen mindestens einen Eintrag ein."
},
"weekendWarrior": {
"title": "Wochenend-Tracker",
"description": "Trage an 4 aufeinanderfolgenden Samstag-Sonntag-Wochenenden Daten ein."
},
"hiddenNightOwl": {
"title": "Nachteule",
"description": "Du hast einen Eintrag zwischen 02:00 und 04:00 Uhr morgens erfasst."
},
"hiddenEarlyBird": {
"title": "Frühaufsteher",
"description": "Du hast einen Eintrag zwischen 04:00 und 06:00 Uhr morgens erfasst."
},
"hiddenLeapDay": {
"title": "Schalttag-Legende",
"description": "Du hast einen Eintrag am 29. Februar erfasst."
},
"hiddenDoctorPdf": {
"title": "Hausbesuch",
"description": "Du hast erstmals einen Arzt-Report als PDF exportiert."
},
"hiddenLocaleFlip": {
"title": "Polyglott",
"description": "Du hast die App-Sprache mindestens einmal gewechselt."
},
"hiddenBugBuddy": {
"title": "Bug-Kumpel",
"description": "Mindestens 5 Bugreports eingereicht — das Projekt liebt dich."
}
},
"nextProgressLabel": "Fortschritt bis Freischaltung",
@@ -2141,8 +2225,19 @@
"categories": {
"medication": "Medikation",
"vitals": "Vitalwerte",
"mood": "Stimmung",
"security": "Konto & Sicherheit",
"engagement": "Engagement"
"engagement": "Engagement",
"hidden": "Versteckt"
},
"hiddenCard": {
"title": "Versteckter Erfolg",
"description": "Tracke fleißig weiter — vielleicht stolperst du darüber.",
"ariaLabel": "Versteckter, gesperrter Erfolg"
},
"hiddenUnlockToast": {
"title": "Du hast einen versteckten Erfolg freigeschaltet!",
"description": "Tippe, um die Überraschung zu sehen."
},
"dashboardCard": {
"title": "Zuletzt freigeschaltet",
+96 -1
View File
@@ -2131,6 +2131,90 @@
"pulseGreen360": {
"title": "Pulse green · 360d",
"description": "Keep your resting pulse in the normal range for 360 consecutive days."
},
"moodFirst": {
"title": "First mood",
"description": "Log your first mood entry."
},
"moodStreak7": {
"title": "Mood diarist 7d",
"description": "Log a mood entry on 7 consecutive days."
},
"moodStreak30": {
"title": "Mood diarist 30d",
"description": "Log a mood entry on 30 consecutive days."
},
"moodUp7": {
"title": "Brighter week",
"description": "Your 7-day mood average improved by at least 1.0 point compared to the previous week."
},
"weightFirst": {
"title": "First weigh-in",
"description": "Record your first weight measurement."
},
"weight50": {
"title": "Fifty weigh-ins",
"description": "Record 50 weight measurements."
},
"weight200": {
"title": "Two hundred weigh-ins",
"description": "Record 200 weight measurements."
},
"bpFirst": {
"title": "First reading",
"description": "Record your first blood-pressure measurement."
},
"bp50": {
"title": "Fifty BP readings",
"description": "Record 50 blood-pressure measurements."
},
"bp200": {
"title": "Two hundred BP readings",
"description": "Record 200 blood-pressure measurements."
},
"pulseFirst": {
"title": "First pulse",
"description": "Record your first pulse measurement."
},
"consistentMonth": {
"title": "Consistent month",
"description": "Log entries on at least 25 distinct days within a single calendar month."
},
"entryStreak7": {
"title": "Tracker streak 7d",
"description": "Log at least one entry on 7 consecutive days."
},
"entryStreak30": {
"title": "Tracker streak 30d",
"description": "Log at least one entry on 30 consecutive days."
},
"weekendWarrior": {
"title": "Weekend tracker",
"description": "Log entries on 4 consecutive Saturday + Sunday weekend pairs."
},
"hiddenNightOwl": {
"title": "Night owl",
"description": "Logged an entry between 02:00 and 04:00 in the morning."
},
"hiddenEarlyBird": {
"title": "Early bird",
"description": "Logged an entry between 04:00 and 06:00 in the morning."
},
"hiddenLeapDay": {
"title": "Leap-day legend",
"description": "Logged an entry on February 29."
},
"hiddenDoctorPdf": {
"title": "House call",
"description": "Exported your first doctor-report PDF."
},
"hiddenLocaleFlip": {
"title": "Polyglot",
"description": "Switched the app language at least once."
},
"hiddenBugBuddy": {
"title": "Bug buddy",
"description": "Submitted at least 5 bug reports — the project loves you."
}
},
"nextProgressLabel": "Progress to unlock",
@@ -2141,8 +2225,19 @@
"categories": {
"medication": "Medication",
"vitals": "Vitals",
"mood": "Mood",
"security": "Account & security",
"engagement": "Engagement"
"engagement": "Engagement",
"hidden": "Hidden"
},
"hiddenCard": {
"title": "Hidden achievement",
"description": "Keep tracking — you might just stumble across it.",
"ariaLabel": "Hidden locked achievement"
},
"hiddenUnlockToast": {
"title": "You unlocked a hidden achievement!",
"description": "Tap to see the surprise."
},
"dashboardCard": {
"title": "Recent unlocks",
@@ -57,6 +57,7 @@ const mockData = {
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-15T12:00:00.000Z",
isHidden: false,
},
{
id: "intake-total-10",
@@ -72,6 +73,7 @@ const mockData = {
unlocked: false,
progressPercent: 40,
completedAt: null,
isHidden: false,
},
{
id: "passkey-created-1",
@@ -87,6 +89,7 @@ const mockData = {
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: false,
},
] satisfies AchievementProgress[],
metrics: {
@@ -103,6 +106,20 @@ const mockData = {
passwordLoginCount: 0,
loginDayStreak: 0,
bugReportCount: 0,
moodEntryCount: 0,
moodDayStreak: 0,
moodImprovementHit: 0,
weightMeasurementCount: 0,
bpMeasurementCount: 0,
pulseMeasurementCount: 0,
consistentMonthCount: 0,
entryDayStreak: 0,
weekendStreakCount: 0,
nightOwlCount: 0,
earlyBirdCount: 0,
leapDayCount: 0,
doctorPdfCount: 0,
localeFlipCount: 0,
},
};
+2
View File
@@ -61,8 +61,10 @@ const iconMap: Record<string, LucideIcon> = {
const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
medication: "achievements.categories.medication",
vitals: "achievements.categories.vitals",
mood: "achievements.categories.mood",
security: "achievements.categories.security",
engagement: "achievements.categories.engagement",
hidden: "achievements.categories.hidden",
};
function formatMetric(
@@ -63,6 +63,7 @@ const baseAchievement: AchievementProgress = {
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-15T12:00:00.000Z",
isHidden: false,
};
describe("pickRecentUnlocks", () => {
@@ -1,11 +1,66 @@
import { describe, it, expect } from "vitest";
import {
ACHIEVEMENT_DEFINITIONS,
ACHIEVEMENT_CATEGORY_ORDER,
applyDiscoveryFilter,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
getUniqueBerlinDays,
type AchievementMetrics,
type AchievementProgress,
type EarnabilityFlags,
} from "@/lib/gamification/achievements";
const FULL_METRICS: AchievementMetrics = {
totalTakenIntakes: 1000,
overIntakeCount: 1000,
skippedIntakeCount: 1000,
bmiGreenStreak: 1000,
bpGreenStreak: 1000,
pulseGreenStreak: 1000,
onTimePerfectDayStreak: 1000,
compliance80DayStreak: 1000,
passkeyCreatedCount: 1000,
passkeyLoginCount: 1000,
passwordLoginCount: 1000,
loginDayStreak: 1000,
bugReportCount: 1000,
moodEntryCount: 1000,
moodDayStreak: 1000,
moodImprovementHit: 1000,
weightMeasurementCount: 1000,
bpMeasurementCount: 1000,
pulseMeasurementCount: 1000,
consistentMonthCount: 1000,
entryDayStreak: 1000,
weekendStreakCount: 1000,
nightOwlCount: 1000,
earlyBirdCount: 1000,
leapDayCount: 1000,
doctorPdfCount: 1000,
localeFlipCount: 1000,
};
const ZERO_METRICS: AchievementMetrics = Object.fromEntries(
Object.keys(FULL_METRICS).map((key) => [key, 0]),
) as unknown as AchievementMetrics;
const ALL_EARNABLE: EarnabilityFlags = {
hasMedication: true,
hasMood: true,
hasWeight: true,
hasBp: true,
hasPulse: true,
};
const NONE_EARNABLE: EarnabilityFlags = {
hasMedication: false,
hasMood: false,
hasWeight: false,
hasBp: false,
hasPulse: false,
};
describe("gamification achievements", () => {
it("deduplicates and sorts day keys", () => {
const days = getUniqueBerlinDays([
@@ -29,30 +84,108 @@ describe("gamification achievements", () => {
).toBe(3);
});
it("evaluates unlocked achievements and points", () => {
const result = evaluateAchievementsWithCompletionDates(
{
totalTakenIntakes: 1000,
overIntakeCount: 1000,
skippedIntakeCount: 1000,
bmiGreenStreak: 1000,
bpGreenStreak: 1000,
pulseGreenStreak: 1000,
onTimePerfectDayStreak: 1000,
compliance80DayStreak: 1000,
passkeyCreatedCount: 1000,
passkeyLoginCount: 1000,
passwordLoginCount: 1000,
loginDayStreak: 1000,
bugReportCount: 1000,
},
{},
);
it("ships the v1.4.18 expanded definition list (59 total)", () => {
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(59);
});
expect(result.summary.unlockedCount).toBe(38);
expect(result.summary.totalCount).toBe(38);
it("includes mood and hidden categories in the render order", () => {
expect(ACHIEVEMENT_CATEGORY_ORDER).toContain("mood");
expect(ACHIEVEMENT_CATEGORY_ORDER).toContain("hidden");
});
it("evaluates unlocked achievements and points when all metrics maxed", () => {
const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {});
expect(result.summary.unlockedCount).toBe(59);
expect(result.summary.totalCount).toBe(59);
expect(result.summary.nextAchievement).toBeNull();
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(38);
expect(result.summary.earnedPoints).toBe(result.summary.totalPoints);
});
it("flags hidden achievements with isHidden", () => {
const hidden = ACHIEVEMENT_DEFINITIONS.filter((def) => def.isHidden);
expect(hidden.length).toBeGreaterThanOrEqual(5);
expect(hidden.length).toBeLessThanOrEqual(8);
for (const def of hidden) {
expect(def.category).toBe("hidden");
}
});
it("flags non-hidden achievements with category != hidden", () => {
const visible = ACHIEVEMENT_DEFINITIONS.filter((def) => !def.isHidden);
for (const def of visible) {
expect(def.category).not.toBe("hidden");
}
});
});
describe("applyDiscoveryFilter", () => {
function progressFor(id: string): AchievementProgress {
const def = ACHIEVEMENT_DEFINITIONS.find((d) => d.id === id);
if (!def) throw new Error(`unknown achievement: ${id}`);
return {
id: def.id,
metric: def.metric,
category: def.category,
titleKey: def.tTitle,
descriptionKey: def.tDescription,
icon: def.icon,
format: def.format,
target: def.target,
current: 0,
points: def.points,
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: def.isHidden,
};
}
it("hides public mood achievements when the user has no mood data", () => {
const items = [progressFor("mood-first")];
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
expect(filtered).toHaveLength(0);
});
it("keeps mood achievements when the user has at least one mood entry", () => {
const items = [progressFor("mood-first")];
const filtered = applyDiscoveryFilter(items, {
...NONE_EARNABLE,
hasMood: true,
});
expect(filtered).toHaveLength(1);
});
it("keeps unlocked achievements even if precondition no longer holds (anti-regression)", () => {
const items = [{ ...progressFor("mood-first"), unlocked: true }];
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
expect(filtered).toHaveLength(1);
});
it("always keeps hidden achievements regardless of earnability", () => {
const items = [progressFor("hidden-night-owl")];
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
expect(filtered).toHaveLength(1);
});
it("keeps engagement / security achievements unconditionally", () => {
const items = [progressFor("login-streak-7"), progressFor("passkey-created-1")];
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
expect(filtered).toHaveLength(2);
});
it("keeps medication achievements only when the user has at least one medication", () => {
const items = [progressFor("intake-total-1")];
expect(applyDiscoveryFilter(items, NONE_EARNABLE)).toHaveLength(0);
expect(
applyDiscoveryFilter(items, { ...NONE_EARNABLE, hasMedication: true }),
).toHaveLength(1);
});
it("zero-progress full set with all-earnable still yields full count", () => {
const result = evaluateAchievementsWithCompletionDates(ZERO_METRICS, {});
const filtered = applyDiscoveryFilter(result.achievements, ALL_EARNABLE);
expect(filtered.length).toBe(result.achievements.length);
expect(result.summary.unlockedCount).toBe(0);
});
});
+451 -43
View File
@@ -15,7 +15,25 @@ export type AchievementMetricKey =
| "passkeyLoginCount"
| "passwordLoginCount"
| "loginDayStreak"
| "bugReportCount";
| "bugReportCount"
// v1.4.18 expansion ─ mood
| "moodEntryCount"
| "moodDayStreak"
| "moodImprovementHit"
// v1.4.18 expansion ─ measurement counts
| "weightMeasurementCount"
| "bpMeasurementCount"
| "pulseMeasurementCount"
// v1.4.18 expansion ─ engagement / consistency
| "consistentMonthCount"
| "entryDayStreak"
| "weekendStreakCount"
// v1.4.18 expansion ─ hidden Easter-eggs
| "nightOwlCount"
| "earlyBirdCount"
| "leapDayCount"
| "doctorPdfCount"
| "localeFlipCount";
/**
* Achievement categories — used by the /achievements UI to visually group
@@ -23,12 +41,20 @@ export type AchievementMetricKey =
* not branch on category, and the metric → category mapping is derived in
* `getAchievementCategory` below so a metric is always in exactly one
* group.
*
* v1.4.18 adds two new buckets:
* - `mood` for the new mood-tracking achievements
* - `hidden` for the Easter-egg group (cards always render as opaque
* placeholders until the achievement unlocks; the trigger is never
* leaked to the DOM)
*/
export type AchievementCategory =
| "medication"
| "vitals"
| "mood"
| "security"
| "engagement";
| "engagement"
| "hidden";
export interface AchievementDefinition {
id: string;
@@ -39,9 +65,31 @@ export interface AchievementDefinition {
tTitle: string;
tDescription: string;
format: "count" | "days" | "percent";
/** v1.4.18 — true for hidden Easter-egg achievements. Locked cards
* for these never reveal their title/description; only the unlock
* surfaces the real strings. */
isHidden: boolean;
/** v1.4.18 — derived from the metric for visible categories, hard-
* coded `hidden` for the Easter-egg group. Lives on the definition
* so the discovery filter never has to recompute it. */
category: AchievementCategory;
}
export function getAchievementCategory(
/**
* v1.4.18 — discovery flags. Predicate input for
* `applyDiscoveryFilter`. A flag is true iff the user has at least one
* data point for the underlying metric (a medication, a mood entry,
* etc.). Hidden achievements ignore the flags entirely.
*/
export interface EarnabilityFlags {
hasMedication: boolean;
hasMood: boolean;
hasWeight: boolean;
hasBp: boolean;
hasPulse: boolean;
}
function categoryForMetric(
metric: AchievementMetricKey,
): AchievementCategory {
switch (metric) {
@@ -54,27 +102,58 @@ export function getAchievementCategory(
case "bmiGreenStreak":
case "bpGreenStreak":
case "pulseGreenStreak":
case "weightMeasurementCount":
case "bpMeasurementCount":
case "pulseMeasurementCount":
return "vitals";
case "moodEntryCount":
case "moodDayStreak":
case "moodImprovementHit":
return "mood";
case "passkeyCreatedCount":
case "passkeyLoginCount":
case "passwordLoginCount":
return "security";
case "loginDayStreak":
case "bugReportCount":
case "consistentMonthCount":
case "entryDayStreak":
case "weekendStreakCount":
return "engagement";
case "nightOwlCount":
case "earlyBirdCount":
case "leapDayCount":
case "doctorPdfCount":
case "localeFlipCount":
return "hidden";
}
}
/**
* Stable category render order on the /achievements page. Medication is
* the densest category (16 / 38 today) so it goes first; engagement is
* the smallest, last.
* @deprecated kept only for legacy callers; new code should read
* `AchievementDefinition.category` directly. v1.4.18 made category a
* stored field on the definition so hidden Easter-eggs (which share
* metrics with no other badge) don't need a special case.
*/
export function getAchievementCategory(
metric: AchievementMetricKey,
): AchievementCategory {
return categoryForMetric(metric);
}
/**
* Stable category render order on the /achievements page. Mood slots
* between vitals and security so the most-used categories cluster at
* the top. Hidden goes last so opaque cards don't push real progress
* below the fold.
*/
export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [
"medication",
"vitals",
"mood",
"security",
"engagement",
"hidden",
] as const;
export interface AchievementMetrics {
@@ -91,6 +170,20 @@ export interface AchievementMetrics {
passwordLoginCount: number;
loginDayStreak: number;
bugReportCount: number;
moodEntryCount: number;
moodDayStreak: number;
moodImprovementHit: number;
weightMeasurementCount: number;
bpMeasurementCount: number;
pulseMeasurementCount: number;
consistentMonthCount: number;
entryDayStreak: number;
weekendStreakCount: number;
nightOwlCount: number;
earlyBirdCount: number;
leapDayCount: number;
doctorPdfCount: number;
localeFlipCount: number;
}
export interface AchievementProgress {
@@ -107,6 +200,9 @@ export interface AchievementProgress {
unlocked: boolean;
progressPercent: number;
completedAt: string | null;
/** v1.4.18 — mirrored from the definition so the UI can render an
* opaque placeholder when locked. */
isHidden: boolean;
}
export interface AchievementSummary {
@@ -135,20 +231,35 @@ function buildStreakAchievements(config: {
descriptionPrefix: string;
points: readonly [number, number, number, number, number];
}): AchievementDefinition[] {
return STREAK_TARGETS.map((target, index) => ({
id: `${config.idPrefix}-${target}`,
metric: config.metric,
target,
points: config.points[index],
icon: config.icon,
tTitle: `achievements.badges.${config.titlePrefix}${target}.title`,
tDescription: `achievements.badges.${config.descriptionPrefix}${target}.description`,
format: "days",
}));
return STREAK_TARGETS.map((target, index) =>
define({
id: `${config.idPrefix}-${target}`,
metric: config.metric,
target,
points: config.points[index],
icon: config.icon,
tTitle: `achievements.badges.${config.titlePrefix}${target}.title`,
tDescription: `achievements.badges.${config.descriptionPrefix}${target}.description`,
format: "days",
}),
);
}
function define(
partial: Omit<AchievementDefinition, "isHidden" | "category"> & {
isHidden?: boolean;
},
): AchievementDefinition {
const isHidden = partial.isHidden ?? false;
return {
...partial,
isHidden,
category: isHidden ? "hidden" : categoryForMetric(partial.metric),
};
}
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
{
define({
id: "intake-total-1",
metric: "totalTakenIntakes",
target: 1,
@@ -157,8 +268,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.intakeTotal1.title",
tDescription: "achievements.badges.intakeTotal1.description",
format: "count",
},
{
}),
define({
id: "intake-total-10",
metric: "totalTakenIntakes",
target: 10,
@@ -167,8 +278,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.intakeTotal10.title",
tDescription: "achievements.badges.intakeTotal10.description",
format: "count",
},
{
}),
define({
id: "intake-total-50",
metric: "totalTakenIntakes",
target: 50,
@@ -177,8 +288,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.intakeTotal50.title",
tDescription: "achievements.badges.intakeTotal50.description",
format: "count",
},
{
}),
define({
id: "intake-total-150",
metric: "totalTakenIntakes",
target: 150,
@@ -187,8 +298,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.intakeTotal150.title",
tDescription: "achievements.badges.intakeTotal150.description",
format: "count",
},
{
}),
define({
id: "intake-total-300",
metric: "totalTakenIntakes",
target: 300,
@@ -197,8 +308,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.intakeTotal300.title",
tDescription: "achievements.badges.intakeTotal300.description",
format: "count",
},
{
}),
define({
id: "over-intake-1",
metric: "overIntakeCount",
target: 1,
@@ -207,8 +318,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.overIntake1.title",
tDescription: "achievements.badges.overIntake1.description",
format: "count",
},
{
}),
define({
id: "skipped-intake-1",
metric: "skippedIntakeCount",
target: 1,
@@ -217,8 +328,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.skippedIntake1.title",
tDescription: "achievements.badges.skippedIntake1.description",
format: "count",
},
{
}),
define({
id: "passkey-created-1",
metric: "passkeyCreatedCount",
target: 1,
@@ -227,8 +338,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.passkeyCreated1.title",
tDescription: "achievements.badges.passkeyCreated1.description",
format: "count",
},
{
}),
define({
id: "passkey-login-1",
metric: "passkeyLoginCount",
target: 1,
@@ -237,8 +348,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.passkeyLogin1.title",
tDescription: "achievements.badges.passkeyLogin1.description",
format: "count",
},
{
}),
define({
id: "password-login-1",
metric: "passwordLoginCount",
target: 1,
@@ -247,8 +358,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.passwordLogin1.title",
tDescription: "achievements.badges.passwordLogin1.description",
format: "count",
},
{
}),
define({
id: "bugreport-1",
metric: "bugReportCount",
target: 1,
@@ -257,8 +368,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.bugReport1.title",
tDescription: "achievements.badges.bugReport1.description",
format: "count",
},
{
}),
define({
id: "login-streak-7",
metric: "loginDayStreak",
target: 7,
@@ -267,8 +378,8 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.loginStreak7.title",
tDescription: "achievements.badges.loginStreak7.description",
format: "days",
},
{
}),
define({
id: "login-streak-30",
metric: "loginDayStreak",
target: 30,
@@ -277,7 +388,7 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tTitle: "achievements.badges.loginStreak30.title",
tDescription: "achievements.badges.loginStreak30.description",
format: "days",
},
}),
...buildStreakAchievements({
idPrefix: "on-time-perfect",
metric: "onTimePerfectDayStreak",
@@ -318,6 +429,229 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
descriptionPrefix: "pulseGreen",
points: [14, 40, 105, 280, 620],
}),
// ─── v1.4.18 ─ mood (3) ─────────────────────────────────
define({
id: "mood-first",
metric: "moodEntryCount",
target: 1,
points: 8,
icon: "Smile",
tTitle: "achievements.badges.moodFirst.title",
tDescription: "achievements.badges.moodFirst.description",
format: "count",
}),
define({
id: "mood-streak-7",
metric: "moodDayStreak",
target: 7,
points: 50,
icon: "Smile",
tTitle: "achievements.badges.moodStreak7.title",
tDescription: "achievements.badges.moodStreak7.description",
format: "days",
}),
define({
id: "mood-streak-30",
metric: "moodDayStreak",
target: 30,
points: 200,
icon: "Smile",
tTitle: "achievements.badges.moodStreak30.title",
tDescription: "achievements.badges.moodStreak30.description",
format: "days",
}),
define({
id: "mood-up-7",
metric: "moodImprovementHit",
target: 1,
points: 90,
icon: "Sun",
tTitle: "achievements.badges.moodUp7.title",
tDescription: "achievements.badges.moodUp7.description",
format: "count",
}),
// ─── v1.4.18 ─ measurement counts (vitals, 7) ───────────
define({
id: "weight-first",
metric: "weightMeasurementCount",
target: 1,
points: 8,
icon: "Scale",
tTitle: "achievements.badges.weightFirst.title",
tDescription: "achievements.badges.weightFirst.description",
format: "count",
}),
define({
id: "weight-50",
metric: "weightMeasurementCount",
target: 50,
points: 90,
icon: "Scale",
tTitle: "achievements.badges.weight50.title",
tDescription: "achievements.badges.weight50.description",
format: "count",
}),
define({
id: "weight-200",
metric: "weightMeasurementCount",
target: 200,
points: 320,
icon: "Trophy",
tTitle: "achievements.badges.weight200.title",
tDescription: "achievements.badges.weight200.description",
format: "count",
}),
define({
id: "bp-first",
metric: "bpMeasurementCount",
target: 1,
points: 8,
icon: "Heart",
tTitle: "achievements.badges.bpFirst.title",
tDescription: "achievements.badges.bpFirst.description",
format: "count",
}),
define({
id: "bp-50",
metric: "bpMeasurementCount",
target: 50,
points: 90,
icon: "Heart",
tTitle: "achievements.badges.bp50.title",
tDescription: "achievements.badges.bp50.description",
format: "count",
}),
define({
id: "bp-200",
metric: "bpMeasurementCount",
target: 200,
points: 320,
icon: "Trophy",
tTitle: "achievements.badges.bp200.title",
tDescription: "achievements.badges.bp200.description",
format: "count",
}),
define({
id: "pulse-first",
metric: "pulseMeasurementCount",
target: 1,
points: 8,
icon: "Activity",
tTitle: "achievements.badges.pulseFirst.title",
tDescription: "achievements.badges.pulseFirst.description",
format: "count",
}),
// ─── v1.4.18 ─ engagement / consistency (4) ─────────────
define({
id: "consistent-month",
metric: "consistentMonthCount",
target: 1,
points: 140,
icon: "CalendarDays",
tTitle: "achievements.badges.consistentMonth.title",
tDescription: "achievements.badges.consistentMonth.description",
format: "count",
}),
define({
id: "entry-streak-7",
metric: "entryDayStreak",
target: 7,
points: 70,
icon: "Flame",
tTitle: "achievements.badges.entryStreak7.title",
tDescription: "achievements.badges.entryStreak7.description",
format: "days",
}),
define({
id: "entry-streak-30",
metric: "entryDayStreak",
target: 30,
points: 260,
icon: "Flame",
tTitle: "achievements.badges.entryStreak30.title",
tDescription: "achievements.badges.entryStreak30.description",
format: "days",
}),
define({
id: "weekend-warrior",
metric: "weekendStreakCount",
target: 4,
points: 40,
icon: "CalendarCheck",
tTitle: "achievements.badges.weekendWarrior.title",
tDescription: "achievements.badges.weekendWarrior.description",
format: "count",
}),
// ─── v1.4.18 ─ hidden Easter-eggs (6) ───────────────────
// Trigger conditions are not leaked to the user — locked card just
// shows the "Hidden" placeholder. Once unlocked, the title +
// description appear in the toast and on the card.
define({
id: "hidden-night-owl",
metric: "nightOwlCount",
target: 1,
points: 25,
icon: "Moon",
tTitle: "achievements.badges.hiddenNightOwl.title",
tDescription: "achievements.badges.hiddenNightOwl.description",
format: "count",
isHidden: true,
}),
define({
id: "hidden-early-bird",
metric: "earlyBirdCount",
target: 1,
points: 25,
icon: "Sun",
tTitle: "achievements.badges.hiddenEarlyBird.title",
tDescription: "achievements.badges.hiddenEarlyBird.description",
format: "count",
isHidden: true,
}),
define({
id: "hidden-leap-day",
metric: "leapDayCount",
target: 1,
points: 50,
icon: "Sparkles",
tTitle: "achievements.badges.hiddenLeapDay.title",
tDescription: "achievements.badges.hiddenLeapDay.description",
format: "count",
isHidden: true,
}),
define({
id: "hidden-doctor-pdf",
metric: "doctorPdfCount",
target: 1,
points: 35,
icon: "FileText",
tTitle: "achievements.badges.hiddenDoctorPdf.title",
tDescription: "achievements.badges.hiddenDoctorPdf.description",
format: "count",
isHidden: true,
}),
define({
id: "hidden-locale-flip",
metric: "localeFlipCount",
target: 1,
points: 15,
icon: "Languages",
tTitle: "achievements.badges.hiddenLocaleFlip.title",
tDescription: "achievements.badges.hiddenLocaleFlip.description",
format: "count",
isHidden: true,
}),
define({
id: "hidden-bug-buddy",
metric: "bugReportCount",
target: 5,
points: 60,
icon: "Bug",
tTitle: "achievements.badges.hiddenBugBuddy.title",
tDescription: "achievements.badges.hiddenBugBuddy.description",
format: "count",
isHidden: true,
}),
];
function dayKeyToNumber(dayKey: string): number {
@@ -385,7 +719,7 @@ export function evaluateAchievementsWithCompletionDates(
return {
id: definition.id,
metric: definition.metric,
category: getAchievementCategory(definition.metric),
category: definition.category,
titleKey: definition.tTitle,
descriptionKey: definition.tDescription,
icon: definition.icon,
@@ -397,6 +731,7 @@ export function evaluateAchievementsWithCompletionDates(
progressPercent: calculateProgress(current, definition.target),
completedAt:
unlocked && completedAtDate ? completedAtDate.toISOString() : null,
isHidden: definition.isHidden,
} satisfies AchievementProgress;
});
@@ -429,3 +764,76 @@ export function evaluateAchievementsWithCompletionDates(
metrics,
};
}
/**
* v1.4.18 — discovery filter. Drops public (non-hidden) achievements
* the user can never earn yet because they have no underlying data
* (e.g. a mood badge for someone who has never logged a mood).
*
* Three exceptions to the "drop if not earnable" rule:
* 1. Already-unlocked achievements stay (regression guard — once
* earned, the badge always renders, even if the user later
* deletes all their data).
* 2. Hidden Easter-eggs always stay (the "?" placeholder is the
* whole point).
* 3. Achievements without a metric-data precondition (security,
* bugreport, login-streaks) stay because the precondition is
* "the user has an account", which is implicit.
*/
export function applyDiscoveryFilter(
achievements: AchievementProgress[],
flags: EarnabilityFlags,
): AchievementProgress[] {
return achievements.filter((item) => {
if (item.unlocked) return true;
if (item.category === "hidden") return true;
return isEarnable(item.metric, flags);
});
}
function isEarnable(
metric: AchievementMetricKey,
flags: EarnabilityFlags,
): boolean {
switch (metric) {
case "totalTakenIntakes":
case "overIntakeCount":
case "skippedIntakeCount":
case "onTimePerfectDayStreak":
case "compliance80DayStreak":
return flags.hasMedication;
case "moodEntryCount":
case "moodDayStreak":
case "moodImprovementHit":
return flags.hasMood;
case "weightMeasurementCount":
case "bmiGreenStreak":
return flags.hasWeight;
case "bpMeasurementCount":
case "bpGreenStreak":
return flags.hasBp;
case "pulseMeasurementCount":
case "pulseGreenStreak":
return flags.hasPulse;
case "passkeyCreatedCount":
case "passkeyLoginCount":
case "passwordLoginCount":
case "loginDayStreak":
case "bugReportCount":
case "consistentMonthCount":
case "entryDayStreak":
case "weekendStreakCount":
// No metric-data precondition — the user has an account, so the
// achievement is always discoverable.
return true;
case "nightOwlCount":
case "earlyBirdCount":
case "leapDayCount":
case "doctorPdfCount":
case "localeFlipCount":
// Hidden achievements are filtered by category check above; this
// branch is only reached if a hidden definition's category got
// misconfigured. Stay defensive.
return true;
}
}
+365
View File
@@ -0,0 +1,365 @@
/**
* v1.4.18 — expansion metrics + earnability flags for the achievement
* route. Pure helpers operating over typed prisma rows so the unit
* tests can drive synthetic data without standing up a database.
*
* The route stitches these into the existing `metrics` object so
* `evaluateAchievementsWithCompletionDates` evaluates the new
* achievements with no further plumbing.
*/
import {
calculateLongestStreak,
toBerlinDayKey,
type EarnabilityFlags,
} from "./achievements";
const BERLIN_TIMEZONE = "Europe/Berlin";
const BERLIN_HOUR_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TIMEZONE,
hour: "2-digit",
hour12: false,
});
function berlinHour(date: Date): number {
const value = BERLIN_HOUR_FORMATTER.format(date);
// formatToParts is more reliable across runtimes — split + parseInt
// works because the formatter uses 24-hour with no extra tokens.
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
const BERLIN_WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TIMEZONE,
weekday: "short",
});
function berlinWeekday(date: Date): string {
return BERLIN_WEEKDAY_FORMATTER.format(date);
}
export interface MoodEntryRecord {
date: string; // YYYY-MM-DD (already Berlin-local in MoodEntry schema)
score: number; // 1..5
moodLoggedAt: Date;
}
export interface MeasurementRecord {
type: string;
measuredAt: Date;
}
export interface IntakeEventRecord {
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
}
export interface AuditLogRecord {
action: string;
createdAt: Date;
}
/**
* Counts of measurements per type. Used both for the count-based
* vitals achievements (weight-200 et al.) and for the discovery
* filter (a user with zero BP measurements never sees the BP
* progress cards).
*/
export interface MeasurementCounts {
weightCount: number;
bpCount: number; // counts BLOOD_PRESSURE_SYS as the canonical BP entry
pulseCount: number;
}
export function countMeasurementsByType(
measurements: MeasurementRecord[],
): MeasurementCounts {
let weight = 0;
let bp = 0;
let pulse = 0;
for (const m of measurements) {
if (m.type === "WEIGHT") weight += 1;
else if (m.type === "BLOOD_PRESSURE_SYS") bp += 1;
else if (m.type === "PULSE") pulse += 1;
}
return { weightCount: weight, bpCount: bp, pulseCount: pulse };
}
/**
* Mood metrics. The longest day-streak is computed off the unique
* Berlin-local `date` strings already stored on `MoodEntry`. The
* "improvement hit" is a one-shot boolean: did *any* 7-day window
* have a mean score at least 1.0 higher than the preceding 7-day
* window? We intentionally use the user's own baseline (no global
* comparison) so the badge is non-coercive.
*/
export function getMoodMetrics(entries: MoodEntryRecord[]): {
moodEntryCount: number;
moodDayStreak: number;
moodImprovementHit: number;
} {
const moodEntryCount = entries.length;
if (moodEntryCount === 0) {
return { moodEntryCount: 0, moodDayStreak: 0, moodImprovementHit: 0 };
}
const dayKeys = Array.from(new Set(entries.map((e) => e.date))).sort();
const moodDayStreak = calculateLongestStreak(dayKeys);
// Aggregate mean score per Berlin day for the improvement check.
const perDay = new Map<string, { sum: number; count: number }>();
for (const e of entries) {
const bucket = perDay.get(e.date) ?? { sum: 0, count: 0 };
bucket.sum += e.score;
bucket.count += 1;
perDay.set(e.date, bucket);
}
const sortedDays = Array.from(perDay.entries())
.map(([date, b]) => ({ date, mean: b.sum / b.count }))
.sort((a, b) => a.date.localeCompare(b.date));
let moodImprovementHit = 0;
// Sliding 7-day window mean compared to the preceding 7-day mean.
// 14 distinct days minimum needed for the comparison to fire.
for (let i = 13; i < sortedDays.length; i++) {
const recent = sortedDays.slice(i - 6, i + 1);
const prior = sortedDays.slice(i - 13, i - 6);
if (recent.length < 7 || prior.length < 7) continue;
const recentMean =
recent.reduce((acc, d) => acc + d.mean, 0) / recent.length;
const priorMean = prior.reduce((acc, d) => acc + d.mean, 0) / prior.length;
if (recentMean - priorMean >= 1.0) {
moodImprovementHit = 1;
break;
}
}
return { moodEntryCount, moodDayStreak, moodImprovementHit };
}
/**
* "Consistent month" + entry-streak metrics. Operates on the union of
* any data point that signals tracking activity — measurements, mood
* entries, taken/skipped intake events. A day "counts" if it has at
* least one entry of any kind (Berlin-local).
*/
export function getEngagementMetrics(input: {
measurements: MeasurementRecord[];
moodEntries: MoodEntryRecord[];
intakeEvents: IntakeEventRecord[];
}): {
consistentMonthCount: number;
entryDayStreak: number;
weekendStreakCount: number;
} {
const allDates: Date[] = [];
for (const m of input.measurements) allDates.push(m.measuredAt);
for (const e of input.moodEntries) allDates.push(e.moodLoggedAt);
for (const i of input.intakeEvents) {
if (i.takenAt) allDates.push(i.takenAt);
else if (i.skipped) allDates.push(i.scheduledFor);
}
if (allDates.length === 0) {
return {
consistentMonthCount: 0,
entryDayStreak: 0,
weekendStreakCount: 0,
};
}
const uniqueDays = Array.from(
new Set(allDates.map((d) => toBerlinDayKey(d))),
).sort();
const entryDayStreak = calculateLongestStreak(uniqueDays);
// consistent-month: count Berlin-local calendar months that have ≥25
// distinct active days.
const monthBuckets = new Map<string, Set<string>>();
for (const day of uniqueDays) {
const month = day.slice(0, 7); // YYYY-MM
const set = monthBuckets.get(month) ?? new Set<string>();
set.add(day);
monthBuckets.set(month, set);
}
let consistentMonthCount = 0;
for (const days of monthBuckets.values()) {
if (days.size >= 25) consistentMonthCount += 1;
}
// weekend-warrior: count of consecutive (Saturday + Sunday) pairs
// both with at least one entry. We walk the unique day list and
// count Sat-Sun pairs, then find the longest run of consecutive
// weekends with both days active. The threshold is currently 4
// (defined by the achievement); we expose the *count of consecutive
// weekend pairs in the longest streak* so the UI can also show
// progress.
const daySet = new Set(uniqueDays);
// Build the list of weekend-pair "yes/no" markers ordered by date.
// Iterate over every Saturday in the date range from the first
// active day to the last.
const firstDay = uniqueDays[0];
const lastDay = uniqueDays[uniqueDays.length - 1];
let weekendStreakCount = 0;
let weekendRun = 0;
// Walk one Saturday at a time. Use a simple `Date` cursor; the
// Berlin TZ formatter resolves the weekday so DST shifts are safe.
const cursor = parseDayKey(firstDay);
const end = parseDayKey(lastDay);
// Snap cursor to the next Saturday at 12:00 UTC (avoids DST edges).
cursor.setUTCHours(12, 0, 0, 0);
while (berlinWeekday(cursor) !== "Sat") {
cursor.setUTCDate(cursor.getUTCDate() + 1);
if (cursor > end) break;
}
while (cursor <= end) {
const sat = toBerlinDayKey(cursor);
const sunDate = new Date(cursor);
sunDate.setUTCDate(sunDate.getUTCDate() + 1);
const sun = toBerlinDayKey(sunDate);
if (daySet.has(sat) && daySet.has(sun)) {
weekendRun += 1;
weekendStreakCount = Math.max(weekendStreakCount, weekendRun);
} else {
weekendRun = 0;
}
cursor.setUTCDate(cursor.getUTCDate() + 7);
}
return { consistentMonthCount, entryDayStreak, weekendStreakCount };
}
function parseDayKey(dayKey: string): Date {
const [year, month, day] = dayKey.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
/**
* Hidden Easter-egg metrics. Each metric is a *count* (not a streak)
* so the achievement target = 1 fires the first time the trigger is
* met. Triggers never reveal themselves to the user — locked cards
* paint the opaque placeholder.
*/
export function getHiddenMetrics(input: {
measurements: MeasurementRecord[];
moodEntries: MoodEntryRecord[];
intakeEvents: IntakeEventRecord[];
auditEvents: AuditLogRecord[];
}): {
nightOwlCount: number;
earlyBirdCount: number;
leapDayCount: number;
doctorPdfCount: number;
localeFlipCount: number;
} {
let nightOwl = 0;
let earlyBird = 0;
let leapDay = 0;
const allTimestamps: Date[] = [];
for (const m of input.measurements) allTimestamps.push(m.measuredAt);
for (const e of input.moodEntries) allTimestamps.push(e.moodLoggedAt);
for (const i of input.intakeEvents) {
if (i.takenAt) allTimestamps.push(i.takenAt);
}
for (const ts of allTimestamps) {
const hour = berlinHour(ts);
if (hour >= 2 && hour < 4) nightOwl += 1;
if (hour >= 4 && hour < 6) earlyBird += 1;
const dayKey = toBerlinDayKey(ts);
// Feb 29 — only valid in leap years.
if (dayKey.endsWith("-02-29")) leapDay += 1;
}
let doctorPdfCount = 0;
let localeFlipCount = 0;
for (const ev of input.auditEvents) {
if (ev.action === "doctor-report.export") doctorPdfCount += 1;
if (ev.action === "settings.locale.update") localeFlipCount += 1;
}
return {
nightOwlCount: nightOwl,
earlyBirdCount: earlyBird,
leapDayCount: leapDay,
doctorPdfCount,
localeFlipCount,
};
}
/**
* v1.4.18 — earnability flags. A flag is true iff the user has at
* least one underlying data point for the metric category. The
* discovery filter consumes this to decide whether a public locked
* achievement should render.
*/
export function getEarnabilityFlags(input: {
hasMedication: boolean;
moodEntryCount: number;
measurementCounts: MeasurementCounts;
}): EarnabilityFlags {
return {
hasMedication: input.hasMedication,
hasMood: input.moodEntryCount > 0,
hasWeight: input.measurementCounts.weightCount > 0,
hasBp: input.measurementCounts.bpCount > 0,
hasPulse: input.measurementCounts.pulseCount > 0,
};
}
export type ExpansionMetrics = {
moodEntryCount: number;
moodDayStreak: number;
moodImprovementHit: number;
weightMeasurementCount: number;
bpMeasurementCount: number;
pulseMeasurementCount: number;
consistentMonthCount: number;
entryDayStreak: number;
weekendStreakCount: number;
nightOwlCount: number;
earlyBirdCount: number;
leapDayCount: number;
doctorPdfCount: number;
localeFlipCount: number;
};
export function buildExpansionMetricValues(input: {
measurements: MeasurementRecord[];
moodEntries: MoodEntryRecord[];
intakeEvents: IntakeEventRecord[];
auditEvents: AuditLogRecord[];
}): ExpansionMetrics {
const counts = countMeasurementsByType(input.measurements);
const mood = getMoodMetrics(input.moodEntries);
const engagement = getEngagementMetrics({
measurements: input.measurements,
moodEntries: input.moodEntries,
intakeEvents: input.intakeEvents,
});
const hidden = getHiddenMetrics({
measurements: input.measurements,
moodEntries: input.moodEntries,
intakeEvents: input.intakeEvents,
auditEvents: input.auditEvents,
});
return {
moodEntryCount: mood.moodEntryCount,
moodDayStreak: mood.moodDayStreak,
moodImprovementHit: mood.moodImprovementHit,
weightMeasurementCount: counts.weightCount,
bpMeasurementCount: counts.bpCount,
pulseMeasurementCount: counts.pulseCount,
consistentMonthCount: engagement.consistentMonthCount,
entryDayStreak: engagement.entryDayStreak,
weekendStreakCount: engagement.weekendStreakCount,
nightOwlCount: hidden.nightOwlCount,
earlyBirdCount: hidden.earlyBirdCount,
leapDayCount: hidden.leapDayCount,
doctorPdfCount: hidden.doctorPdfCount,
localeFlipCount: hidden.localeFlipCount,
};
}