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
3 changes: 2 additions & 1 deletion src/charts/history/data.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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--) {
Expand Down
33 changes: 33 additions & 0 deletions src/components/TrendIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<TrendDirection, SvgIconComponent> = {
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<Theme>;
}

export const TrendIcon: React.FC<TrendIconProps> = ({
direction,
color,
fontSize,
sx,
}) => {
const Icon = TREND_ICON[direction];
return <Icon color={color} fontSize={fontSize} sx={sx} />;
};
3 changes: 2 additions & 1 deletion src/contexts/KnownAliases/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) ?? [],
]),
Expand Down
3 changes: 2 additions & 1 deletion src/contexts/PlayerVisits/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isNormalizedUUID } from "#helpers/uuid.ts";
import { MS_PER_DAY } from "#time.ts";

export const localStorageKey = "playerVisits";

Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/helpers/duration.ts
Original file line number Diff line number Diff line change
@@ -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`;
};
19 changes: 19 additions & 0 deletions src/helpers/duration.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion src/queries/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<History> => {
if (!isNormalizedUUID(uuid)) {
Expand Down
3 changes: 2 additions & 1 deletion src/queries/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Sessions> => {
if (!isNormalizedUUID(uuid)) {
Expand Down
3 changes: 2 additions & 1 deletion src/queries/username.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion src/queries/uuid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/queries/wrapped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<WrappedData> => {
if (!isNormalizedUUID(uuid)) {
Expand Down
4 changes: 3 additions & 1 deletion src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
80 changes: 20 additions & 60 deletions src/routes/session/$uuid.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 } }) => {
Expand Down Expand Up @@ -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<TrendSentiment, SvgIconOwnProps["color"]> = {
good: "success",
Expand Down Expand Up @@ -438,9 +420,7 @@ const Sessions: React.FC<SessionsProps> = ({
const durationHours =
(session.end.queriedAt.getTime() -
session.start.queriedAt.getTime()) /
1000 /
60 /
60;
MS_PER_HOUR;

if (durationHours <= 0) {
assume(
Expand Down Expand Up @@ -553,9 +533,9 @@ const Sessions: React.FC<SessionsProps> = ({
{session.extrapolated
? "< "
: undefined}
{renderDuration(
session.end.queriedAt,
session.start.queriedAt,
{formatDuration(
session.end.queriedAt.getTime() -
session.start.queriedAt.getTime(),
)}
</Typography>
</TableCell>
Expand Down Expand Up @@ -697,7 +677,8 @@ const SessionStatCard: React.FC<SessionStatCardProps> = ({
<Typography variant="caption" color={undefined}>
<Skeleton variant="text" width={30} />
</Typography>
<TrendingFlat
<TrendIcon
direction="flat"
color={undefined}
fontSize="small"
/>
Expand Down Expand Up @@ -784,10 +765,8 @@ const SessionStatCard: React.FC<SessionStatCardProps> = ({
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 (
<Card variant="outlined" sx={{ height: "100%", flexGrow: 1 }}>
Expand Down Expand Up @@ -819,24 +798,11 @@ const SessionStatCard: React.FC<SessionStatCardProps> = ({
signDisplay: "always",
})}
</Typography>
{diff > 0 && (
<TrendingUp
color={trendColor}
fontSize="small"
/>
)}
{diff < 0 && (
<TrendingDown
color={trendColor}
fontSize="small"
/>
)}
{diff === 0 && (
<TrendingFlat
color={trendColor}
fontSize="small"
/>
)}
<TrendIcon
direction={trendDirection}
color={trendColor}
fontSize="small"
/>
</Stack>
</Tooltip>
</Stack>
Expand Down Expand Up @@ -875,13 +841,7 @@ const ProgressionValueAndMilestone: React.FC<ProgressionValueAndMilestoneProps>
return (
<Stack direction="row" gap={0.5} alignItems="center">
{renderValue(endValue)}
{direction === "up" && <TrendingUp color={color} fontSize="medium" />}
{direction === "down" && (
<TrendingDown color={color} fontSize="medium" />
)}
{direction === "flat" && (
<TrendingFlat color={color} fontSize="medium" />
)}
<TrendIcon direction={direction} color={color} fontSize="medium" />
{renderValue(nextMilestoneValue)}
</Stack>
);
Expand Down Expand Up @@ -1121,11 +1081,11 @@ const StatProgressionCard: React.FC<StatProgressionCardProps> = ({
}

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 (
<Card variant="outlined" sx={{ height: "100%" }}>
Expand Down
4 changes: 2 additions & 2 deletions src/routes/session/-uuid.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading