mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
merge(modules): gate achievements behind the achievements module flag
# Conflicts: # src/hooks/use-auth.ts
This commit is contained in:
commit
6bde45ce0a
10 files changed
+349
-7
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.0 — `<AchievementUnlockNotifier>` honours the achievements module
|
||||
* gate. When the module is disabled the polling query is never enabled,
|
||||
* so no unlock toast can surface — the engagement surface disappears in
|
||||
* lock-step with the page, the dashboard tile, and the 403 route.
|
||||
*/
|
||||
|
||||
let mockUser: { modules?: Record<string, boolean> } | null = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
|
||||
}));
|
||||
|
||||
const useAchievementsQuery = vi.fn(
|
||||
(_opts?: { enabled?: boolean }) => ({ data: undefined }),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/queries/use-achievements-query", () => ({
|
||||
useAchievementsQuery: (opts?: { enabled?: boolean }) =>
|
||||
useAchievementsQuery(opts),
|
||||
}));
|
||||
|
||||
const toast = vi.fn();
|
||||
vi.mock("sonner", () => ({ toast: (...args: unknown[]) => toast(...args) }));
|
||||
|
||||
import { AchievementUnlockNotifier } from "../achievement-unlock-notifier";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AchievementUnlockNotifier userId="user-1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUser = {};
|
||||
});
|
||||
|
||||
describe("<AchievementUnlockNotifier> — achievements module gate", () => {
|
||||
it("disables the polling query when the module is off", () => {
|
||||
mockUser = { modules: { achievements: false } };
|
||||
expect(render()).toBe("");
|
||||
expect(useAchievementsQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables the polling query when the module is on (explicit true)", () => {
|
||||
mockUser = { modules: { achievements: true } };
|
||||
render();
|
||||
expect(useAchievementsQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables the query by default when the module key is absent (default-on)", () => {
|
||||
mockUser = { modules: {} };
|
||||
render();
|
||||
expect(useAchievementsQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
* with their localized title + completedAt date.
|
||||
*/
|
||||
|
||||
let mockUser: { modules?: Record<string, boolean> } | null = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
|
||||
}));
|
||||
|
||||
let mockData:
|
||||
@@ -54,6 +56,7 @@ function render() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsPending = false;
|
||||
mockUser = {};
|
||||
});
|
||||
|
||||
const baseAchievement: AchievementProgress = {
|
||||
@@ -130,6 +133,41 @@ describe("pickRecentUnlocks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<RecentAchievementsCard> — achievements module gate", () => {
|
||||
it("renders nothing when the achievements module is disabled", () => {
|
||||
// v1.18.0 — an explicit `false` for the module hides the dashboard
|
||||
// tile entirely so the surface disappears in lock-step with the page
|
||||
// and the 403 route.
|
||||
mockUser = { modules: { achievements: false } };
|
||||
mockData = {
|
||||
summary: {},
|
||||
achievements: [
|
||||
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
|
||||
],
|
||||
metrics: {},
|
||||
};
|
||||
expect(render()).toBe("");
|
||||
});
|
||||
|
||||
it("renders the tile when the module is enabled (explicit true)", () => {
|
||||
mockUser = { modules: { achievements: true } };
|
||||
mockData = {
|
||||
summary: {},
|
||||
achievements: [
|
||||
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
|
||||
],
|
||||
metrics: {},
|
||||
};
|
||||
expect(render()).toContain('data-slot="recent-achievements-card"');
|
||||
});
|
||||
|
||||
it("renders the tile by default when the module key is absent (default-on)", () => {
|
||||
mockUser = { modules: {} };
|
||||
mockData = { summary: {}, achievements: [], metrics: {} };
|
||||
expect(render()).toContain('data-slot="recent-achievements-card"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<RecentAchievementsCard>", () => {
|
||||
it("renders a skeleton (no content, no empty state) while the query is pending", () => {
|
||||
// The flash this guards against: before the query settles the card
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Sparkles, Trophy } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
|
||||
@@ -56,6 +57,14 @@ export function AchievementUnlockNotifier({
|
||||
userId,
|
||||
}: AchievementUnlockNotifierProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the notifier goes inert: the polling query never
|
||||
// fires and no unlock toast can surface. Default-on: an absent key reads
|
||||
// as enabled, so the notifier only stands down on an explicit `false`.
|
||||
const achievementsEnabled = user?.modules?.achievements !== false;
|
||||
|
||||
const storageKey = useMemo(() => `${STORAGE_KEY_PREFIX}:${userId}`, [userId]);
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const localStateReadyRef = useRef(false);
|
||||
@@ -70,6 +79,7 @@ export function AchievementUnlockNotifier({
|
||||
// long-open tabs still surface new unlocks without forcing the card
|
||||
// to re-render on the same cadence.
|
||||
const { data } = useAchievementsQuery({
|
||||
enabled: !!user && achievementsEnabled,
|
||||
refetchInterval: 2 * 60 * 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
@@ -81,6 +82,13 @@ const RECENT_LIMIT = 3;
|
||||
*/
|
||||
export function RecentAchievementsCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the dashboard tile disappears entirely (the data
|
||||
// query never fires; the API would 403 anyway). Default-on: an absent
|
||||
// key reads as enabled, so the tile only hides on an explicit `false`.
|
||||
const achievementsEnabled = user?.modules?.achievements !== false;
|
||||
|
||||
// `isPending` (no data yet, fetch in flight or gated) drives the
|
||||
// loading branch. Rendering the empty / content branch before the
|
||||
@@ -89,10 +97,17 @@ export function RecentAchievementsCard() {
|
||||
// content) for the fetch window, then swapped once the real payload
|
||||
// landed. A skeleton holds the card's footprint without committing to
|
||||
// a content shape it may have to retract.
|
||||
const { data, isPending } = useAchievementsQuery();
|
||||
const { data, isPending } = useAchievementsQuery({
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
|
||||
|
||||
// Module off ⇒ render nothing so the tile vanishes from the dashboard.
|
||||
if (!achievementsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="recent-achievements-card"
|
||||
|
||||
Reference in new issue
Block a user