diff --git a/src/components/GameLayout.tsx b/src/components/GameLayout.tsx index 7a00ba4..6524cbf 100644 --- a/src/components/GameLayout.tsx +++ b/src/components/GameLayout.tsx @@ -12,6 +12,7 @@ import { computeOfflineProgress } from "../engine/offlineEngine"; import { useDailyObjectiveTracking } from "../hooks/useDailyObjectiveTracking"; import { useGameLoop } from "../hooks/useGameLoop"; import { useKonamiCode } from "../hooks/useKonamiCode"; +import { useOfflineNotification } from "../hooks/useOfflineNotification"; import { useReducedMotion } from "../hooks/useReducedMotion"; import { useSound } from "../hooks/useSound"; import { useGameStore } from "../store"; @@ -30,6 +31,7 @@ import { UpgradesSidebar } from "./UpgradesSidebar"; export function GameLayout() { useGameLoop(); useDailyObjectiveTracking(); + useOfflineNotification(); const [offlineResult, setOfflineResult] = useState(null); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 87be633..045f5d4 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -8,6 +8,7 @@ import { Switch, Text, Textarea, + Tooltip, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useState } from "react"; @@ -38,6 +39,16 @@ export function SettingsPanel({ const setNumberFormat = useSettingsStore((s) => s.setNumberFormat); const soundEnabled = useSettingsStore((s) => s.soundEnabled); const setSoundEnabled = useSettingsStore((s) => s.setSoundEnabled); + const notificationsEnabled = useSettingsStore((s) => s.notificationsEnabled); + const setNotificationsEnabled = useSettingsStore( + (s) => s.setNotificationsEnabled, + ); + + // The toggle is disabled (and shows a tooltip) when the OS/browser has blocked + // the Notification API. We read the permission eagerly — the drawer is opened on + // demand so we always see the current state. + const notificationsBlocked = + typeof Notification !== "undefined" && Notification.permission === "denied"; const [resetStage, setResetStage] = useState(0); const [importError, setImportError] = useState(null); @@ -197,6 +208,25 @@ export function SettingsPanel({ onChange={(e) => setSoundEnabled(e.currentTarget.checked)} styles={{ label: { fontFamily: "monospace" } }} /> + +
+ + setNotificationsEnabled(e.currentTarget.checked) + } + disabled={notificationsBlocked} + styles={{ label: { fontFamily: "monospace" } }} + /> +
+
diff --git a/src/components/upgrades/BoosterCard.tsx b/src/components/upgrades/BoosterCard.tsx index e2b88fa..0916749 100644 --- a/src/components/upgrades/BoosterCard.tsx +++ b/src/components/upgrades/BoosterCard.tsx @@ -1,4 +1,12 @@ -import { Badge, Button, Card, Group, Text } from "@mantine/core"; +import { + ActionIcon, + Badge, + Button, + Card, + Group, + Popover, + Text, +} from "@mantine/core"; import { notifications } from "@mantine/notifications"; import type { DecimalSource } from "break_infinity.js"; import { useCallback, useRef, useState } from "react"; @@ -6,6 +14,7 @@ import type { Booster } from "../../data/boosters"; import { useReducedMotion } from "../../hooks/useReducedMotion"; import { D } from "../../utils/decimal"; import { formatNumber } from "../../utils/formatNumber"; +import { BoosterTooltipContent } from "./BoosterTooltipContent"; interface BoosterCardProps { booster: Booster; @@ -25,6 +34,8 @@ export function BoosterCard({ const canAfford = !purchased && D(trainingData).gte(booster.cost); const locked = evolutionStage < booster.unlockStage; const [isGlowing, setIsGlowing] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const isHoverDevice = useRef(false); const glowTimerRef = useRef>(undefined); const prefersReduced = useReducedMotion(); @@ -46,53 +57,88 @@ export function BoosterCard({ if (locked) return null; return ( - - - - {booster.icon} {booster.name} - - {purchased && ( - - ACTIVE - - )} - + + { + isHoverDevice.current = true; + setTooltipOpen(true); + }} + onMouseLeave={() => setTooltipOpen(false)} + className={isGlowing ? "glow-pulse" : undefined} + padding="sm" + radius="sm" + withBorder + style={{ + borderColor: purchased + ? "var(--mantine-color-violet-8)" + : canAfford + ? "var(--mantine-color-green-8)" + : "var(--mantine-color-dark-4)", + opacity: purchased ? 0.7 : canAfford ? 1 : 0.5, + animation: isGlowing ? "glow-pulse 0.6s ease-in-out" : undefined, + }} + > + + + {booster.icon} {booster.name} + + + {purchased && ( + + ACTIVE + + )} + { + e.stopPropagation(); + if (!isHoverDevice.current) { + setTooltipOpen((o) => !o); + } + }} + > + ℹ + + + - - {booster.description} - + + {booster.description} + - - - ×{booster.multiplier} all auto-gen - - {!purchased && ( - - )} - - + + + ×{booster.multiplier} all auto-gen + + {!purchased && ( + + )} + + + + + + + ); } diff --git a/src/components/upgrades/BoosterTooltipContent.tsx b/src/components/upgrades/BoosterTooltipContent.tsx new file mode 100644 index 0000000..b2d95c0 --- /dev/null +++ b/src/components/upgrades/BoosterTooltipContent.tsx @@ -0,0 +1,100 @@ +import { Divider, Group, Stack, Text } from "@mantine/core"; +import type { Booster } from "../../data/boosters"; +import { BOOSTERS } from "../../data/boosters"; +import { getIdleBoostMultiplier } from "../../data/prestigeShop"; +import { getSpeciesBonus } from "../../data/species"; +import { UPGRADES } from "../../data/upgrades"; +import { + computeBoosterMultiplier, + getTotalTdPerSecond, +} from "../../engine/upgradeEngine"; +import { useGameStore } from "../../store"; +import { formatNumber } from "../../utils/formatNumber"; +import { computeGlobalMultiplierTooltipData } from "./tooltipHelpers"; + +interface BoosterTooltipContentProps { + booster: Booster; + purchased: boolean; +} + +export function BoosterTooltipContent({ + booster, + purchased, +}: BoosterTooltipContentProps) { + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + const boostersPurchased = useGameStore((s) => s.boostersPurchased); + const prestigeUpgrades = useGameStore((s) => s.prestigeUpgrades); + const currentSpecies = useGameStore((s) => s.currentSpecies); + const activeChallengeId = useGameStore((s) => s.activeChallengeId); + + // Mirror the no-prestige challenge logic from the game engine + const effectivePrestige = + activeChallengeId === "no-prestige" ? {} : prestigeUpgrades; + const idleBoost = getIdleBoostMultiplier( + (effectivePrestige as Record)["idle-boost"] ?? 0, + ); + const speciesBonus = getSpeciesBonus(currentSpecies); + const boosterMult = computeBoosterMultiplier(BOOSTERS, boostersPurchased); + const currentTdPerSecond = getTotalTdPerSecond( + UPGRADES, + upgradeOwned, + idleBoost * speciesBonus.autoGen, + boosterMult, + ); + + const { currentTdPerSecond: current, newTdPerSecond } = + computeGlobalMultiplierTooltipData(booster, currentTdPerSecond); + + return ( + + + {booster.icon} {booster.name} + + + + + {booster.description} + + + + + Multiplier + + + ×{booster.multiplier} all TD/s + + + + {!purchased && ( + <> + + + + Current TD/s + + + {formatNumber(current)} + + + + + + After purchase + + + {formatNumber(newTdPerSecond)} TD/s + + + + )} + + ); +} diff --git a/src/components/upgrades/ClickUpgradeTooltipContent.tsx b/src/components/upgrades/ClickUpgradeTooltipContent.tsx index ebbe11b..e528a6d 100644 --- a/src/components/upgrades/ClickUpgradeTooltipContent.tsx +++ b/src/components/upgrades/ClickUpgradeTooltipContent.tsx @@ -1,6 +1,20 @@ import { Badge, Divider, Group, Stack, Text } from "@mantine/core"; +import { BOOSTERS } from "../../data/boosters"; import type { ClickUpgrade } from "../../data/clickUpgrades"; +import { CLICK_UPGRADES } from "../../data/clickUpgrades"; +import { + getClickMasteryBonus, + getIdleBoostMultiplier, +} from "../../data/prestigeShop"; +import { getSpeciesBonus } from "../../data/species"; +import { UPGRADES } from "../../data/upgrades"; +import { + computeBoosterMultiplier, + getTotalTdPerSecond, +} from "../../engine/upgradeEngine"; +import { useGameStore } from "../../store"; import { formatNumber } from "../../utils/formatNumber"; +import { computeClickBonusTooltipData } from "./tooltipHelpers"; interface ClickUpgradeTooltipContentProps { upgrade: ClickUpgrade; @@ -11,8 +25,42 @@ export function ClickUpgradeTooltipContent({ upgrade, purchased, }: ClickUpgradeTooltipContentProps) { + const clickUpgradesPurchased = useGameStore((s) => s.clickUpgradesPurchased); + const upgradeOwned = useGameStore((s) => s.upgradeOwned); + const boostersPurchased = useGameStore((s) => s.boostersPurchased); + const prestigeUpgrades = useGameStore((s) => s.prestigeUpgrades); + const currentSpecies = useGameStore((s) => s.currentSpecies); + const activeChallengeId = useGameStore((s) => s.activeChallengeId); + + // Mirror the no-prestige challenge logic from the game engine + const effectivePrestige = + activeChallengeId === "no-prestige" ? {} : prestigeUpgrades; + const idleBoost = getIdleBoostMultiplier( + (effectivePrestige as Record)["idle-boost"] ?? 0, + ); + const speciesBonus = getSpeciesBonus(currentSpecies); + const boosterMult = computeBoosterMultiplier(BOOSTERS, boostersPurchased); + const tdPerSecond = getTotalTdPerSecond( + UPGRADES, + upgradeOwned, + idleBoost * speciesBonus.autoGen, + boosterMult, + ); + const clickMasteryBonus = getClickMasteryBonus( + (effectivePrestige as Record)["click-mastery"] ?? 0, + ); + + const { deltaClickPower } = computeClickBonusTooltipData( + upgrade, + clickUpgradesPurchased, + CLICK_UPGRADES, + tdPerSecond, + clickMasteryBonus, + speciesBonus.clickPower, + ); + return ( - + {upgrade.icon} {upgrade.name} @@ -39,14 +87,33 @@ export function ClickUpgradeTooltipContent({ {!purchased && ( - - - Cost - - - {formatNumber(upgrade.cost)} TD - - + <> + + + Click bonus + + + +{formatNumber(deltaClickPower)} TD/click + + + + + + Cost + + + {formatNumber(upgrade.cost)} TD + + + )} ); diff --git a/src/components/upgrades/tooltipHelpers.test.ts b/src/components/upgrades/tooltipHelpers.test.ts index fd5a1ba..1c07018 100644 --- a/src/components/upgrades/tooltipHelpers.test.ts +++ b/src/components/upgrades/tooltipHelpers.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; +import { BOOSTERS } from "../../data/boosters"; +import { CLICK_UPGRADES } from "../../data/clickUpgrades"; import { UPGRADES } from "../../data/upgrades"; -import { computeGeneratorTooltipData } from "./tooltipHelpers"; +import { + computeClickBonusTooltipData, + computeGeneratorTooltipData, + computeGlobalMultiplierTooltipData, +} from "./tooltipHelpers"; const neuralNotepad = UPGRADES.find((u) => u.id === "neural-notepad"); if (!neuralNotepad) @@ -146,13 +152,14 @@ describe("computeGeneratorTooltipData", () => { }); it("accounts for milestone crossing when buying the 50th unit (49 → 50)", () => { - // Buying the 50th unit crosses the x3 milestone. - // Current: 0.2 * 49 * 2 = 19.6 - // Future: 0.2 * 50 * 3 = 30.0 - // Delta: 30.0 - 19.6 = 10.4 + // Buying the 50th unit crosses the x3 milestone AND triggers the + // neural-notepad self-synergy (+100%, ×2) simultaneously. + // Current (49 owned): synergyMultiplier=1 → 0.2 * 49 * 2 * 1 = 19.6 + // Future (50 owned): synergyMultiplier=2 → 0.2 * 50 * 3 * 2 = 60.0 + // Delta: 60.0 - 19.6 = 40.4 const allOwned = { "neural-notepad": 49 }; const data = computeGeneratorTooltipData(neuralNotepad, 49, allOwned); - expect(data.deltaTdPerSecond).toBeCloseTo(10.4); + expect(data.deltaTdPerSecond).toBeCloseTo(40.4); expect(data.milestoneWillCross).toBe(true); }); @@ -169,12 +176,13 @@ describe("computeGeneratorTooltipData", () => { it("applies normal delta at max milestone (owned=100, 100 → 101)", () => { // x6 milestone active, no further milestones. - // Current: 0.2 * 100 * 6 = 120.0 - // Future: 0.2 * 101 * 6 = 121.2 - // Delta: 1.2 + // The neural-notepad self-synergy (+100%, ×2) is active at 100 owned (≥50). + // Current: 0.2 * 100 * 6 * 2 = 240.0 + // Future: 0.2 * 101 * 6 * 2 = 242.4 + // Delta: 2.4 const allOwned = { "neural-notepad": 100 }; const data = computeGeneratorTooltipData(neuralNotepad, 100, allOwned); - expect(data.deltaTdPerSecond).toBeCloseTo(1.2); + expect(data.deltaTdPerSecond).toBeCloseTo(2.4); expect(data.milestoneWillCross).toBe(false); }); @@ -186,3 +194,134 @@ describe("computeGeneratorTooltipData", () => { }); }); }); + +// ── computeClickBonusTooltipData tests ─────────────────────────────────────── + +const betterDataset = CLICK_UPGRADES.find((u) => u.id === "better-dataset"); +if (!betterDataset) + throw new Error("better-dataset click upgrade not found in CLICK_UPGRADES"); +const stackOverflow = CLICK_UPGRADES.find((u) => u.id === "stack-overflow"); +if (!stackOverflow) + throw new Error("stack-overflow click upgrade not found in CLICK_UPGRADES"); + +describe("computeClickBonusTooltipData", () => { + it("delta is 0 when tdPerSecond is 0 and floor of 1 already applied to both", () => { + // Both current and future floor to 1, so delta is 0 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 0, + ); + expect(data.currentClickPower.toNumber()).toBe(1); + expect(data.futureClickPower.toNumber()).toBe(1); + expect(data.deltaClickPower.toNumber()).toBe(0); + }); + + it("returns correct delta when tdPerSecond is large enough to escape the floor", () => { + // BASE_CLICK_SECONDS = 0.05; betterDataset.clickSeconds = 0.1; tdPerSecond = 100 + // currentBase = 0.05 * 100 = 5 → max(1,5) = 5 + // futureBase = 0.15 * 100 = 15 → max(1,15) = 15 + // delta = 10 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(5); + expect(data.futureClickPower.toNumber()).toBeCloseTo(15); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(10); + }); + + it("already-purchased upgrade reduces future seconds correctly", () => { + // betterDataset already purchased; buying stackOverflow (+0.15s) + // currentSeconds = 0.05 + 0.1 = 0.15, tdPerSecond = 100 + // currentBase = 0.15 * 100 = 15 + // futureBase = 0.30 * 100 = 30 + // delta = 15 + const data = computeClickBonusTooltipData( + stackOverflow, + ["better-dataset"], + CLICK_UPGRADES, + 100, + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(15); + expect(data.futureClickPower.toNumber()).toBeCloseTo(30); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(15); + }); + + it("applies clickMasteryBonus to currentSeconds", () => { + // clickMasteryBonus=1 adds 0.1s to current seconds + // currentSeconds = 0.05 + 0.1 = 0.15, tdPerSecond = 100 + // futureSeconds = 0.05 + 0.1 + 0.1 = 0.25 + // delta = (0.25 - 0.15) * 100 = 10 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + 1, // clickMasteryBonus + ); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(10); + }); + + it("applies speciesClickMultiplier to both base values", () => { + // speciesClickMultiplier = 1.5; betterDataset; tdPerSecond = 100; no purchased + // currentBase = 0.05 * 100 * 1.5 = 7.5 + // futureBase = 0.15 * 100 * 1.5 = 22.5 + // delta = 15 + const data = computeClickBonusTooltipData( + betterDataset, + [], + CLICK_UPGRADES, + 100, + 0, + 1.5, // speciesClickMultiplier + ); + expect(data.currentClickPower.toNumber()).toBeCloseTo(7.5); + expect(data.futureClickPower.toNumber()).toBeCloseTo(22.5); + expect(data.deltaClickPower.toNumber()).toBeCloseTo(15); + }); +}); + +// ── computeGlobalMultiplierTooltipData tests ───────────────────────────────── + +const seriesAFunding = BOOSTERS.find((b) => b.id === "series-a-funding"); +if (!seriesAFunding) + throw new Error("series-a-funding booster not found in BOOSTERS"); +const hypeTrain = BOOSTERS.find((b) => b.id === "hype-train"); +if (!hypeTrain) throw new Error("hype-train booster not found in BOOSTERS"); + +describe("computeGlobalMultiplierTooltipData", () => { + it("doubles TD/s for the 2× booster", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 500); + expect(data.multiplier).toBe(2); + expect(data.currentTdPerSecond.toNumber()).toBe(500); + expect(data.newTdPerSecond.toNumber()).toBe(1000); + }); + + it("triples TD/s for the 3× booster", () => { + const data = computeGlobalMultiplierTooltipData(hypeTrain, 200); + expect(data.multiplier).toBe(3); + expect(data.currentTdPerSecond.toNumber()).toBe(200); + expect(data.newTdPerSecond.toNumber()).toBe(600); + }); + + it("returns 0 new TD/s when current is 0", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 0); + expect(data.currentTdPerSecond.toNumber()).toBe(0); + expect(data.newTdPerSecond.toNumber()).toBe(0); + }); + + it("preserves current TD/s unchanged in the output", () => { + const data = computeGlobalMultiplierTooltipData(seriesAFunding, 1234.5); + expect(data.currentTdPerSecond.toNumber()).toBeCloseTo(1234.5); + }); + + it("handles Decimal input for currentTdPerSecond", () => { + // Large numbers go through Decimal path + const data = computeGlobalMultiplierTooltipData(hypeTrain, 1e15); + expect(data.newTdPerSecond.toNumber()).toBeCloseTo(3e15); + }); +}); diff --git a/src/components/upgrades/tooltipHelpers.ts b/src/components/upgrades/tooltipHelpers.ts index e15569f..f845f4f 100644 --- a/src/components/upgrades/tooltipHelpers.ts +++ b/src/components/upgrades/tooltipHelpers.ts @@ -1,12 +1,17 @@ +import type { Booster } from "../../data/boosters"; +import type { ClickUpgrade } from "../../data/clickUpgrades"; import { MILESTONE_THRESHOLDS } from "../../data/milestones"; import type { Upgrade } from "../../data/upgrades"; import { UPGRADES } from "../../data/upgrades"; +import { computeClickSeconds } from "../../engine/clickEngine"; import { getMilestoneLevel, getMilestoneMultiplier, } from "../../engine/milestoneEngine"; import { getSynergyMultiplier } from "../../engine/synergyEngine"; import { getTotalTdPerSecond } from "../../engine/upgradeEngine"; +import type { DecimalSource } from "../../utils/decimal"; +import { D, Decimal } from "../../utils/decimal"; export interface GeneratorTooltipData { name: string; @@ -63,7 +68,10 @@ export function computeGeneratorTooltipData( const newMilestoneMultiplier = getMilestoneMultiplier(newOwned); const newSynergyMultiplier = getSynergyMultiplier(upgrade.id, newAllOwned); const futureTdForGenerator = - upgrade.baseTdPerSecond * newOwned * newMilestoneMultiplier * newSynergyMultiplier; + upgrade.baseTdPerSecond * + newOwned * + newMilestoneMultiplier * + newSynergyMultiplier; const deltaTdPerSecond = futureTdForGenerator - totalTdForGenerator; const milestoneWillCross = getMilestoneLevel(newOwned) > milestoneLevel; @@ -84,3 +92,81 @@ export function computeGeneratorTooltipData( milestoneWillCross, }; } + +// ── Click-bonus tooltip ─────────────────────────────────────────────────────── + +export interface ClickBonusTooltipData { + /** TD gained per click with current upgrades (no combo, floor applied). */ + currentClickPower: Decimal; + /** TD gained per click after purchasing this upgrade (no combo, floor applied). */ + futureClickPower: Decimal; + /** Net increase in click power from buying this upgrade. */ + deltaClickPower: Decimal; +} + +/** + * Computes the click-power delta shown in a click-bonus upgrade tooltip. + * + * Combo is intentionally excluded because it is transient — the tooltip + * reflects the stable base increase, not a snapshot of a lucky combo streak. + * The max(1, ...) floor matches the real engine so early-game values (where + * tdPerSecond ≈ 0) show truthful numbers. + */ +export function computeClickBonusTooltipData( + upgrade: ClickUpgrade, + purchasedIds: string[], + clickUpgrades: readonly ClickUpgrade[], + tdPerSecond: DecimalSource, + clickMasteryBonus = 0, + speciesClickMultiplier = 1, +): ClickBonusTooltipData { + const currentSeconds = computeClickSeconds( + purchasedIds, + clickUpgrades, + clickMasteryBonus, + ); + const futureSeconds = currentSeconds + upgrade.clickSeconds; + + const currentBase = D(currentSeconds) + .mul(tdPerSecond) + .mul(speciesClickMultiplier); + const futureBase = D(futureSeconds) + .mul(tdPerSecond) + .mul(speciesClickMultiplier); + + const currentClickPower = Decimal.max(1, currentBase); + const futureClickPower = Decimal.max(1, futureBase); + const deltaClickPower = futureClickPower.sub(currentClickPower); + + return { currentClickPower, futureClickPower, deltaClickPower }; +} + +// ── Global-multiplier tooltip ───────────────────────────────────────────────── + +export interface GlobalMultiplierTooltipData { + multiplier: number; + /** Total TD/s right now (with all current booster multipliers applied). */ + currentTdPerSecond: Decimal; + /** Projected total TD/s after purchasing this booster. */ + newTdPerSecond: Decimal; +} + +/** + * Computes the before/after TD/s pair for a global-multiplier (booster) tooltip. + * + * `currentTdPerSecond` must already include all active booster multipliers. + * Purchasing this booster multiplies the entire total by `booster.multiplier`, + * so `newTdPerSecond = currentTdPerSecond × booster.multiplier`. + */ +export function computeGlobalMultiplierTooltipData( + booster: Booster, + currentTdPerSecond: DecimalSource, +): GlobalMultiplierTooltipData { + const current = D(currentTdPerSecond); + const newTdPerSecond = current.mul(booster.multiplier); + return { + multiplier: booster.multiplier, + currentTdPerSecond: current, + newTdPerSecond, + }; +} diff --git a/src/hooks/useOfflineNotification.test.ts b/src/hooks/useOfflineNotification.test.ts new file mode 100644 index 0000000..075c1f5 --- /dev/null +++ b/src/hooks/useOfflineNotification.test.ts @@ -0,0 +1,277 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { initialSettings, useSettingsStore } from "../store/settingsStore"; +import { + OFFLINE_CAP_MS, + useOfflineNotification, +} from "./useOfflineNotification"; + +// --------------------------------------------------------------------------- +// Notification API mock +// --------------------------------------------------------------------------- + +const mockNotificationConstructor = vi.fn(); +const mockClose = vi.fn(); + +class MockNotification { + static permission: NotificationPermission = "default"; + static requestPermission = vi.fn<() => Promise>(); + + body: string; + icon: string | undefined; + onclick: ((this: Notification, ev: Event) => unknown) | null = null; + close = mockClose; + + constructor(title: string, options?: { body?: string; icon?: string }) { + this.body = options?.body ?? ""; + this.icon = options?.icon; + mockNotificationConstructor(title, options); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + MockNotification.permission = "default"; + MockNotification.requestPermission = + vi.fn<() => Promise>(); + mockNotificationConstructor.mockClear(); + mockClose.mockClear(); + + Object.defineProperty(globalThis, "Notification", { + value: MockNotification, + writable: true, + configurable: true, + }); + + // Default to hidden so timer callbacks fire without the visibility guard + // blocking them. Individual tests override this as needed. + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }); + + useSettingsStore.setState(initialSettings); +}); + +afterEach(() => { + // Run pending fake timers before unmounting to avoid act() hangs during cleanup. + vi.runAllTimers(); + vi.useRealTimers(); + // Explicitly unmount all rendered hooks to prevent visibilitychange listener + // leakage across tests (RTL auto-cleanup does not always fire in per-file jsdom). + cleanup(); +}); + +describe("useOfflineNotification", () => { + it("does not schedule a notification when notificationsEnabled is false", () => { + useSettingsStore.setState({ notificationsEnabled: false }); + MockNotification.permission = "granted"; + + renderHook(() => useOfflineNotification()); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + }); + + it("does not schedule a notification when permission is not granted", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "default"; + + renderHook(() => useOfflineNotification()); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + }); + + it("does not schedule a notification when permission is denied", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "denied"; + + renderHook(() => useOfflineNotification()); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + }); + + it("fires a notification after OFFLINE_CAP_MS when permission is granted", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "granted"; + + renderHook(() => useOfflineNotification()); + + vi.advanceTimersByTime(OFFLINE_CAP_MS - 1); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1001); + expect(mockNotificationConstructor).toHaveBeenCalledOnce(); + expect(mockNotificationConstructor).toHaveBeenCalledWith( + "GLORP", + expect.objectContaining({ + body: expect.stringContaining("GLORP has maxed out!"), + }), + ); + }); + + it("does not fire the notification while the tab is visible", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "granted"; + + // Override to visible — the timer fires but the callback should bail out. + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }); + + renderHook(() => useOfflineNotification()); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + }); + + it("resets the 8-hour countdown when the tab regains focus", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "granted"; + + renderHook(() => useOfflineNotification()); + + // Advance 4 hours — no notification yet. + vi.advanceTimersByTime(4 * 60 * 60 * 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + + // Tab gains focus — should reset session start and reschedule for 8h from now. + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Advance another 4 hours (8h total from original mount, but only 4h since reset). + // Notification should NOT have fired yet. + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }); + vi.advanceTimersByTime(4 * 60 * 60 * 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + + // Advance the remaining 4h since the reset — now it fires. + vi.advanceTimersByTime(4 * 60 * 60 * 1000 + 1000); + expect(mockNotificationConstructor).toHaveBeenCalledOnce(); + }); + + it("cancels the timer when notificationsEnabled is toggled off", () => { + useSettingsStore.setState({ notificationsEnabled: true }); + MockNotification.permission = "granted"; + + renderHook(() => useOfflineNotification()); + + // Disable notifications mid-countdown. + act(() => { + useSettingsStore.setState({ notificationsEnabled: false }); + }); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).not.toHaveBeenCalled(); + }); + + it("reschedules the timer when notificationsEnabled is toggled back on", () => { + useSettingsStore.setState({ notificationsEnabled: false }); + MockNotification.permission = "granted"; + + renderHook(() => useOfflineNotification()); + + // Enable notifications — should schedule the timer. + act(() => { + useSettingsStore.setState({ notificationsEnabled: true }); + }); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).toHaveBeenCalledOnce(); + }); + + describe("requestPermissionOnInteraction", () => { + it("calls Notification.requestPermission on first interaction", async () => { + MockNotification.permission = "default"; + MockNotification.requestPermission.mockResolvedValue("granted"); + useSettingsStore.setState({ notificationsEnabled: true }); + + const { result } = renderHook(() => useOfflineNotification()); + + await act(async () => { + await result.current.requestPermissionOnInteraction(); + }); + + expect(MockNotification.requestPermission).toHaveBeenCalledOnce(); + }); + + it("only calls requestPermission once even if invoked multiple times", async () => { + MockNotification.permission = "default"; + MockNotification.requestPermission.mockResolvedValue("granted"); + useSettingsStore.setState({ notificationsEnabled: true }); + + const { result } = renderHook(() => useOfflineNotification()); + + await act(async () => { + await result.current.requestPermissionOnInteraction(); + await result.current.requestPermissionOnInteraction(); + await result.current.requestPermissionOnInteraction(); + }); + + expect(MockNotification.requestPermission).toHaveBeenCalledOnce(); + }); + + it("does not prompt if permission is already granted", async () => { + MockNotification.permission = "granted"; + useSettingsStore.setState({ notificationsEnabled: true }); + + const { result } = renderHook(() => useOfflineNotification()); + + await act(async () => { + await result.current.requestPermissionOnInteraction(); + }); + + expect(MockNotification.requestPermission).not.toHaveBeenCalled(); + }); + + it("does not prompt if permission is already denied", async () => { + MockNotification.permission = "denied"; + useSettingsStore.setState({ notificationsEnabled: true }); + + const { result } = renderHook(() => useOfflineNotification()); + + await act(async () => { + await result.current.requestPermissionOnInteraction(); + }); + + expect(MockNotification.requestPermission).not.toHaveBeenCalled(); + }); + + it("schedules a notification after permission is granted", async () => { + MockNotification.permission = "default"; + // Real browsers update Notification.permission before the promise resolves, + // so the mock must do the same for scheduleNotification()'s isPermissionGranted() + // check to see "granted" when called inside requestPermissionOnInteraction. + MockNotification.requestPermission.mockImplementation(async () => { + MockNotification.permission = "granted" as NotificationPermission; + return "granted" as NotificationPermission; + }); + useSettingsStore.setState({ notificationsEnabled: true }); + + const { result } = renderHook(() => useOfflineNotification()); + + await act(async () => { + await result.current.requestPermissionOnInteraction(); + }); + + vi.advanceTimersByTime(OFFLINE_CAP_MS + 1000); + expect(mockNotificationConstructor).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/hooks/useOfflineNotification.ts b/src/hooks/useOfflineNotification.ts new file mode 100644 index 0000000..d17a508 --- /dev/null +++ b/src/hooks/useOfflineNotification.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useSettingsStore } from "../store/settingsStore"; + +/** The offline-progress cap in milliseconds (8 hours). */ +export const OFFLINE_CAP_MS = 8 * 60 * 60 * 1000; + +const NOTIFICATION_TITLE = "GLORP"; +const NOTIFICATION_BODY = + "GLORP has maxed out! 8 hours of training data is waiting. \u{1F916}"; + +function isNotificationSupported(): boolean { + return typeof Notification !== "undefined"; +} + +function isPermissionGranted(): boolean { + return isNotificationSupported() && Notification.permission === "granted"; +} + +/** + * Schedules a browser notification to fire when the player has been away + * long enough for offline progress to hit the 8-hour cap. + * + * Behaviour: + * - Permission is requested lazily on the player's first click (not on mount), + * satisfying the Safari user-gesture requirement. + * - The 8-hour countdown resets each time the tab regains focus + * (via the Page Visibility API `visibilitychange` event). + * - The player can opt out via the `notificationsEnabled` setting even after + * granting OS-level permission; toggling it off cancels the pending timer. + * - If OS/browser permission is "denied" the feature does nothing silently. + */ +export function useOfflineNotification() { + const notificationsEnabled = useSettingsStore((s) => s.notificationsEnabled); + const timerRef = useRef | null>(null); + const sessionStartRef = useRef(Date.now()); + const hasRequestedRef = useRef(false); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const scheduleNotification = useCallback(() => { + clearTimer(); + if (!notificationsEnabled) return; + if (!isPermissionGranted()) return; + + const elapsed = Date.now() - sessionStartRef.current; + const remaining = OFFLINE_CAP_MS - elapsed; + if (remaining <= 0) return; + + timerRef.current = setTimeout(() => { + // Don't interrupt the player if they're actively viewing the tab. + if ( + typeof document !== "undefined" && + document.visibilityState === "visible" + ) { + return; + } + const notification = new Notification(NOTIFICATION_TITLE, { + body: NOTIFICATION_BODY, + icon: "/favicon.ico", + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + }, remaining); + }, [notificationsEnabled, clearTimer]); + + // Schedule on mount; re-schedule when notificationsEnabled toggles; cancel on unmount. + useEffect(() => { + scheduleNotification(); + return clearTimer; + }, [scheduleNotification, clearTimer]); + + // Reset the 8-hour countdown each time the tab regains focus. + useEffect(() => { + if (typeof document === "undefined") return; + + function handleVisibilityChange() { + if (document.visibilityState === "visible") { + sessionStartRef.current = Date.now(); + scheduleNotification(); + } + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [scheduleNotification]); + + /** + * Requests OS notification permission after the player's first interaction. + * Safe to call many times — only ever prompts once. + */ + const requestPermissionOnInteraction = useCallback(async () => { + if (!isNotificationSupported()) return; + if (Notification.permission !== "default") return; + if (hasRequestedRef.current) return; + + hasRequestedRef.current = true; + try { + const result = await Notification.requestPermission(); + if (result === "granted") { + scheduleNotification(); + } + } catch { + // requestPermission may throw in environments where no user gesture is active. + } + }, [scheduleNotification]); + + // Use a ref so the first-click listener always calls the latest version of + // requestPermissionOnInteraction without re-registering on every render. + const requestPermissionRef = useRef(requestPermissionOnInteraction); + requestPermissionRef.current = requestPermissionOnInteraction; + + // Register a one-time click listener to prompt for permission after first interaction. + useEffect(() => { + if (!isNotificationSupported()) return; + if (Notification.permission !== "default") return; + + function handleFirstClick() { + void requestPermissionRef.current(); + } + + document.addEventListener("click", handleFirstClick, { once: true }); + return () => { + document.removeEventListener("click", handleFirstClick); + }; + }, []); // mount-only — the ref keeps the callback current + + return { requestPermissionOnInteraction }; +} diff --git a/src/store/settingsStore.test.ts b/src/store/settingsStore.test.ts index 7aab052..1e3875b 100644 --- a/src/store/settingsStore.test.ts +++ b/src/store/settingsStore.test.ts @@ -83,4 +83,27 @@ describe("useSettingsStore", () => { expect(useSettingsStore.getState().crtEnabled).toBe(false); expect(useSettingsStore.getState().animationsDisabled).toBe(false); }); + + it("has notificationsEnabled defaulting to true", () => { + const state = useSettingsStore.getState(); + expect(state.notificationsEnabled).toBe(true); + }); + + it("setNotificationsEnabled sets notificationsEnabled to false", () => { + useSettingsStore.getState().setNotificationsEnabled(false); + expect(useSettingsStore.getState().notificationsEnabled).toBe(false); + }); + + it("setNotificationsEnabled sets notificationsEnabled back to true", () => { + useSettingsStore.setState({ notificationsEnabled: false }); + useSettingsStore.getState().setNotificationsEnabled(true); + expect(useSettingsStore.getState().notificationsEnabled).toBe(true); + }); + + it("setNotificationsEnabled does not affect other settings", () => { + useSettingsStore.getState().setNotificationsEnabled(false); + expect(useSettingsStore.getState().crtEnabled).toBe(false); + expect(useSettingsStore.getState().soundEnabled).toBe(true); + expect(useSettingsStore.getState().animationsDisabled).toBe(false); + }); }); diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index bbb37f7..44fee0b 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -12,6 +12,7 @@ export interface SettingsState { buyMode: BuyMode; numberFormat: NumberFormat; soundEnabled: boolean; + notificationsEnabled: boolean; } interface SettingsActions { @@ -20,6 +21,7 @@ interface SettingsActions { setBuyMode: (mode: BuyMode) => void; setNumberFormat: (format: NumberFormat) => void; setSoundEnabled: (enabled: boolean) => void; + setNotificationsEnabled: (enabled: boolean) => void; } export type SettingsStore = SettingsState & SettingsActions; @@ -30,6 +32,7 @@ export const initialSettings: SettingsState = { buyMode: 1, numberFormat: "full", soundEnabled: true, + notificationsEnabled: true, }; export const useSettingsStore = create()( @@ -42,6 +45,8 @@ export const useSettingsStore = create()( setBuyMode: (buyMode) => set({ buyMode }), setNumberFormat: (numberFormat) => set({ numberFormat }), setSoundEnabled: (soundEnabled) => set({ soundEnabled }), + setNotificationsEnabled: (notificationsEnabled) => + set({ notificationsEnabled }), }), { name: "glorp-settings", storage: safeStorage }, ),