HASH 9a9841cc76d1
DATE 2026-05-03
SUBJECT web: add tier-aware time coloring across CM pages
FILES 8 CHANGED
HASH 9a9841cc76d1
DATE 2026-05-03
SUBJECT web: add tier-aware time coloring
across CM pages
FILES 8 CHANGED
┌─ web/src/components/DungeonTime/DungeonTime.astro ─────────────────────────┐
│ diff --git a/web/src/components/DungeonTime/DungeonTime.astro b/web/src/components │
│ /DungeonTime/DungeonTime.astro │
│ new file mode 100644 │
│ index 0000000..5bbaf40 │
│ --- /dev/null │
│ +++ b/web/src/components/DungeonTime/DungeonTime.astro │
│ @@ -0,0 +1,36 @@ │
│ +--- │
│ +import { formatDurationMMSS } from "../../lib/utils"; │
│ +import { │
│ + getTimeClass, │
│ + getTierTooltip, │
│ + dungeonIdFromSlug, │
│ +} from "../../lib/dungeon-thresholds"; │
│ + │
│ +interface Props { │
│ + // Pass whichever the caller has - id wins if both are supplied. │
│ + dungeonId?: number | null; │
│ + dungeonSlug?: string | null; │
│ + durationMs: number; │
│ + // Override the displayed text (default: "M:SS"). │
│ + display?: string; │
│ + // Extra classes appended after the tier class. │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + dungeonId, │
│ + dungeonSlug, │
│ + durationMs, │
│ + display, │
│ + class: extraClass, │
│ +} = Astro.props; │
│ + │
│ +const resolvedId = dungeonId ?? dungeonIdFromSlug(dungeonSlug); │
│ +const tierClass = resolvedId != null ? getTimeClass(resolvedId, durationMs) : nul │
│ l; │
│ +const tooltip = resolvedId != null ? getTierTooltip(resolvedId, durationMs) : nul │
│ l; │
│ +const text = display ?? formatDurationMMSS(durationMs); │
│ +--- │
│ + │
│ +<span class:list={["time", tierClass, extraClass]} title={tooltip}> │
│ + {text} │
│ +</span> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...DungeonTime/DungeonTime.astro ───┐
│ diff --git a/web/src/components/DungeonTime/ │
│ DungeonTime.astro b/web/src/components/Dunge │
│ onTime/DungeonTime.astro │
│ new file mode 100644 │
│ index 0000000..5bbaf40 │
│ --- /dev/null │
│ +++ b/web/src/components/DungeonTime/Dungeon │
│ Time.astro │
│ @@ -0,0 +1,36 @@ │
│ +--- │
│ +import { formatDurationMMSS } from "../../l │
│ ib/utils"; │
│ +import { │
│ + getTimeClass, │
│ + getTierTooltip, │
│ + dungeonIdFromSlug, │
│ +} from "../../lib/dungeon-thresholds"; │
│ + │
│ +interface Props { │
│ + // Pass whichever the caller has - id win │
│ s if both are supplied. │
│ + dungeonId?: number | null; │
│ + dungeonSlug?: string | null; │
│ + durationMs: number; │
│ + // Override the displayed text (default: │
│ "M:SS"). │
│ + display?: string; │
│ + // Extra classes appended after the tier │
│ class. │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + dungeonId, │
│ + dungeonSlug, │
│ + durationMs, │
│ + display, │
│ + class: extraClass, │
│ +} = Astro.props; │
│ + │
│ +const resolvedId = dungeonId ?? dungeonIdFr │
│ omSlug(dungeonSlug); │
│ +const tierClass = resolvedId != null ? getT │
│ imeClass(resolvedId, durationMs) : null; │
│ +const tooltip = resolvedId != null ? getTie │
│ rTooltip(resolvedId, durationMs) : null; │
│ +const text = display ?? formatDurationMMSS( │
│ durationMs); │
│ +--- │
│ + │
│ +<span class:list={["time", tierClass, extra │
│ Class]} title={tooltip}> │
│ + {text} │
│ +</span> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/ChallengeRun/ChallengeRun.astro ──────────────────┐
│ diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.astro b/web/src/com │
│ ponents/Home/ChallengeRun/ChallengeRun.astro │
│ index af2a617..85a9d6c 100644 │
│ --- a/web/src/components/Home/ChallengeRun/ChallengeRun.astro │
│ +++ b/web/src/components/Home/ChallengeRun/ChallengeRun.astro │
│ @@ -1,8 +1,9 @@ │
│ --- │
│ import "./ChallengeRun.scss"; │
│ -import { formatDurationMMSS, formatShortDate } from "../../../lib/utils"; │
│ +import { formatShortDate } from "../../../lib/utils"; │
│ import { getDungeonIconUrl } from "../../../lib/dungeonIcons"; │
│ import TeamComposition from "../../Leaderboard/TeamComposition/TeamComposition.as │
│ tro"; │
│ +import DungeonTime from "../../DungeonTime/DungeonTime.astro"; │
│ import type { TeamMember } from "../../../lib/types"; │
│ │
│ interface Props { │
│ @@ -62,7 +63,11 @@ const bracketClass = bracket ? `bracket-${bracket}` : ""; │
│ <TeamComposition members={teamMembers} /> │
│ </div> │
│ │
│ - <span class="challenge-run__duration">{formatDurationMMSS(durationMs)}</span> │
│ + <DungeonTime │
│ + class="challenge-run__duration" │
│ + dungeonSlug={dungeonSlug} │
│ + durationMs={durationMs} │
│ + /> │
│ │
│ <time │
│ class="challenge-run__date" │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...allengeRun/ChallengeRun.astro ───┐
│ diff --git a/web/src/components/Home/Challen │
│ geRun/ChallengeRun.astro b/web/src/component │
│ s/Home/ChallengeRun/ChallengeRun.astro │
│ index af2a617..85a9d6c 100644 │
│ --- a/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.astro │
│ +++ b/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.astro │
│ @@ -1,8 +1,9 @@ │
│ --- │
│ import "./ChallengeRun.scss"; │
│ -import { formatDurationMMSS, formatShortDat │
│ e } from "../../../lib/utils"; │
│ +import { formatShortDate } from "../../../l │
│ ib/utils"; │
│ import { getDungeonIconUrl } from "../../.. │
│ /lib/dungeonIcons"; │
│ import TeamComposition from "../../Leaderbo │
│ ard/TeamComposition/TeamComposition.astro"; │
│ +import DungeonTime from "../../DungeonTime/ │
│ DungeonTime.astro"; │
│ import type { TeamMember } from "../../../l │
│ ib/types"; │
│ │
│ interface Props { │
│ @@ -62,7 +63,11 @@ const bracketClass = brac │
│ ket ? `bracket-${bracket}` : ""; │
│ <TeamComposition members={teamMembers} │
│ /> │
│ </div> │
│ │
│ - <span class="challenge-run__duration">{fo │
│ rmatDurationMMSS(durationMs)}</span> │
│ + <DungeonTime │
│ + class="challenge-run__duration" │
│ + dungeonSlug={dungeonSlug} │
│ + durationMs={durationMs} │
│ + /> │
│ │
│ <time │
│ class="challenge-run__date" │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/ChallengeRun/ChallengeRun.scss ───────────────────┐
│ diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.scss b/web/src/comp │
│ onents/Home/ChallengeRun/ChallengeRun.scss │
│ index 0ad9de2..43e51a0 100644 │
│ --- a/web/src/components/Home/ChallengeRun/ChallengeRun.scss │
│ +++ b/web/src/components/Home/ChallengeRun/ChallengeRun.scss │
│ @@ -77,10 +77,11 @@ │
│ } │
│ } │
│ │
│ + // Color comes from the global `.time / .time--*` classes applied by DungeonTim │
│ e - │
│ + // we deliberately don't set `color` here so tier coloring wins. │
│ &__duration { │
│ flex: 0 0 auto; │
│ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; │
│ - color: var(--text-primary); │
│ font-weight: $font-weight-semibold; │
│ font-size: $font-size-base; │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...hallengeRun/ChallengeRun.scss ───┐
│ diff --git a/web/src/components/Home/Challen │
│ geRun/ChallengeRun.scss b/web/src/components │
│ /Home/ChallengeRun/ChallengeRun.scss │
│ index 0ad9de2..43e51a0 100644 │
│ --- a/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.scss │
│ +++ b/web/src/components/Home/ChallengeRun/C │
│ hallengeRun.scss │
│ @@ -77,10 +77,11 @@ │
│ } │
│ } │
│ │
│ + // Color comes from the global `.time / . │
│ time--*` classes applied by DungeonTime - │
│ + // we deliberately don't set `color` here │
│ so tier coloring wins. │
│ &__duration { │
│ flex: 0 0 auto; │
│ font-family: ui-monospace, SFMono-Regul │
│ ar, Menlo, monospace; │
│ - color: var(--text-primary); │
│ font-weight: $font-weight-semibold; │
│ font-size: $font-size-base; │
│ } │
└──────────────────────────────────────────────┘
┌─ web/src/components/Leaderboard/LeaderboardTable/LeaderboardTable.astro ───┐
│ diff --git a/web/src/components/Leaderboard/LeaderboardTable/LeaderboardTable.astr │
│ o b/web/src/components/Leaderboard/LeaderboardTable/LeaderboardTable.astro │
│ index 7285ec0..990b1a8 100644 │
│ --- a/web/src/components/Leaderboard/LeaderboardTable/LeaderboardTable.astro │
│ +++ b/web/src/components/Leaderboard/LeaderboardTable/LeaderboardTable.astro │
│ @@ -6,6 +6,7 @@ import TableCell from "../../Table/TableCell.astro"; │
│ import TeamComposition from "../TeamComposition/TeamComposition.astro"; │
│ import PlayerLink from "../PlayerLink/PlayerLink.astro"; │
│ import { formatDurationMMSS } from "../../../lib/utils"; │
│ +import DungeonTime from "../../DungeonTime/DungeonTime.astro"; │
│ import "./LeaderboardTable.scss"; │
│ │
│ type LeaderboardType = "dungeon" | "player"; │
│ @@ -73,7 +74,6 @@ const formatTime = (ms: number) => { │
│ {runs.length > 0 ? ( │
│ runs.map((run, index) => { │
│ const rank = (currentPage - 1) * pageSize + index + 1; │
│ - const duration = formatTime(run.duration); │
│ const date = formatDate(run.completed_timestamp); │
│ const bracketClass = run.ranking_percentile │
│ ? `bracket-${run.ranking_percentile.toLowerCase()}` │
│ @@ -86,7 +86,13 @@ const formatTime = (ms: number) => { │
│ {rank} │
│ </span> │
│ </TableCell> │
│ - <TableCell label="Time">{duration}</TableCell> │
│ + <TableCell label="Time"> │
│ + <DungeonTime │
│ + dungeonId={Number(dungeon) || null} │
│ + dungeonSlug={dungeon} │
│ + durationMs={run.duration} │
│ + /> │
│ + </TableCell> │
│ <TableCell label="Team"> │
│ <TeamComposition │
│ members={run.members} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...dTable/LeaderboardTable.astro ───┐
│ diff --git a/web/src/components/Leaderboard/ │
│ LeaderboardTable/LeaderboardTable.astro b/we │
│ b/src/components/Leaderboard/LeaderboardTabl │
│ e/LeaderboardTable.astro │
│ index 7285ec0..990b1a8 100644 │
│ --- a/web/src/components/Leaderboard/Leaderb │
│ oardTable/LeaderboardTable.astro │
│ +++ b/web/src/components/Leaderboard/Leaderb │
│ oardTable/LeaderboardTable.astro │
│ @@ -6,6 +6,7 @@ import TableCell from "../.. │
│ /Table/TableCell.astro"; │
│ import TeamComposition from "../TeamComposi │
│ tion/TeamComposition.astro"; │
│ import PlayerLink from "../PlayerLink/Playe │
│ rLink.astro"; │
│ import { formatDurationMMSS } from "../../. │
│ ./lib/utils"; │
│ +import DungeonTime from "../../DungeonTime/ │
│ DungeonTime.astro"; │
│ import "./LeaderboardTable.scss"; │
│ │
│ type LeaderboardType = "dungeon" | "player" │
│ ; │
│ @@ -73,7 +74,6 @@ const formatTime = (ms: nu │
│ mber) => { │
│ {runs.length > 0 ? ( │
│ runs.map((run, index) => { │
│ const rank = (currentPage - 1 │
│ ) * pageSize + index + 1; │
│ - const duration = formatTime(r │
│ un.duration); │
│ const date = formatDate(run.c │
│ ompleted_timestamp); │
│ const bracketClass = run.rank │
│ ing_percentile │
│ ? `bracket-${run.ranking_pe │
│ rcentile.toLowerCase()}` │
│ @@ -86,7 +86,13 @@ const formatTime = (ms: n │
│ umber) => { │
│ {rank} │
│ </span> │
│ </TableCell> │
│ - <TableCell label="Time">{ │
│ duration}</TableCell> │
│ + <TableCell label="Time"> │
│ + <DungeonTime │
│ + dungeonId={Number(dun │
│ geon) || null} │
│ + dungeonSlug={dungeon} │
│ + durationMs={run.durat │
│ ion} │
│ + /> │
│ + </TableCell> │
│ <TableCell label="Team"> │
│ <TeamComposition │
│ members={run.members} │
└──────────────────────────────────────────────┘
┌─ web/src/components/PlayerProfile/PlayerBestRuns/PlayerBestRuns.astro ─────┐
│ diff --git a/web/src/components/PlayerProfile/PlayerBestRuns/PlayerBestRuns.astro │
│ b/web/src/components/PlayerProfile/PlayerBestRuns/PlayerBestRuns.astro │
│ index 92dbb8b..b31e59a 100644 │
│ --- a/web/src/components/PlayerProfile/PlayerBestRuns/PlayerBestRuns.astro │
│ +++ b/web/src/components/PlayerProfile/PlayerBestRuns/PlayerBestRuns.astro │
│ @@ -4,7 +4,8 @@ import Table from "../../Table/Table.astro"; │
│ import TableRow from "../../Table/TableRow.astro"; │
│ import TableCell from "../../Table/TableCell.astro"; │
│ import TeamComposition from "../../Leaderboard/TeamComposition/TeamComposition.as │
│ tro"; │
│ -import { formatDurationMMSS, buildLeaderboardURL } from "../../../lib/utils"; │
│ +import { buildLeaderboardURL } from "../../../lib/utils"; │
│ +import DungeonTime from "../../DungeonTime/DungeonTime.astro"; │
│ import { formatRankingWithBracket } from "../../../lib/ranking-utils"; │
│ import { getDungeonIconUrl } from "../../../lib/dungeonIcons"; │
│ import { getEffectiveRealmSlug } from "../../../lib/realms"; │
│ @@ -80,7 +81,6 @@ const selectedSeasonId = │
│ │
│ const shouldShowSeasonSelector = seasonEntries.length > 1; │
│ │
│ -const formatTime = (ms: number) => formatDurationMMSS(ms); │
│ --- │
│ │
│ { │
│ @@ -145,7 +145,6 @@ const formatTime = (ms: number) => formatDurationMMSS(ms); │
│ </div> │
│ │
│ {entry.runs.map((run) => { │
│ - const duration = formatTime(run.duration); │
│ const dungeonSlug = (run as any).dungeon_slug || ""; │
│ const iconUrl = getDungeonIconUrl( │
│ dungeonSlug, │
│ @@ -228,7 +227,12 @@ const formatTime = (ms: number) => formatDurationMMSS(ms); │
│ <span class="dungeon-name">{run.dungeon_name}</span> │
│ </div> │
│ </TableCell> │
│ - <TableCell label="Time">{duration}</TableCell> │
│ + <TableCell label="Time"> │
│ + <DungeonTime │
│ + dungeonId={run.dungeon_id} │
│ + durationMs={run.duration} │
│ + /> │
│ + </TableCell> │
│ <TableCell label="Team"> │
│ <TeamComposition │
│ members={members} │
│ @@ -352,7 +356,6 @@ const formatTime = (ms: number) => formatDurationMMSS(ms); │
│ </div> │
│ │
│ {entry.runs.map((run) => { │
│ - const duration = formatTime(run.duration); │
│ const dungeonSlug = (run as any).dungeon_slug || ""; │
│ const iconUrl = getDungeonIconUrl( │
│ dungeonSlug, │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...BestRuns/PlayerBestRuns.astro ───┐
│ diff --git a/web/src/components/PlayerProfil │
│ e/PlayerBestRuns/PlayerBestRuns.astro b/web/ │
│ src/components/PlayerProfile/PlayerBestRuns/ │
│ PlayerBestRuns.astro │
│ index 92dbb8b..b31e59a 100644 │
│ --- a/web/src/components/PlayerProfile/Playe │
│ rBestRuns/PlayerBestRuns.astro │
│ +++ b/web/src/components/PlayerProfile/Playe │
│ rBestRuns/PlayerBestRuns.astro │
│ @@ -4,7 +4,8 @@ import Table from "../../Tab │
│ le/Table.astro"; │
│ import TableRow from "../../Table/TableRow. │
│ astro"; │
│ import TableCell from "../../Table/TableCel │
│ l.astro"; │
│ import TeamComposition from "../../Leaderbo │
│ ard/TeamComposition/TeamComposition.astro"; │
│ -import { formatDurationMMSS, buildLeaderboa │
│ rdURL } from "../../../lib/utils"; │
│ +import { buildLeaderboardURL } from "../../ │
│ ../lib/utils"; │
│ +import DungeonTime from "../../DungeonTime/ │
│ DungeonTime.astro"; │
│ import { formatRankingWithBracket } from ". │
│ ./../../lib/ranking-utils"; │
│ import { getDungeonIconUrl } from "../../.. │
│ /lib/dungeonIcons"; │
│ import { getEffectiveRealmSlug } from "../. │
│ ./../lib/realms"; │
│ @@ -80,7 +81,6 @@ const selectedSeasonId = │
│ │
│ const shouldShowSeasonSelector = seasonEntr │
│ ies.length > 1; │
│ │
│ -const formatTime = (ms: number) => formatDu │
│ rationMMSS(ms); │
│ --- │
│ │
│ { │
│ @@ -145,7 +145,6 @@ const formatTime = (ms: │
│ number) => formatDurationMMSS(ms); │
│ </div> │
│ │
│ {entry.runs.map((run) => │
│ { │
│ - const duration = format │
│ Time(run.duration); │
│ const dungeonSlug = (ru │
│ n as any).dungeon_slug || ""; │
│ const iconUrl = getDung │
│ eonIconUrl( │
│ dungeonSlug, │
│ @@ -228,7 +227,12 @@ const formatTime = (ms: │
│ number) => formatDurationMMSS(ms); │
│ <span class="du │
│ ngeon-name">{run.dungeon_name}</span> │
│ </div> │
│ </TableCell> │
│ - <TableCell label="T │
│ ime">{duration}</TableCell> │
│ + <TableCell label="T │
│ ime"> │
│ + <DungeonTime │
│ + dungeonId={run. │
│ dungeon_id} │
│ + durationMs={run │
│ .duration} │
│ + /> │
│ + </TableCell> │
│ <TableCell label="T │
│ eam"> │
│ <TeamComposition │
│ members={member │
│ s} │
│ @@ -352,7 +356,6 @@ const formatTime = (ms: │
│ number) => formatDurationMMSS(ms); │
│ </div> │
│ │
│ {entry.runs.map((run) => { │
│ - const duration = formatTi │
│ me(run.duration); │
│ const dungeonSlug = (run │
│ as any).dungeon_slug || ""; │
│ const iconUrl = getDungeo │
│ nIconUrl( │
│ dungeonSlug, │
└──────────────────────────────────────────────┘
┌─ web/src/lib/dungeon-thresholds.ts ────────────────────────────────────────┐
│ diff --git a/web/src/lib/dungeon-thresholds.ts b/web/src/lib/dungeon-thresholds.ts │
│ new file mode 100644 │
│ index 0000000..ca61ab7 │
│ --- /dev/null │
│ +++ b/web/src/lib/dungeon-thresholds.ts │
│ @@ -0,0 +1,102 @@ │
│ +// Per-dungeon timer thresholds for MoP Challenge Modes, in milliseconds. │
│ +// Gold = the achievement par-time. Platinum / Title (aka "Remarkable") │
│ +// are tighter community/in-game cutoffs. │
│ + │
│ +export type TimeTier = "title" | "platinum" | "gold"; │
│ + │
│ +export interface DungeonThresholds { │
│ + goldMs: number; │
│ + platinumMs: number; │
│ + titleMs: number; │
│ +} │
│ + │
│ +export const DUNGEON_THRESHOLDS: Record<number, DungeonThresholds> = { │
│ + 2: { goldMs: 900000, platinumMs: 615000, titleMs: 510000 }, // Temple of the │
│ Jade Serpent (15:00 / 10:15 / 8:30) │
│ + 56: { goldMs: 720000, platinumMs: 495000, titleMs: 390000 }, // Stormstout Bre │
│ wery (12:00 / 8:15 / 6:30) │
│ + 57: { goldMs: 780000, platinumMs: 480000, titleMs: 330000 }, // Gate of the Se │
│ tting Sun (13:00 / 8:00 / 5:30) │
│ + 58: { goldMs: 1260000, platinumMs: 840000, titleMs: 630000 }, // Shado-Pan Mona │
│ stery (21:00 / 14:00 / 10:30) │
│ + 59: { goldMs: 1050000, platinumMs: 735000, titleMs: 615000 }, // Siege of Niuza │
│ o Temple (17:30 / 12:15 / 10:15) │
│ + 60: { goldMs: 720000, platinumMs: 495000, titleMs: 405000 }, // Mogu'shan Pala │
│ ce (12:00 / 8:15 / 6:45) │
│ + 76: { goldMs: 1140000, platinumMs: 615000, titleMs: 435000 }, // Scholomance │
│ (19:00 / 10:15 / 7:15) │
│ + 77: { goldMs: 780000, platinumMs: 480000, titleMs: 255000 }, // Scarlet Halls │
│ (13:00 / 8:00 / 4:15) │
│ + 78: { goldMs: 780000, platinumMs: 540000, titleMs: 330000 }, // Scarlet Monast │
│ ery (13:00 / 9:00 / 5:30) │
│ +}; │
│ + │
│ +// Slug ? dungeon_id, for callsites that have the URL slug but not the numeric id │
│ . │
│ +// Slugs match those produced by the Go generator. │
│ +export const DUNGEON_SLUG_TO_ID: Record<string, number> = { │
│ + "temple-of-the-jade-serpent": 2, │
│ + "stormstout-brewery": 56, │
│ + "gate-of-the-setting-sun": 57, │
│ + "shado-pan-monastery": 58, │
│ + "siege-of-niuzao-temple": 59, │
│ + "mogu-shan-palace": 60, │
│ + "scholomance": 76, │
│ + "scarlet-halls": 77, │
│ + "scarlet-monastery": 78, │
│ +}; │
│ + │
│ +export function dungeonIdFromSlug(slug: string | undefined | null): number | null │
│ { │
│ + if (!slug) return null; │
│ + return DUNGEON_SLUG_TO_ID[slug] ?? null; │
│ +} │
│ + │
│ +/** │
│ + * In-game cutoffs are lenient: the displayed cutoff is the first second that │
│ + * still earns the medal. e.g. a 5:30.800 run beats a "5:30" title cutoff, │
│ + * because the timer reads "5:30" until it ticks over to 5:31. So the actual │
│ + * pass condition is `duration < threshold + 1000ms`. │
│ + */ │
│ +const LENIENCY_MS = 1000; │
│ + │
│ +/** │
│ + * Returns the highest tier the run beat, or null if it didn't beat gold (or │
│ + * the dungeon is unknown). │
│ + */ │
│ +export function getTimeTier(dungeonId: number, durationMs: number): TimeTier | nu │
│ ll { │
│ + const t = DUNGEON_THRESHOLDS[dungeonId]; │
│ + if (!t) return null; │
│ + if (durationMs < t.titleMs + LENIENCY_MS) return "title"; │
│ + if (durationMs < t.platinumMs + LENIENCY_MS) return "platinum"; │
│ + if (durationMs < t.goldMs + LENIENCY_MS) return "gold"; │
│ + return null; │
│ +} │
│ + │
│ +/** │
│ + * CSS class slug for the tier the run hit, e.g. "time--title". │
│ + * Use this for client-rendered widgets that build DOM in JS. │
│ + * Returns null when the run didn't beat gold or the dungeon is unknown. │
│ + */ │
│ +export function getTimeClass(dungeonId: number, durationMs: number): string | nul │
│ l { │
│ + const tier = getTimeTier(dungeonId, durationMs); │
│ + return tier ? `time--${tier}` : null; │
│ +} │
│ + │
│ +const TIER_LABEL: Record<TimeTier, string> = { │
│ + title: "Title", │
│ + platinum: "Platinum", │
│ + gold: "Gold", │
│ +}; │
│ + │
│ +/** │
│ + * Hover text describing the tier and its cutoff for the given dungeon. │
│ + * E.g. "Title - cutoff 8:30". Returns null when no tier was hit. │
│ + * Says "cutoff" rather than "under" because the in-game check is lenient │
│ + * (a 8:30.999 run still earns title when the cutoff is 8:30). │
│ + */ │
│ +export function getTierTooltip(dungeonId: number, durationMs: number): string | n │
│ ull { │
│ + const tier = getTimeTier(dungeonId, durationMs); │
│ + if (!tier) return null; │
│ + const t = DUNGEON_THRESHOLDS[dungeonId]; │
│ + if (!t) return null; │
│ + const cutoffMs = │
│ + tier === "title" ? t.titleMs : tier === "platinum" ? t.platinumMs : t.goldMs; │
│ + return `${TIER_LABEL[tier]} - cutoff ${formatCutoff(cutoffMs)}`; │
│ +} │
│ + │
│ +function formatCutoff(ms: number): string { │
│ + const totalSec = Math.floor(ms / 1000); │
│ + const m = Math.floor(totalSec / 60); │
│ + const s = totalSec % 60; │
│ + return `${m}:${s.toString().padStart(2, "0")}`; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/lib/dungeon-thresholds.ts ───┐
│ diff --git a/web/src/lib/dungeon-thresholds. │
│ ts b/web/src/lib/dungeon-thresholds.ts │
│ new file mode 100644 │
│ index 0000000..ca61ab7 │
│ --- /dev/null │
│ +++ b/web/src/lib/dungeon-thresholds.ts │
│ @@ -0,0 +1,102 @@ │
│ +// Per-dungeon timer thresholds for MoP Cha │
│ llenge Modes, in milliseconds. │
│ +// Gold = the achievement par-time. Platinu │
│ m / Title (aka "Remarkable") │
│ +// are tighter community/in-game cutoffs. │
│ + │
│ +export type TimeTier = "title" | "platinum" │
│ | "gold"; │
│ + │
│ +export interface DungeonThresholds { │
│ + goldMs: number; │
│ + platinumMs: number; │
│ + titleMs: number; │
│ +} │
│ + │
│ +export const DUNGEON_THRESHOLDS: Record<num │
│ ber, DungeonThresholds> = { │
│ + 2: { goldMs: 900000, platinumMs: 615000 │
│ , titleMs: 510000 }, // Temple of the Jade S │
│ erpent (15:00 / 10:15 / 8:30) │
│ + 56: { goldMs: 720000, platinumMs: 495000 │
│ , titleMs: 390000 }, // Stormstout Brewery │
│ (12:00 / 8:15 / 6:30) │
│ + 57: { goldMs: 780000, platinumMs: 480000 │
│ , titleMs: 330000 }, // Gate of the Setting │
│ Sun (13:00 / 8:00 / 5:30) │
│ + 58: { goldMs: 1260000, platinumMs: 840000 │
│ , titleMs: 630000 }, // Shado-Pan Monastery │
│ (21:00 / 14:00 / 10:30) │
│ + 59: { goldMs: 1050000, platinumMs: 735000 │
│ , titleMs: 615000 }, // Siege of Niuzao Temp │
│ le (17:30 / 12:15 / 10:15) │
│ + 60: { goldMs: 720000, platinumMs: 495000 │
│ , titleMs: 405000 }, // Mogu'shan Palace │
│ (12:00 / 8:15 / 6:45) │
│ + 76: { goldMs: 1140000, platinumMs: 615000 │
│ , titleMs: 435000 }, // Scholomance │
│ (19:00 / 10:15 / 7:15) │
│ + 77: { goldMs: 780000, platinumMs: 480000 │
│ , titleMs: 255000 }, // Scarlet Halls │
│ (13:00 / 8:00 / 4:15) │
│ + 78: { goldMs: 780000, platinumMs: 540000 │
│ , titleMs: 330000 }, // Scarlet Monastery │
│ (13:00 / 9:00 / 5:30) │
│ +}; │
│ + │
│ +// Slug ? dungeon_id, for callsites that ha │
│ ve the URL slug but not the numeric id. │
│ +// Slugs match those produced by the Go gen │
│ erator. │
│ +export const DUNGEON_SLUG_TO_ID: Record<str │
│ ing, number> = { │
│ + "temple-of-the-jade-serpent": 2, │
│ + "stormstout-brewery": 56, │
│ + "gate-of-the-setting-sun": 57, │
│ + "shado-pan-monastery": 58, │
│ + "siege-of-niuzao-temple": 59, │
│ + "mogu-shan-palace": 60, │
│ + "scholomance": 76, │
│ + "scarlet-halls": 77, │
│ + "scarlet-monastery": 78, │
│ +}; │
│ + │
│ +export function dungeonIdFromSlug(slug: str │
│ ing | undefined | null): number | null { │
│ + if (!slug) return null; │
│ + return DUNGEON_SLUG_TO_ID[slug] ?? null; │
│ +} │
│ + │
│ +/** │
│ + * In-game cutoffs are lenient: the display │
│ ed cutoff is the first second that │
│ + * still earns the medal. e.g. a 5:30.800 r │
│ un beats a "5:30" title cutoff, │
│ + * because the timer reads "5:30" until it │
│ ticks over to 5:31. So the actual │
│ + * pass condition is `duration < threshold │
│ + 1000ms`. │
│ + */ │
│ +const LENIENCY_MS = 1000; │
│ + │
│ +/** │
│ + * Returns the highest tier the run beat, o │
│ r null if it didn't beat gold (or │
│ + * the dungeon is unknown). │
│ + */ │
│ +export function getTimeTier(dungeonId: numb │
│ er, durationMs: number): TimeTier | null { │
│ + const t = DUNGEON_THRESHOLDS[dungeonId]; │
│ + if (!t) return null; │
│ + if (durationMs < t.titleMs + LENIENCY_MS) │
│ return "title"; │
│ + if (durationMs < t.platinumMs + LENIENCY_ │
│ MS) return "platinum"; │
│ + if (durationMs < t.goldMs + LENIENCY_MS) │
│ return "gold"; │
│ + return null; │
│ +} │
│ + │
│ +/** │
│ + * CSS class slug for the tier the run hit, │
│ e.g. "time--title". │
│ + * Use this for client-rendered widgets tha │
│ t build DOM in JS. │
│ + * Returns null when the run didn't beat go │
│ ld or the dungeon is unknown. │
│ + */ │
│ +export function getTimeClass(dungeonId: num │
│ ber, durationMs: number): string | null { │
│ + const tier = getTimeTier(dungeonId, durat │
│ ionMs); │
│ + return tier ? `time--${tier}` : null; │
│ +} │
│ + │
│ +const TIER_LABEL: Record<TimeTier, string> │
│ = { │
│ + title: "Title", │
│ + platinum: "Platinum", │
│ + gold: "Gold", │
│ +}; │
│ + │
│ +/** │
│ + * Hover text describing the tier and its c │
│ utoff for the given dungeon. │
│ + * E.g. "Title - cutoff 8:30". Returns null │
│ when no tier was hit. │
│ + * Says "cutoff" rather than "under" becaus │
│ e the in-game check is lenient │
│ + * (a 8:30.999 run still earns title when t │
│ he cutoff is 8:30). │
│ + */ │
│ +export function getTierTooltip(dungeonId: n │
│ umber, durationMs: number): string | null { │
│ + const tier = getTimeTier(dungeonId, durat │
│ ionMs); │
│ + if (!tier) return null; │
│ + const t = DUNGEON_THRESHOLDS[dungeonId]; │
│ + if (!t) return null; │
│ + const cutoffMs = │
│ + tier === "title" ? t.titleMs : tier === │
│ "platinum" ? t.platinumMs : t.goldMs; │
│ + return `${TIER_LABEL[tier]} - cutoff ${fo │
│ rmatCutoff(cutoffMs)}`; │
│ +} │
│ + │
│ +function formatCutoff(ms: number): string { │
│ + const totalSec = Math.floor(ms / 1000); │
│ + const m = Math.floor(totalSec / 60); │
│ + const s = totalSec % 60; │
│ + return `${m}:${s.toString().padStart(2, " │
│ 0")}`; │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/styles/core/_tokens.scss ─────────────────────────────────────────┐
│ diff --git a/web/src/styles/core/_tokens.scss b/web/src/styles/core/_tokens.scss │
│ index e1a5faf..c99fcca 100644 │
│ --- a/web/src/styles/core/_tokens.scss │
│ +++ b/web/src/styles/core/_tokens.scss │
│ @@ -88,4 +88,11 @@ $font-weight-bold: 700; │
│ --bracket-rare: var(--quality-rare); │
│ --bracket-uncommon: var(--quality-uncommon); │
│ --bracket-common: var(--quality-poor); │
│ + │
│ + /* CM run-time tier colors - applied to the displayed time value to indicate │
│ + which threshold a run beat. Palette matches the ChallengeModeTimer addon │
│ + (Title is also called "Remarkable" in-game). */ │
│ + --time-gold: #ffd100; │
│ + --time-platinum: #e6e6ff; │
│ + --time-title: #4dccff; │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/styles/core/_tokens.scss ───┐
│ diff --git a/web/src/styles/core/_tokens.scs │
│ s b/web/src/styles/core/_tokens.scss │
│ index e1a5faf..c99fcca 100644 │
│ --- a/web/src/styles/core/_tokens.scss │
│ +++ b/web/src/styles/core/_tokens.scss │
│ @@ -88,4 +88,11 @@ $font-weight-bold: 700; │
│ --bracket-rare: var(--quality-rare); │
│ --bracket-uncommon: var(--quality-uncommo │
│ n); │
│ --bracket-common: var(--quality-poor); │
│ + │
│ + /* CM run-time tier colors - applied to t │
│ he displayed time value to indicate │
│ + which threshold a run beat. Palette ma │
│ tches the ChallengeModeTimer addon │
│ + (Title is also called "Remarkable" in- │
│ game). */ │
│ + --time-gold: #ffd100; │
│ + --time-platinum: #e6e6ff; │
│ + --time-title: #4dccff; │
│ } │
└──────────────────────────────────────────────┘
┌─ web/src/styles/core/_utilities.scss ──────────────────────────────────────┐
│ diff --git a/web/src/styles/core/_utilities.scss b/web/src/styles/core/_utilities. │
│ scss │
│ index b03fc73..4f6994f 100644 │
│ --- a/web/src/styles/core/_utilities.scss │
│ +++ b/web/src/styles/core/_utilities.scss │
│ @@ -656,3 +656,15 @@ │
│ transform: rotate(360deg); │
│ } │
│ } │
│ + │
│ +// CM run-time tier classes - applied globally so server-rendered (.astro) and │
│ +// client-rendered (TS) widgets can share the same colorization. Tints the time │
│ +// text only; layout is unchanged. Pair with `.time` (no-op marker) so consumers │
│ +// can grep "class=\"time" to find all colorized timers. │
│ +.time { │
│ + font-variant-numeric: tabular-nums; │
│ +} │
│ + │
│ +.time--gold { color: var(--time-gold); } │
│ +.time--platinum { color: var(--time-platinum); } │
│ +.time--title { color: var(--time-title); } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/styles/core/_utilities.scss ───┐
│ diff --git a/web/src/styles/core/_utilities. │
│ scss b/web/src/styles/core/_utilities.scss │
│ index b03fc73..4f6994f 100644 │
│ --- a/web/src/styles/core/_utilities.scss │
│ +++ b/web/src/styles/core/_utilities.scss │
│ @@ -656,3 +656,15 @@ │
│ transform: rotate(360deg); │
│ } │
│ } │
│ + │
│ +// CM run-time tier classes - applied globa │
│ lly so server-rendered (.astro) and │
│ +// client-rendered (TS) widgets can share t │
│ he same colorization. Tints the time │
│ +// text only; layout is unchanged. Pair wit │
│ h `.time` (no-op marker) so consumers │
│ +// can grep "class=\"time" to find all colo │
│ rized timers. │
│ +.time { │
│ + font-variant-numeric: tabular-nums; │
│ +} │
│ + │
│ +.time--gold { color: var(--time-gold); │
│ } │
│ +.time--platinum { color: var(--time-platinu │
│ m); } │
│ +.time--title { color: var(--time-title); │
│ } │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET