diff --git a/src/routes/session/$uuid.tsx b/src/routes/session/$uuid.tsx index d9ed9bca..36422ff9 100644 --- a/src/routes/session/$uuid.tsx +++ b/src/routes/session/$uuid.tsx @@ -57,7 +57,12 @@ import { getSessionsQueryOptions } from "#queries/sessions.ts"; import type { Sessions } from "#queries/sessions.ts"; import { getUsernameQueryOptions, useUUIDToUsername } from "#queries/username.ts"; import { sessionSearchSchema } from "#schemas/sessionSearch.ts"; -import { formatStatValue } from "#stats/format.ts"; +import { + formatStatValue, + getTrendDirection, + getTrendSentiment, +} from "#stats/format.ts"; +import type { TrendSentiment } from "#stats/format.ts"; import { computeStat } from "#stats/index.ts"; import { ALL_GAMEMODE_KEYS, ALL_STAT_KEYS } from "#stats/keys.ts"; import type { GamemodeKey, StatKey } from "#stats/keys.ts"; @@ -144,8 +149,12 @@ const renderDuration = (end: Date, start: Date) => { return hours ? `${hours.toLocaleString()}h ${paddedMinutes}m` : `${paddedMinutes}m`; }; -// Stats where a positive trend is "bad" (i.e. rendered in error colours). -const BAD_STATS: readonly StatKey[] = ["deaths", "finalDeaths", "bedsLost", "losses"]; +// Maps a stat's trend sentiment to the MUI colour used for its value/icon. +const SENTIMENT_COLOR: Record = { + good: "success", + bad: "error", + neutral: undefined, +}; const isLinearStat = (stat: StatKey) => { return !["fkdr", "kdr", "bblr", "wlr", "index", "winrate"].includes(stat); @@ -775,23 +784,10 @@ const SessionStatCard: React.FC = ({ return `Hypixel API disabled for ${getFullStatLabel(stat)}.`; } - let trendDirection: "flat" | "up" | "down"; - if (diff === 0) { - trendDirection = "flat"; - } else if (diff > 0) { - trendDirection = "up"; - } else { - trendDirection = "down"; - } - - let trendColor: SvgIconOwnProps["color"]; - if (trendDirection === "flat") { - trendColor = undefined; - } else if ((trendDirection === "up") === BAD_STATS.includes(stat)) { - trendColor = "error"; - } else { - trendColor = "success"; - } + const trendColor = + SENTIMENT_COLOR[ + getTrendSentiment(stat, getTrendDirection(startValue, endValue)) + ]; return ( @@ -871,25 +867,10 @@ const ProgressionValueAndMilestone: React.FC endValue: number, nextMilestoneValue: number, renderValue: (value: number) => React.ReactNode, - badStat: boolean, + stat: StatKey, ) => { - let direction: "up" | "down" | "flat"; - if (nextMilestoneValue > endValue) { - direction = "up"; - } else if (nextMilestoneValue < endValue) { - direction = "down"; - } else { - direction = "flat"; - } - - let color: SvgIconOwnProps["color"]; - if (direction === "flat") { - color = undefined; - } else if ((direction === "up") === badStat) { - color = "error"; - } else { - color = "success"; - } + const direction = getTrendDirection(endValue, nextMilestoneValue); + const color = SENTIMENT_COLOR[getTrendSentiment(stat, direction)]; return ( @@ -913,7 +894,7 @@ const ProgressionValueAndMilestone: React.FC {formatStatValue(progression.stat, value)} ), - BAD_STATS.includes(progression.stat), + progression.stat, ); }; diff --git a/src/stats/format.ts b/src/stats/format.ts index 74853bca..a1d848ec 100644 --- a/src/stats/format.ts +++ b/src/stats/format.ts @@ -28,6 +28,44 @@ export const STAT_FORMAT_KIND: Record = { export const getStatFormatKind = (stat: StatKey): StatFormatKind => STAT_FORMAT_KIND[stat]; +// Stats where a rising value is "bad" (i.e. rendered in error colours): more +// deaths, more losses, etc. Every other stat improves as it grows. +export const BAD_STATS: readonly StatKey[] = [ + "deaths", + "finalDeaths", + "bedsLost", + "losses", +]; + +export const isBadStat = (stat: StatKey): boolean => BAD_STATS.includes(stat); + +export type TrendDirection = "up" | "down" | "flat"; + +// Three-way comparison of a stat's movement from `from` to `to`. +export const getTrendDirection = (from: number, to: number): TrendDirection => { + if (to > from) { + return "up"; + } + if (to < from) { + return "down"; + } + return "flat"; +}; + +export type TrendSentiment = "good" | "bad" | "neutral"; + +// Whether moving in `direction` is good or bad for `stat`. A rising bad stat (or +// a falling good stat) is "bad"; the opposite is "good"; no movement is neutral. +export const getTrendSentiment = ( + stat: StatKey, + direction: TrendDirection, +): TrendSentiment => { + if (direction === "flat") { + return "neutral"; + } + return (direction === "up") === isBadStat(stat) ? "bad" : "good"; +}; + interface FormatStatValueOptions { // "compact" → cards / diffs / short contexts (fewer decimals); // "detailed" → values / tooltips / hover detail (more decimals). diff --git a/src/stats/format.unit.test.ts b/src/stats/format.unit.test.ts new file mode 100644 index 00000000..f44d1408 --- /dev/null +++ b/src/stats/format.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; + +import { + BAD_STATS, + getTrendDirection, + getTrendSentiment, + isBadStat, +} from "./format.ts"; +import { ALL_STAT_KEYS } from "./keys.ts"; + +describe("isBadStat - good/bad classification", () => { + test("bad stats are flagged", () => { + for (const stat of BAD_STATS) { + expect(isBadStat(stat)).toBe(true); + } + }); + + test("every non-bad stat is good", () => { + for (const stat of ALL_STAT_KEYS) { + expect(isBadStat(stat)).toBe(BAD_STATS.includes(stat)); + } + }); + + // Literal tables, kept independent of BAD_STATS so the expectations don't + // derive from the very constant under test. The good-stat table also guards + // completeness: a newly added stat fails here until it's classified. + test("exactly these stats are bad", () => { + expect( + ["deaths", "finalDeaths", "bedsLost", "losses"].toSorted(), + ).toStrictEqual(ALL_STAT_KEYS.filter(isBadStat).toSorted()); + }); + + test("every other stat is good", () => { + expect( + [ + "experience", + "stars", + "winstreak", + "gamesPlayed", + "wins", + "bedsBroken", + "finalKills", + "kills", + "fkdr", + "kdr", + "bblr", + "wlr", + "winrate", + "index", + ].toSorted(), + ).toStrictEqual(ALL_STAT_KEYS.filter((stat) => !isBadStat(stat)).toSorted()); + }); +}); + +describe("getTrendDirection - numeric movement", () => { + test("classifies rising, falling, and flat movement", () => { + expect(getTrendDirection(1, 2)).toBe("up"); + expect(getTrendDirection(2, 1)).toBe("down"); + expect(getTrendDirection(2, 2)).toBe("flat"); + }); + + test("works with a diff against zero", () => { + expect(getTrendDirection(0, 0.5)).toBe("up"); + expect(getTrendDirection(0, -0.5)).toBe("down"); + expect(getTrendDirection(0, 0)).toBe("flat"); + }); +}); + +describe("getTrendSentiment - direction to good/bad", () => { + test("flat movement is always neutral", () => { + expect(getTrendSentiment("wins", "flat")).toBe("neutral"); + expect(getTrendSentiment("deaths", "flat")).toBe("neutral"); + }); + + test("a rising good stat is good, a falling good stat is bad", () => { + expect(getTrendSentiment("wins", "up")).toBe("good"); + expect(getTrendSentiment("wins", "down")).toBe("bad"); + expect(getTrendSentiment("fkdr", "up")).toBe("good"); + expect(getTrendSentiment("fkdr", "down")).toBe("bad"); + }); + + test("a rising bad stat is bad, a falling bad stat is good", () => { + expect(getTrendSentiment("deaths", "up")).toBe("bad"); + expect(getTrendSentiment("deaths", "down")).toBe("good"); + expect(getTrendSentiment("losses", "up")).toBe("bad"); + expect(getTrendSentiment("losses", "down")).toBe("good"); + }); +});