OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
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
 

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>

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>
 

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"

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"
 

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;
   }

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;
   }
 

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}

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}
 

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,

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,
 

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")}`;
+}

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")}`;
+}
 

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;
 }

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;
 }
 

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); }

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