diff --git a/src/charts/history/data.ts b/src/charts/history/data.ts index 88b2347e..9ae4e35d 100644 --- a/src/charts/history/data.ts +++ b/src/charts/history/data.ts @@ -1,6 +1,7 @@ import type { History } from "#queries/history.ts"; import { computeStat } from "#stats/index.ts"; import { ALL_GAMEMODE_KEYS, ALL_STAT_KEYS, ALL_VARIANT_KEYS } from "#stats/keys.ts"; +import { MS_PER_MINUTE } from "#time.ts"; import { makeDataKey } from "./dataKeys.ts"; import type { DataKey } from "./dataKeys.ts"; @@ -54,7 +55,7 @@ export const clusterChartData = (chartData: ChartData): ChartData => { // oxlint-disable-next-line unicorn/prefer-at const timeSpan = chartData[chartData.length - 1].queriedAt - chartData[0].queriedAt; // Cluster entries that are closer than 1% of the time span or 1 minute - const threshold = Math.max(timeSpan / 100, 1000 * 60); + const threshold = Math.max(timeSpan / 100, MS_PER_MINUTE); const clusteredChartData: MutableChartData = []; for (let i = chartData.length - 1; i >= 0; i--) { diff --git a/src/components/TrendIcon.tsx b/src/components/TrendIcon.tsx new file mode 100644 index 00000000..21eb612d --- /dev/null +++ b/src/components/TrendIcon.tsx @@ -0,0 +1,33 @@ +import { TrendingDown, TrendingFlat, TrendingUp } from "@mui/icons-material"; +import type { SvgIconComponent } from "@mui/icons-material"; +import type { SvgIconOwnProps, SxProps, Theme } from "@mui/material"; + +import type { TrendDirection } from "#stats/format.ts"; + +// Direction → arrow icon, so an up/down/flat movement renders with the same +// icon everywhere. +const TREND_ICON: Record = { + up: TrendingUp, + down: TrendingDown, + flat: TrendingFlat, +}; + +interface TrendIconProps { + readonly direction: TrendDirection; + // Forwarded to the MUI icon. Omit `color` to inherit the surrounding text + // colour. Use `sx` for a pixel size or palette-path colour the props can't + // express, e.g. `{ fontSize: 16, color: "success.main" }`. + readonly color?: SvgIconOwnProps["color"]; + readonly fontSize?: SvgIconOwnProps["fontSize"]; + readonly sx?: SxProps; +} + +export const TrendIcon: React.FC = ({ + direction, + color, + fontSize, + sx, +}) => { + const Icon = TREND_ICON[direction]; + return ; +}; diff --git a/src/contexts/KnownAliases/helpers.ts b/src/contexts/KnownAliases/helpers.ts index 88560fb6..fffedaf5 100644 --- a/src/contexts/KnownAliases/helpers.ts +++ b/src/contexts/KnownAliases/helpers.ts @@ -1,5 +1,6 @@ import { isNormalizedUUID } from "#helpers/uuid.ts"; import { makeLocalStorageWrite } from "#hooks/useLocalStorage.ts"; +import { MS_PER_DAY } from "#time.ts"; export const localStorageKey = "knownAliases"; @@ -153,7 +154,7 @@ export const presentRecentKnownAliases = ( (info) => // Only allow aliases that have been resolved in the last year info.lastResolved.getTime() > - loadedAt.getTime() - 1000 * 60 * 60 * 24 * 30 * 12, + loadedAt.getTime() - MS_PER_DAY * 30 * 12, ) .map((info) => info.username) ?? [], ]), diff --git a/src/contexts/PlayerVisits/helpers.ts b/src/contexts/PlayerVisits/helpers.ts index 5839ed67..e2ed1396 100644 --- a/src/contexts/PlayerVisits/helpers.ts +++ b/src/contexts/PlayerVisits/helpers.ts @@ -1,4 +1,5 @@ import { isNormalizedUUID } from "#helpers/uuid.ts"; +import { MS_PER_DAY } from "#time.ts"; export const localStorageKey = "playerVisits"; @@ -74,7 +75,7 @@ export const parseStoredPlayerVisits = (stored: string | null): PlayerVisits => const lastVisitedWeight = (lastVisited: Date): number => { const diff = loadedAt.getTime() - lastVisited.getTime(); - const days = diff / (1000 * 60 * 60 * 24); + const days = diff / MS_PER_DAY; // NOTE: Also includes days < 0 for when the user has visited a player after the page was loaded if (days < 1) { diff --git a/src/helpers/duration.ts b/src/helpers/duration.ts new file mode 100644 index 00000000..3c2425cd --- /dev/null +++ b/src/helpers/duration.ts @@ -0,0 +1,21 @@ +import { MS_PER_MINUTE } from "#time.ts"; + +/** + * Format a duration given in milliseconds as a short human-readable string. + * + * - Rolls over to days past 24h (`1d 1h`), dropping minutes at that scale. + * - Rounds to the nearest minute and clamps negative inputs to `0m`. + * - Zero-pads the minutes when hours are shown so values line up in a column. + * + * Callers with a start/end pair pass the difference, e.g. + * `formatDuration(end.getTime() - start.getTime())`. + */ +export const formatDuration = (ms: number): string => { + const totalMin = Math.max(0, Math.round(ms / MS_PER_MINUTE)); + const days = Math.floor(totalMin / (60 * 24)); + const hours = Math.floor((totalMin % (60 * 24)) / 60); + const mins = totalMin % 60; + if (days > 0) return `${days.toString()}d ${hours.toString()}h`; + if (hours > 0) return `${hours.toString()}h ${mins.toString().padStart(2, "0")}m`; + return `${mins.toString()}m`; +}; diff --git a/src/helpers/duration.unit.test.ts b/src/helpers/duration.unit.test.ts new file mode 100644 index 00000000..de4173c5 --- /dev/null +++ b/src/helpers/duration.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; + +import { formatDuration } from "./duration.ts"; + +describe(formatDuration, () => { + const cases: { name: string; ms: number; expected: string }[] = [ + { name: "zero", ms: 0, expected: "0m" }, + { name: "negative clamps to 0m", ms: -5000, expected: "0m" }, + { name: "rounds to the nearest minute", ms: 100_000, expected: "2m" }, + { name: "minutes only", ms: 45 * 60_000, expected: "45m" }, + { name: "hours zero-pad the minutes", ms: 65 * 60_000, expected: "1h 05m" }, + { name: "ninety minutes", ms: 90 * 60_000, expected: "1h 30m" }, + { name: "days and hours", ms: 25 * 60 * 60_000, expected: "1d 1h" }, + ]; + + test.each(cases)("$name", ({ ms, expected }) => { + expect(formatDuration(ms)).toBe(expected); + }); +}); diff --git a/src/queries/history.ts b/src/queries/history.ts index e6725462..bcb72c40 100644 --- a/src/queries/history.ts +++ b/src/queries/history.ts @@ -4,6 +4,7 @@ import { queryOptions } from "@tanstack/react-query"; import { env } from "#env.ts"; import { getOrSetUserId } from "#helpers/userId.ts"; import { isNormalizedUUID } from "#helpers/uuid.ts"; +import { MS_PER_MINUTE } from "#time.ts"; import { apiToPlayerDataPIT } from "./playerdata.ts"; import type { APIPlayerDataPIT, PlayerDataPIT } from "./playerdata.ts"; @@ -32,7 +33,7 @@ export const getHistoryQueryOptions = ({ const endISOString = end.toISOString(); return queryOptions({ - staleTime: currentTimeIsInWindow ? 1000 * 60 * 1 : Infinity, + staleTime: currentTimeIsInWindow ? MS_PER_MINUTE : Infinity, queryKey: ["history", uuid, startISOString, endISOString, limit], queryFn: async (): Promise => { if (!isNormalizedUUID(uuid)) { diff --git a/src/queries/sessions.ts b/src/queries/sessions.ts index a9ed41dc..766145d0 100644 --- a/src/queries/sessions.ts +++ b/src/queries/sessions.ts @@ -4,6 +4,7 @@ import { queryOptions } from "@tanstack/react-query"; import { env } from "#env.ts"; import { getOrSetUserId } from "#helpers/userId.ts"; import { isNormalizedUUID } from "#helpers/uuid.ts"; +import { MS_PER_MINUTE } from "#time.ts"; import { apiToPlayerDataPIT } from "./playerdata.ts"; import type { APIPlayerDataPIT, PlayerDataPIT } from "./playerdata.ts"; @@ -51,7 +52,7 @@ export const getSessionsQueryOptions = ({ uuid, start, end }: SessionsQueryOptio const endISOString = end.toISOString(); return queryOptions({ - staleTime: currentTimeIsInWindow ? 1000 * 60 * 1 : Infinity, + staleTime: currentTimeIsInWindow ? MS_PER_MINUTE : Infinity, queryKey: ["sessions", uuid, startISOString, endISOString], queryFn: async (): Promise => { if (!isNormalizedUUID(uuid)) { diff --git a/src/queries/username.ts b/src/queries/username.ts index 9fd8d83e..7965a321 100644 --- a/src/queries/username.ts +++ b/src/queries/username.ts @@ -5,10 +5,11 @@ import { addKnownAliasAndPersist } from "#contexts/KnownAliases/helpers.ts"; import { env } from "#env.ts"; import { getOrSetUserId } from "#helpers/userId.ts"; import { isNormalizedUUID } from "#helpers/uuid.ts"; +import { MS_PER_HOUR } from "#time.ts"; export const getUsernameQueryOptions = (uuid: string) => queryOptions({ - staleTime: 1000 * 60 * 60, + staleTime: MS_PER_HOUR, queryKey: ["username", uuid], queryFn: async (): Promise<{ uuid: string; username: string }> => { if (!isNormalizedUUID(uuid)) { diff --git a/src/queries/uuid.ts b/src/queries/uuid.ts index 5ce06f8e..71429723 100644 --- a/src/queries/uuid.ts +++ b/src/queries/uuid.ts @@ -5,10 +5,11 @@ import { addKnownAliasAndPersist } from "#contexts/KnownAliases/helpers.ts"; import { env } from "#env.ts"; import { getOrSetUserId } from "#helpers/userId.ts"; import { normalizeUUID } from "#helpers/uuid.ts"; +import { MS_PER_DAY } from "#time.ts"; export const getUUIDQueryOptions = (username: string) => queryOptions({ - staleTime: 1000 * 60 * 60 * 24 * 21, + staleTime: MS_PER_DAY * 21, queryKey: ["uuid", username], queryFn: async (): Promise<{ uuid: string; username: string }> => { const response = await fetch( diff --git a/src/queries/wrapped.ts b/src/queries/wrapped.ts index 752f1c67..01fecaf9 100644 --- a/src/queries/wrapped.ts +++ b/src/queries/wrapped.ts @@ -4,6 +4,7 @@ import { queryOptions } from "@tanstack/react-query"; import { env } from "#env.ts"; import { getOrSetUserId } from "#helpers/userId.ts"; import { isNormalizedUUID } from "#helpers/uuid.ts"; +import { MS_PER_MINUTE } from "#time.ts"; import { apiToPlayerDataPIT } from "./playerdata.ts"; import type { APIPlayerDataPIT, PlayerDataPIT } from "./playerdata.ts"; @@ -167,7 +168,7 @@ export const getWrappedQueryOptions = ({ const currentTimeIsInWindow = currentYear === year; return queryOptions({ - staleTime: currentTimeIsInWindow ? 1000 * 60 * 5 : Infinity, + staleTime: currentTimeIsInWindow ? MS_PER_MINUTE * 5 : Infinity, queryKey: ["wrapped", uuid, year, timezone], queryFn: async (): Promise => { if (!isNormalizedUUID(uuid)) { diff --git a/src/queryClient.ts b/src/queryClient.ts index 5fe6dc74..754fb317 100644 --- a/src/queryClient.ts +++ b/src/queryClient.ts @@ -2,7 +2,9 @@ import { QueryClient } from "@tanstack/react-query"; import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; import { get, set, del } from "idb-keyval"; -export const maxAge = 1000 * 60 * 60 * 24 * 21; // 21 days +import { MS_PER_DAY } from "#time.ts"; + +export const maxAge = MS_PER_DAY * 21; // 21 days export function createQueryClient() { return new QueryClient({ diff --git a/src/routes/session/$uuid.tsx b/src/routes/session/$uuid.tsx index 36422ff9..d81d5b24 100644 --- a/src/routes/session/$uuid.tsx +++ b/src/routes/session/$uuid.tsx @@ -1,12 +1,4 @@ -import { - Info, - TrendingDown, - TrendingFlat, - TrendingUp, - InfoOutlined, - QueryStats, - Warning, -} from "@mui/icons-material"; +import { Info, InfoOutlined, QueryStats, Warning } from "@mui/icons-material"; import { Box, Card, @@ -44,9 +36,11 @@ import React from "react"; import { HistoryChart, SimpleHistoryChart } from "#charts/history/chart.tsx"; import { PlayerHead } from "#components/player.tsx"; import { TimeIntervalPicker } from "#components/TimeIntervalPicker.tsx"; +import { TrendIcon } from "#components/TrendIcon.tsx"; import { UserSearch } from "#components/UserSearch.tsx"; import { ChartSynchronizerProvider } from "#contexts/ChartSynchronizer/provider.tsx"; import { usePlayerVisits } from "#contexts/PlayerVisits/hooks.ts"; +import { formatDuration } from "#helpers/duration.ts"; import { addExtrapolatedSessions } from "#helpers/session.ts"; import { normalizeUUID } from "#helpers/uuid.ts"; import { useAssume } from "#hooks/useAssumption.ts"; @@ -78,6 +72,7 @@ import { ERR_TRACKING_STARTED, } from "#stats/progression.ts"; import type { StatProgression } from "#stats/progression.ts"; +import { MS_PER_DAY, MS_PER_HOUR } from "#time.ts"; export const Route = createFileRoute("/session/$uuid")({ loaderDeps: ({ search: { timeIntervalDefinition, trackingStart } }) => { @@ -136,19 +131,6 @@ interface SessionsProps { showExtrapolatedSessions: boolean; } -const renderDuration = (end: Date, start: Date) => { - const duration = end.getTime() - start.getTime(); - const hours = Math.floor(duration / (1000 * 60 * 60)); - const minutes = Math.floor( - (duration % (1000 * 60 * 60)) / (1000 * 60), - ).toLocaleString(); - - // Align the hours with the other rows if this row has hours - const paddedMinutes = minutes.length === 1 && hours ? `0${minutes}` : minutes; - - return hours ? `${hours.toLocaleString()}h ${paddedMinutes}m` : `${paddedMinutes}m`; -}; - // Maps a stat's trend sentiment to the MUI colour used for its value/icon. const SENTIMENT_COLOR: Record = { good: "success", @@ -438,9 +420,7 @@ const Sessions: React.FC = ({ const durationHours = (session.end.queriedAt.getTime() - session.start.queriedAt.getTime()) / - 1000 / - 60 / - 60; + MS_PER_HOUR; if (durationHours <= 0) { assume( @@ -553,9 +533,9 @@ const Sessions: React.FC = ({ {session.extrapolated ? "< " : undefined} - {renderDuration( - session.end.queriedAt, - session.start.queriedAt, + {formatDuration( + session.end.queriedAt.getTime() - + session.start.queriedAt.getTime(), )} @@ -697,7 +677,8 @@ const SessionStatCard: React.FC = ({ - @@ -784,10 +765,8 @@ const SessionStatCard: React.FC = ({ return `Hypixel API disabled for ${getFullStatLabel(stat)}.`; } - const trendColor = - SENTIMENT_COLOR[ - getTrendSentiment(stat, getTrendDirection(startValue, endValue)) - ]; + const trendDirection = getTrendDirection(startValue, endValue); + const trendColor = SENTIMENT_COLOR[getTrendSentiment(stat, trendDirection)]; return ( @@ -819,24 +798,11 @@ const SessionStatCard: React.FC = ({ signDisplay: "always", })} - {diff > 0 && ( - - )} - {diff < 0 && ( - - )} - {diff === 0 && ( - - )} + @@ -875,13 +841,7 @@ const ProgressionValueAndMilestone: React.FC return ( {renderValue(endValue)} - {direction === "up" && } - {direction === "down" && ( - - )} - {direction === "flat" && ( - - )} + {renderValue(nextMilestoneValue)} ); @@ -1121,11 +1081,11 @@ const StatProgressionCard: React.FC = ({ } const projectedMilestoneDate = new Date( - referenceDate.getTime() + progression.daysUntilMilestone * 24 * 60 * 60 * 1000, + referenceDate.getTime() + progression.daysUntilMilestone * MS_PER_DAY, ); const daysUntilMilestoneFromNow = - (projectedMilestoneDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + (projectedMilestoneDate.getTime() - now.getTime()) / MS_PER_DAY; return ( diff --git a/src/routes/session/-uuid.ui.test.tsx b/src/routes/session/-uuid.ui.test.tsx index af9ffe54..1b648e88 100644 --- a/src/routes/session/-uuid.ui.test.tsx +++ b/src/routes/session/-uuid.ui.test.tsx @@ -557,10 +557,10 @@ describe("Session detail page", () => { await expect .poll(() => { const cells = document.querySelectorAll("td"); - // Duration format contains "m" for minutes or "h" for hours + // Duration renders as "Xd Yh", "Xh YYm", or "Xm" return [...cells].some((cell) => { const text = cell.textContent.trim(); - return /\d+m/.test(text); + return /\d+[dhm]/.test(text); }); }) .toBe(true); diff --git a/src/routes/wrapped/$uuid.tsx b/src/routes/wrapped/$uuid.tsx index b1d5b607..f0278a1d 100644 --- a/src/routes/wrapped/$uuid.tsx +++ b/src/routes/wrapped/$uuid.tsx @@ -53,6 +53,7 @@ import { computeStat } from "#stats/index.ts"; import type { StatKey } from "#stats/keys.ts"; import { createExportTheme } from "#theme/index.ts"; import { rainbowGradient } from "#theme/tokens.ts"; +import { MS_PER_DAY, MS_PER_HOUR } from "#time.ts"; const getDefaultTimeZone = (): string => { return new Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -301,7 +302,7 @@ const BestSessionCard: React.FC = ({ // Calculate session duration in hours const durationMs = endDate.getTime() - startDate.getTime(); - const durationHours = durationMs / (1000 * 60 * 60); + const durationHours = durationMs / MS_PER_HOUR; const { start, end } = session; const history = [start]; @@ -1745,12 +1746,12 @@ function WrappedHeader({ wrappedData, uuid, year }: WrappedHeaderProps) { {wrappedData?.yearStats && wrappedData.yearStats.end.queriedAt.getTime() - wrappedData.yearStats.start.queriedAt.getTime() < - 1000 * 60 * 60 * 24 * 30 * 8 && ( + MS_PER_DAY * 30 * 8 && ( Date = () => new Date()) => + z.object({ + // TODO: Read "preferred user" from local storage or similar + uuids: z.array(z.string()).readonly().catch([]).default([]), + start: z.coerce + .date() + .catch(() => startOfDay(now())) + .default(() => startOfDay(now())), + end: z.coerce + .date() + .catch(() => endOfDay(now())) + .default(() => endOfDay(now())), + limit: z.number().int().min(1).max(50).catch(50).default(50), + stats: z + .enum(ALL_STAT_KEYS) + .array() + .readonly() + .catch(["fkdr"]) + .default(["fkdr"]), + gamemodes: z + .enum(ALL_GAMEMODE_KEYS) + .array() + .readonly() + .catch(["overall"]) + .default(["overall"]), + variantSelection: z + .enum(["session", "overall", "both"]) + .catch("session") + .default("session"), + }); -export const historyExploreSearchSchema = z.object({ - // TODO: Read "preferred user" from local storage or similar - uuids: z.array(z.string()).readonly().catch([]).default([]), - start: z.coerce.date().catch(defaultStart).default(defaultStart), - end: z.coerce.date().catch(defaultEnd).default(defaultEnd), - limit: z.number().int().min(1).max(50).catch(50).default(50), - stats: z.enum(ALL_STAT_KEYS).array().readonly().catch(["fkdr"]).default(["fkdr"]), - gamemodes: z - .enum(ALL_GAMEMODE_KEYS) - .array() - .readonly() - .catch(["overall"]) - .default(["overall"]), - variantSelection: z - .enum(["session", "overall", "both"]) - .catch("session") - .default("session"), -}); +export const historyExploreSearchSchema = makeHistoryExploreSearchSchema(); diff --git a/src/schemas/historySearch.unit.test.ts b/src/schemas/historySearch.unit.test.ts index fa6fe5b1..9959a946 100644 --- a/src/schemas/historySearch.unit.test.ts +++ b/src/schemas/historySearch.unit.test.ts @@ -1,13 +1,16 @@ import { test, expect, describe } from "vitest"; +import { endOfDay, startOfDay } from "#intervals.ts"; import { ALL_GAMEMODE_KEYS, ALL_STAT_KEYS } from "#stats/keys.ts"; -import { historyExploreSearchSchema } from "./historySearch.ts"; +import { makeHistoryExploreSearchSchema } from "./historySearch.ts"; -const defaultStart = new Date(); -defaultStart.setHours(0, 0, 0, 0); -const defaultEnd = new Date(); -defaultEnd.setHours(23, 59, 59, 999); +// Pin "now" so the start/end fallbacks are deterministic instead of drifting +// with the wall-clock and matching the source only by coincidence. +const NOW = new Date("2025-06-15T12:34:56.789Z"); +const defaultStart = startOfDay(NOW); +const defaultEnd = endOfDay(NOW); +const historyExploreSearchSchema = makeHistoryExploreSearchSchema(() => NOW); describe("historyExploreSearchSchema validation", () => { test("no params -> all defaults", () => { @@ -55,16 +58,14 @@ describe("historyExploreSearchSchema validation", () => { const result = historyExploreSearchSchema.parse({ start: "invalid-date", }); - // Was struggling with equality on the dates here. Converting to time - expect(result.start.getTime()).toBe(defaultStart.getTime()); + expect(result.start).toStrictEqual(defaultStart); }); test("invalid end date -> fallback to default", () => { const result = historyExploreSearchSchema.parse({ end: "invalid-date", }); - // Was struggling with equality on the dates here. Converting to time - expect(result.end.getTime()).toBe(defaultEnd.getTime()); + expect(result.end).toStrictEqual(defaultEnd); }); test("invalid limit -> fallback to default", () => { diff --git a/src/schemas/sessionSearch.ts b/src/schemas/sessionSearch.ts index 3c6caa1c..aa959fba 100644 --- a/src/schemas/sessionSearch.ts +++ b/src/schemas/sessionSearch.ts @@ -3,39 +3,47 @@ import { z } from "zod"; import { startOfDay } from "#intervals.ts"; import { ALL_GAMEMODE_KEYS, ALL_STAT_KEYS } from "#stats/keys.ts"; -export const sessionSearchSchema = z.object({ - timeIntervalDefinition: z - .union([ - z.object({ - type: z.literal("contained"), - date: z.coerce.date().optional().catch(undefined), +// `now` is injected so tests can pin "current time" deterministically. The real +// dependency is bound in the singleton export below, so consumers stay +// unchanged. `trackingStart`'s default reads `now()` inside the transform, so +// it is recomputed on every parse rather than frozen at module-eval time. +export const makeSessionSearchSchema = (now: () => Date = () => new Date()) => + z.object({ + timeIntervalDefinition: z + .union([ + z.object({ + type: z.literal("contained"), + date: z.coerce.date().optional().catch(undefined), + }), + z.object({ + type: z.literal("until"), + date: z.coerce.date().optional().catch(undefined), + }), + ]) + .catch({ type: "contained" }) + .default({ type: "contained" }), + trackingStart: z.coerce + .date() + .optional() + .catch(undefined) + .transform((value) => { + if (value === undefined) { + // Default to start of day 1 year ago (same date). Copy the + // injected Date so we never mutate the caller's instance. + const date = new Date(now()); + date.setFullYear(date.getFullYear() - 1); + return startOfDay(date); + } + return value; }), - z.object({ - type: z.literal("until"), - date: z.coerce.date().optional().catch(undefined), - }), - ]) - .catch({ type: "contained" }) - .default({ type: "contained" }), - trackingStart: z.coerce - .date() - .optional() - .catch(undefined) - .transform((value) => { - if (value === undefined) { - // Default to start of day 1 year ago (same date) - const date = new Date(); - date.setFullYear(date.getFullYear() - 1); - return startOfDay(date); - } - return value; - }), - gamemode: z.enum(ALL_GAMEMODE_KEYS).catch("overall").default("overall"), - stat: z.enum(ALL_STAT_KEYS).catch("fkdr").default("fkdr"), - variantSelection: z - .enum(["session", "overall", "both"]) - .catch("both") - .default("both"), - sessionTableMode: z.enum(["total", "rate"]).catch("total").default("total"), - showExtrapolatedSessions: z.boolean().catch(false).default(false), -}); + gamemode: z.enum(ALL_GAMEMODE_KEYS).catch("overall").default("overall"), + stat: z.enum(ALL_STAT_KEYS).catch("fkdr").default("fkdr"), + variantSelection: z + .enum(["session", "overall", "both"]) + .catch("both") + .default("both"), + sessionTableMode: z.enum(["total", "rate"]).catch("total").default("total"), + showExtrapolatedSessions: z.boolean().catch(false).default(false), + }); + +export const sessionSearchSchema = makeSessionSearchSchema(); diff --git a/src/schemas/sessionSearch.unit.test.ts b/src/schemas/sessionSearch.unit.test.ts index 3c4af6b0..c34eb701 100644 --- a/src/schemas/sessionSearch.unit.test.ts +++ b/src/schemas/sessionSearch.unit.test.ts @@ -2,20 +2,25 @@ import { test, expect, describe } from "vitest"; import { ALL_GAMEMODE_KEYS, ALL_STAT_KEYS } from "#stats/keys.ts"; -import { sessionSearchSchema } from "./sessionSearch.ts"; +import { makeSessionSearchSchema } from "./sessionSearch.ts"; -// Helper to get the expected default tracking start (start of day 1 year ago) -const getDefaultTrackingStart = () => { - const date = new Date(); +// Pin "now" so the tracking-start default is deterministic instead of drifting +// with the wall-clock and matching the source only by coincidence. +const NOW = new Date("2025-06-15T12:34:56.789Z"); +const sessionSearchSchema = makeSessionSearchSchema(() => NOW); + +// The default tracking start is the start of day, one year before NOW. +const expectedTrackingStart = (() => { + const date = new Date(NOW); date.setFullYear(date.getFullYear() - 1); date.setHours(0, 0, 0, 0); return date; -}; +})(); describe("sessionSearchSchema validation", () => { test("no params -> all defaults", () => { const result = sessionSearchSchema.parse({}); - const expectedDefault = getDefaultTrackingStart(); + const expectedDefault = expectedTrackingStart; expect(result).toStrictEqual({ gamemode: "overall", stat: "fkdr", @@ -104,32 +109,13 @@ describe("sessionSearchSchema validation", () => { const result = sessionSearchSchema.parse({ trackingStart: "invalid", }); - const expectedDefault = getDefaultTrackingStart(); + const expectedDefault = expectedTrackingStart; expect(result.trackingStart).toStrictEqual(expectedDefault); }); - test("default trackingStart is 1 year ago from now (same date)", () => { + test("default trackingStart is the start of day one year before now", () => { const result = sessionSearchSchema.parse({}); - const now = new Date(); - const expectedDate = new Date(); - expectedDate.setFullYear(expectedDate.getFullYear() - 1); - expectedDate.setHours(0, 0, 0, 0); - - // Verify the year is exactly 1 year less - expect(result.trackingStart.getFullYear()).toBe(now.getFullYear() - 1); - - // Verify the month and day are the same - // NOTE: This test will fail on leap years when run on Feb 29 - // because setFullYear on Feb 29 of a leap year going back to a non-leap year - // will result in March 1 instead of Feb 28 - expect(result.trackingStart.getMonth()).toBe(expectedDate.getMonth()); - expect(result.trackingStart.getDate()).toBe(expectedDate.getDate()); - - // Also verify it's at the start of the day (midnight) - expect(result.trackingStart.getHours()).toBe(0); - expect(result.trackingStart.getMinutes()).toBe(0); - expect(result.trackingStart.getSeconds()).toBe(0); - expect(result.trackingStart.getMilliseconds()).toBe(0); + expect(result.trackingStart).toStrictEqual(expectedTrackingStart); }); test("date coercion understands simple date strings", () => { @@ -233,7 +219,7 @@ describe("sessionSearchSchema validation", () => { sessionTableMode: "rate", showExtrapolatedSessions: "invalid", }); - const expectedDefault = getDefaultTrackingStart(); + const expectedDefault = expectedTrackingStart; expect(result.gamemode).toBe("overall"); // fallback expect(result.stat).toBe("wins"); // valid expect(result.variantSelection).toBe("both"); // fallback diff --git a/src/schemas/wrappedSearch.ts b/src/schemas/wrappedSearch.ts index ad14a9f0..a99a5e69 100644 --- a/src/schemas/wrappedSearch.ts +++ b/src/schemas/wrappedSearch.ts @@ -1,15 +1,26 @@ import { z } from "zod"; -import { getWrappedYear } from "#helpers/wrapped.ts"; +import { computeWrappedYear } from "#helpers/wrapped.ts"; -const currentWrappedYear = getWrappedYear(); +// `now` is injected so tests can pin "current time" deterministically. The real +// dependency is bound in the singleton export below, so consumers stay +// unchanged. +export const makeWrappedSearchSchema = (now: () => Date = () => new Date()) => + z.object({ + year: z.coerce + .number() + .int() + .min(2025) // First full year with flashlight data from prism (started December 2024) + // NOTE: This upper bound is frozen at schema-construction time. + // Zod's `.max()` takes a plain value, not a thunk, so it cannot be + // recomputed per parse the way `.catch`/`.default` can. In a + // long-lived tab that crosses into a new wrapped year the bound goes + // stale; the fallbacks below stay current, so the practical impact + // is that a fresh-but-out-of-range year is rejected to the (also + // fresh) fallback. Acceptable drift. + .max(computeWrappedYear(now())) + .catch(() => computeWrappedYear(now())) + .default(() => computeWrappedYear(now())), + }); -export const wrappedSearchSchema = z.object({ - year: z.coerce - .number() - .int() - .min(2025) // First full year with flashlight data from prism (started December 2024) - .max(currentWrappedYear) - .catch(currentWrappedYear) - .default(currentWrappedYear), -}); +export const wrappedSearchSchema = makeWrappedSearchSchema(); diff --git a/src/schemas/wrappedSearch.unit.test.ts b/src/schemas/wrappedSearch.unit.test.ts index 21f75b50..e6a98915 100644 --- a/src/schemas/wrappedSearch.unit.test.ts +++ b/src/schemas/wrappedSearch.unit.test.ts @@ -1,37 +1,44 @@ import { test, expect, describe } from "vitest"; -import { getWrappedYear } from "#helpers/wrapped.ts"; +import { computeWrappedYear } from "#helpers/wrapped.ts"; -import { wrappedSearchSchema } from "./wrappedSearch.ts"; +import { makeWrappedSearchSchema } from "./wrappedSearch.ts"; + +// Pin "now" so the current wrapped year is deterministic. In December the +// current calendar year is the active wrapped year, giving us a range +// [2025, 2026] to exercise both bounds. +const NOW = new Date("2026-12-15T12:00:00.000Z"); +const currentWrappedYear = computeWrappedYear(NOW); // 2026 +const wrappedSearchSchema = makeWrappedSearchSchema(() => NOW); describe("wrappedSearchSchema validation", () => { - test("no params -> all defaults", () => { + test("no params -> defaults to the current wrapped year", () => { const result = wrappedSearchSchema.parse({}); - expect(result).toStrictEqual({ - year: getWrappedYear(), - }); + expect(result).toStrictEqual({ year: currentWrappedYear }); + }); + + test("valid year within range", () => { + const result = wrappedSearchSchema.parse({ year: 2025 }); + expect(result).toStrictEqual({ year: 2025 }); + }); + + test("year at the current-wrapped-year upper bound is valid", () => { + const result = wrappedSearchSchema.parse({ year: currentWrappedYear }); + expect(result).toStrictEqual({ year: currentWrappedYear }); }); - test("valid custom values", () => { - const result = wrappedSearchSchema.parse({ - year: 2025, - }); - expect(result).toStrictEqual({ - year: 2025, - }); + test("invalid year -> fallback to current wrapped year", () => { + const result = wrappedSearchSchema.parse({ year: "not-a-number" }); + expect(result.year).toBe(currentWrappedYear); }); - test("invalid year -> fallback to default", () => { - const result = wrappedSearchSchema.parse({ - year: "not-a-number", - }); - expect(result.year).toBe(getWrappedYear()); + test("year below the minimum -> fallback to current wrapped year", () => { + const result = wrappedSearchSchema.parse({ year: 1999 }); + expect(result.year).toBe(currentWrappedYear); }); - test("year out of range -> fallback to default", () => { - const result = wrappedSearchSchema.parse({ - year: 1999, - }); - expect(result.year).toBe(getWrappedYear()); + test("year above the upper bound -> fallback to current wrapped year", () => { + const result = wrappedSearchSchema.parse({ year: currentWrappedYear + 1 }); + expect(result.year).toBe(currentWrappedYear); }); }); diff --git a/src/stats/progression.ts b/src/stats/progression.ts index 85158007..0386d67d 100644 --- a/src/stats/progression.ts +++ b/src/stats/progression.ts @@ -1,5 +1,6 @@ import type { TimeInterval } from "#intervals.ts"; import type { History } from "#queries/history.ts"; +import { MS_PER_DAY } from "#time.ts"; import { getStat } from "./index.ts"; import type { GamemodeKey, StatKey } from "./keys.ts"; @@ -218,8 +219,7 @@ const computeQuotientProgression = ( const [start, end] = trackingHistory; const startDate = start.queriedAt; const endDate = trackingEnd; - const daysElapsed = - (endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000); + const daysElapsed = (endDate.getTime() - startDate.getTime()) / MS_PER_DAY; const startDividend = getStat(start, gamemode, dividendStat); const startDivisor = getStat(start, gamemode, divisorStat); @@ -402,8 +402,7 @@ export const computeStatProgression = ( const [start, end] = trackingHistory; const startDate = start.queriedAt; const endDate = trackingEnd; - const daysElapsed = - (endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000); + const daysElapsed = (endDate.getTime() - startDate.getTime()) / MS_PER_DAY; switch (stat) { case "stars": { diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 00000000..c9951463 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,5 @@ +// Milliseconds per unit of time, so duration maths reads in named units instead +// of repeated `24 * 60 * 60 * 1000`-style literals. +export const MS_PER_MINUTE = 60_000; +export const MS_PER_HOUR = 60 * MS_PER_MINUTE; +export const MS_PER_DAY = 24 * MS_PER_HOUR;