mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
e73813ad7c
commit
19b08442b4
8 files changed
+1183
-67
No files matched your search
+96
-1
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user