Skip to content

Commit 9a985d3

Browse files
authored
Merge pull request #186 from phattbeats/pha1274-wrapped-polish
PHA-1274: bittersweet default music, fix coin sheen artifact, Major-complete homepage
2 parents e45a5e1 + beddbd0 commit 9a985d3

5 files changed

Lines changed: 184 additions & 21 deletions

File tree

scripts/verify-stage-wrapped.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ check("every track has a src + credit + mood", WRAPPED_TRACKS.every((t) => !!t.s
126126
check("every track is attributed (CC-BY / Kevin MacLeod)", WRAPPED_TRACKS.every((t) => /CC-BY/i.test(t.credit) && /MacLeod/i.test(t.credit)));
127127
check("track ids are unique", new Set(WRAPPED_TRACKS.map((t) => t.id)).size === WRAPPED_TRACKS.length);
128128
check("offers a bittersweet/somber ending mood", WRAPPED_TRACKS.some((t) => /bittersweet|somber/i.test(t.mood)));
129-
check("default track leads (index 0 = the epic theme)", WRAPPED_TRACKS[0].id === "descent");
129+
check("default track leads (index 0 = the bittersweet theme)", WRAPPED_TRACKS[0].id === "despair-triumph");
130+
check("the leading track's mood is bittersweet", /bittersweet/i.test(WRAPPED_TRACKS[0].mood));
130131

131132
console.log(`\nstage-wrapped: ${pass} passed, ${fail} failed`);
132133
if (fail > 0) process.exit(1);

src/app/(app)/page.tsx

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getWireItems } from "@/lib/news";
1313
import { refreshOutcomesOnRead } from "@/lib/outcomes";
1414
import { WatchNow } from "@/components/watch/WatchNow";
1515
import { currentEventId } from "@/lib/events-core";
16+
import { majorChampion, majorWrappedSectionId, type MajorChampion } from "@/lib/stage-wrapped-launch";
1617

1718
export const dynamic = "force-dynamic";
1819

@@ -30,7 +31,7 @@ export default async function DashboardPage() {
3031
// and no added render latency. Mirrors the news wire's refreshWireOnRead.
3132
await refreshOutcomesOnRead(EVENT_ID);
3233

33-
const [resolvedRows, outcomeRows, allPicks, allPlayers, wireItemsAll] = await Promise.all([
34+
const [resolvedRows, outcomeRows, allPicks, allPlayers, wireItemsAll, champion] = await Promise.all([
3435
prisma.stageOutcome.findMany({
3536
where: { eventId: EVENT_ID },
3637
select: { sectionId: true, groupId: true, slotIndex: true },
@@ -44,6 +45,9 @@ export default async function DashboardPage() {
4445
select: { id: true, displayName: true, avatarUrl: true },
4546
}),
4647
getWireItems(3),
48+
// The crowned champion, or null until the Grand Final resolves. Non-null
49+
// flips the whole dashboard into its "Major complete" send-off (PHA-1274).
50+
majorChampion(EVENT_ID),
4751
]);
4852

4953
const wireItems = wireItemsAll;
@@ -144,7 +148,12 @@ export default async function DashboardPage() {
144148
}
145149

146150
const eventStarted = resolvedRows.length > 0;
147-
const eventLabel = eventStarted ? "Live now" : "Pre-event";
151+
// Once the Grand Final crowns a champion the Major is DONE — the eyebrow stops
152+
// saying "Live now" (which it would otherwise say forever, since outcomes stay
153+
// resolved) and the hero becomes a send-off (PHA-1274: "the homepage needs
154+
// updated").
155+
const eventLabel = champion ? "Major complete" : eventStarted ? "Live now" : "Pre-event";
156+
const wrappedHref = `/reveal/${majorWrappedSectionId()}?wrapped=1`;
148157

149158
return (
150159
<>
@@ -155,7 +164,10 @@ export default async function DashboardPage() {
155164
</span>
156165
</div>
157166

158-
{/* Stage briefing */}
167+
{/* Stage briefing — or, once a champion is crowned, the Major send-off. */}
168+
{champion ? (
169+
<ConcludedHero champion={champion} wrappedHref={wrappedHref} />
170+
) : (
159171
<section className="brk" style={{
160172
position: "relative",
161173
background: "var(--surf-1)",
@@ -248,6 +260,7 @@ export default async function DashboardPage() {
248260
</div>
249261
</div>
250262
</section>
263+
)}
251264

252265
{/* Stats + Leaderboard cols */}
253266
<div className="dash-cols">
@@ -284,12 +297,16 @@ export default async function DashboardPage() {
284297
<div className="lbl">POINTS</div>
285298
<div className="val foil">{selfRow?.score ?? 0}</div>
286299
<div className="sub">
287-
of {maxPoints} · {activeLabel}{" "}
288-
{active.pick.pickable
289-
? "open"
290-
: active.pick.reason === "locked-time-passed"
291-
? "live"
292-
: "pending"}
300+
of {maxPoints} ·{" "}
301+
{champion
302+
? "final"
303+
: `${activeLabel} ${
304+
active.pick.pickable
305+
? "open"
306+
: active.pick.reason === "locked-time-passed"
307+
? "live"
308+
: "pending"
309+
}`}
293310
</div>
294311
</div>
295312
</div>
@@ -375,6 +392,89 @@ export default async function DashboardPage() {
375392
);
376393
}
377394

395+
/**
396+
* The dashboard hero AFTER the Major is decided (PHA-1274) — replaces the stage
397+
* briefing the moment the Grand Final crowns a champion. Same broadcast shell as
398+
* the briefing (keyline corners + faint HeatMark), but it names the champion and
399+
* points everyone at the Wrapped recap and the final board instead of a now-dead
400+
* "picks are locked, watch the live bracket" call.
401+
*/
402+
function ConcludedHero({
403+
champion,
404+
wrappedHref,
405+
}: {
406+
champion: MajorChampion;
407+
wrappedHref: string;
408+
}) {
409+
return (
410+
<section className="brk" style={{
411+
position: "relative",
412+
background: "var(--surf-1)",
413+
border: "1px solid var(--hair-2)",
414+
padding: "26px 28px 28px",
415+
overflow: "hidden",
416+
}}>
417+
<span className="br-tr" />
418+
<span className="br-bl" />
419+
<div style={{
420+
position: "absolute",
421+
right: -20,
422+
top: "50%",
423+
transform: "translateY(-50%)",
424+
width: 240,
425+
height: 240,
426+
opacity: 0.05,
427+
pointerEvents: "none",
428+
}}>
429+
<HeatMark size={240} />
430+
</div>
431+
<div style={{ position: "relative", zIndex: 1 }}>
432+
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>
433+
<span className="eyebrow-mono">CHAMPIONS</span>
434+
<span className="live-tag" style={{ color: "var(--ink-mid)", borderColor: "var(--hair-2)", background: "var(--surf-2)" }}>
435+
That&apos;s a wrap
436+
</span>
437+
</div>
438+
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 14 }}>
439+
{champion.logoSrc && (
440+
// eslint-disable-next-line @next/next/no-img-element
441+
<img
442+
src={champion.logoSrc}
443+
alt=""
444+
width={64}
445+
height={64}
446+
style={{ objectFit: "contain", filter: "drop-shadow(0 4px 10px rgba(0,0,0,0.5))", flexShrink: 0 }}
447+
/>
448+
)}
449+
<h1 className="font-display" style={{
450+
fontWeight: 800,
451+
fontSize: "clamp(34px, 5vw, 48px)",
452+
textTransform: "uppercase",
453+
lineHeight: 0.92,
454+
margin: 0,
455+
}}>
456+
{champion.name}
457+
</h1>
458+
</div>
459+
<p style={{ fontSize: 14, color: "var(--ink-mid)", margin: 0, maxWidth: 460, textWrap: "pretty" }}>
460+
{champion.name} are your IEM Cologne Major 2026 champions. Thirty-two
461+
walked in; one walks out with the trophy. Thanks for calling it with us
462+
all Major long — the recap below has the whole run.
463+
</p>
464+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center", marginTop: 18 }}>
465+
<Link href={wrappedHref} className="btn-heat" prefetch={false}>
466+
Watch the Wrapped recap
467+
<svg viewBox="0 0 24 24" strokeLinecap="round" strokeLinejoin="round">
468+
<polyline points="9 18 15 12 9 6" />
469+
</svg>
470+
</Link>
471+
<Link href="/leaderboard" className="btn-ghost" prefetch={false}>Final Ranks</Link>
472+
</div>
473+
</div>
474+
</section>
475+
);
476+
}
477+
378478
function StageStatusTag({
379479
pickability,
380480
}: {

src/app/globals.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,11 @@ main.shell.with-nav-action { padding-bottom: calc(152px + env(safe-area-inset-bo
958958
sparkle, clipped to the coin disc. Transform/opacity only (no blur/backdrop-
959959
filter — PHA-1269), staggered per coin via --shimmer-delay, off when the user
960960
prefers reduced motion. */
961-
.coin-shine { position: relative; display: block; width: 100%; height: 100%; border-radius: 50%; }
961+
/* overflow:hidden + the round radius CLIP the sweep to the coin. Without it the
962+
sheen band parks at translateX(120%) for most of the cycle and sits as a stray
963+
glossy bar just off the coin's right edge (PHA-1274: "what is this weird
964+
artifact?"). The clip keeps the glide on the disc and nowhere else. */
965+
.coin-shine { position: relative; display: block; width: 100%; height: 100%; border-radius: 50%; overflow: hidden; }
962966
.coin-shine img { width: 100%; height: 100%; object-fit: contain; }
963967
.coin-shine::before {
964968
content: ""; position: absolute; inset: 0; pointer-events: none;

src/lib/stage-wrapped-core.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,25 +170,27 @@ export interface WrappedTrack {
170170
/**
171171
* The deck soundtrack options (PHA-1274, Brandon: "a few more epic musics with
172172
* more bittersweet or somber endings"). Index 0 is the default that plays on the
173-
* first sound-on; the track control cycles through the rest. Order puts the
174-
* triumphant theme first, then the bittersweet/somber pieces that suit a Major
175-
* sending its champion home and everyone else into the off-season.
173+
* first sound-on; the track control cycles through the rest. The bittersweet
174+
* theme leads (Brandon: "make the default music the bittersweet epic one … the
175+
* music needs to be bittersweet") — it's the right send-off for a Major crowning
176+
* its champion and sending everyone else into the off-season; the purely
177+
* triumphant and somber pieces follow on the cycle.
176178
*/
177179
export const WRAPPED_TRACKS: readonly WrappedTrack[] = [
178-
{
179-
id: "descent",
180-
title: "The Descent",
181-
src: "/audio/wrapped-theme.mp3",
182-
credit: "“The Descent” — Kevin MacLeod (incompetech.com) · CC-BY 3.0",
183-
mood: "Epic",
184-
},
185180
{
186181
id: "despair-triumph",
187182
title: "Despair & Triumph",
188183
src: "/audio/wrapped-despair-triumph.mp3",
189184
credit: "“Despair and Triumph” — Kevin MacLeod (incompetech.com) · CC-BY 3.0",
190185
mood: "Bittersweet",
191186
},
187+
{
188+
id: "descent",
189+
title: "The Descent",
190+
src: "/audio/wrapped-theme.mp3",
191+
credit: "“The Descent” — Kevin MacLeod (incompetech.com) · CC-BY 3.0",
192+
mood: "Epic",
193+
},
192194
{
193195
id: "long-note-three",
194196
title: "Long Note Three",

src/lib/stage-wrapped-launch.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,59 @@ export async function prepareMajorWrappedAutoDeck(
265265
slides,
266266
};
267267
}
268+
269+
/** The Grand Final section id for this format (the recap deep link target). */
270+
export function majorWrappedSectionId(): number {
271+
return PLAYOFF_ROUNDS.find((r) => r.key === "GF")?.sectionId ?? 110;
272+
}
273+
274+
export interface MajorChampion {
275+
pickId: number;
276+
name: string;
277+
/** First logo tier for the crest (or null when none resolves). */
278+
logoSrc: string | null;
279+
}
280+
281+
/**
282+
* WHO won the Major — the crowned champion, or null until the Grand Final
283+
* resolves. Reuses the same bracket pipeline the Wrapped deck does
284+
* (`buildPlayoffBracket` + `derivePlayoffStorylines`) so the home send-off names
285+
* the exact team the recap crowns. Cheap: one query scoped to the playoff
286+
* sections. Drives the dashboard's "Major complete" hero (PHA-1274).
287+
*/
288+
export async function majorChampion(eventId: number): Promise<MajorChampion | null> {
289+
const layout = getCommittedLayout();
290+
const playoffSectionIds = PLAYOFF_ROUNDS.map((r) => r.sectionId);
291+
const playoffSections = layout.sections.filter((s) => playoffSectionIds.includes(s.sectionid));
292+
if (playoffSections.length === 0) return null;
293+
294+
const outcomes = await prisma.stageOutcome.findMany({
295+
where: { eventId, sectionId: { in: playoffSectionIds } },
296+
});
297+
if (outcomes.length === 0) return null;
298+
299+
const winnerByGroup = new Map<number, number>();
300+
for (const o of outcomes) if (o.slotIndex === 0) winnerByGroup.set(o.groupId, o.winnerPickId);
301+
302+
const bracket = buildPlayoffBracket({ sections: playoffSections, winnerByGroup });
303+
if (!isPlayoffWrapped(bracket)) return null; // no champion crowned yet
304+
305+
const teamMap = buildTeamMap(layout);
306+
const facts = derivePlayoffStorylines(bracket, {
307+
nameOf: (pid: number) => teamMap.get(pid)?.name ?? null,
308+
});
309+
if (facts.championPickId == null) return null;
310+
311+
const team = teamMap.get(facts.championPickId);
312+
let logoSrc: string | null = null;
313+
if (team) {
314+
for (const tier of resolveLogoTiers(team)) {
315+
if (tier.kind === "image") { logoSrc = tier.src; break; }
316+
}
317+
}
318+
return {
319+
pickId: facts.championPickId,
320+
name: facts.championName ?? team?.name ?? "the champions",
321+
logoSrc,
322+
};
323+
}

0 commit comments

Comments
 (0)