diff --git a/messages/de.json b/messages/de.json index 40e518945..9324016ca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3ff8de62c..f4bd85e74 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index 3d67bf9eb..051162b8d 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -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, }, }; diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 79d9a5f83..fda1590ee 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -61,8 +61,10 @@ const iconMap: Record = { const CATEGORY_LABEL_KEY: Record = { 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( diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx index 7276c037c..d5df3b6d4 100644 --- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -63,6 +63,7 @@ const baseAchievement: AchievementProgress = { unlocked: true, progressPercent: 100, completedAt: "2026-04-15T12:00:00.000Z", + isHidden: false, }; describe("pickRecentUnlocks", () => { diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts index 2ad1a7693..20a803749 100644 --- a/src/lib/gamification/__tests__/achievements.test.ts +++ b/src/lib/gamification/__tests__/achievements.test.ts @@ -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); + }); }); diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index fbf13ee39..90b4582c3 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -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 & { + 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; + } +} diff --git a/src/lib/gamification/expansion-metrics.ts b/src/lib/gamification/expansion-metrics.ts new file mode 100644 index 000000000..6157b5e30 --- /dev/null +++ b/src/lib/gamification/expansion-metrics.ts @@ -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(); + 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>(); + for (const day of uniqueDays) { + const month = day.slice(0, 7); // YYYY-MM + const set = monthBuckets.get(month) ?? new Set(); + 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, + }; +}