Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 20 additions & 39 deletions src/routes/session/$uuid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TrendSentiment, SvgIconOwnProps["color"]> = {
good: "success",
bad: "error",
neutral: undefined,
};

const isLinearStat = (stat: StatKey) => {
return !["fkdr", "kdr", "bblr", "wlr", "index", "winrate"].includes(stat);
Expand Down Expand Up @@ -775,23 +784,10 @@ const SessionStatCard: React.FC<SessionStatCardProps> = ({
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 (
<Card variant="outlined" sx={{ height: "100%", flexGrow: 1 }}>
Expand Down Expand Up @@ -871,25 +867,10 @@ const ProgressionValueAndMilestone: React.FC<ProgressionValueAndMilestoneProps>
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 (
<Stack direction="row" gap={0.5} alignItems="center">
Expand All @@ -913,7 +894,7 @@ const ProgressionValueAndMilestone: React.FC<ProgressionValueAndMilestoneProps>
{formatStatValue(progression.stat, value)}
</Typography>
),
BAD_STATS.includes(progression.stat),
progression.stat,
);
};

Expand Down
38 changes: 38 additions & 0 deletions src/stats/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@ export const STAT_FORMAT_KIND: Record<StatKey, StatFormatKind> = {
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).
Expand Down
88 changes: 88 additions & 0 deletions src/stats/format.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment thread
Amund211 marked this conversation as resolved.
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");
});
});