From bd5c18d13eb4e3c5b0d7796ba8aab9821b9ae20e Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 14:05:30 -0400 Subject: [PATCH 01/13] feat(multi): render multi-PGCR with standard PGCR UI Merge multiple RaidHub instances into one synthetic InstanceExtended so multi sessions use PGCRHeader, PGCRPlayers, and the player details panel like single-instance PGCR. Add mergeRaidHubInstances, session timeline for 2+ instances, and hide reporting when the view is merged. Remove the legacy multi combinator/player/details components. Made-with: Cursor --- src/app/multi/[multiId]/page.tsx | 5 +- src/components/multi/multi-combinator.tsx | 195 ------------------ src/components/multi/multi-loader.tsx | 33 ++- src/components/multi/multi-player.tsx | 174 ---------------- src/components/multi/multi-weapons-list.tsx | 41 ---- src/components/pgcr/pgcr-menu.tsx | 4 +- .../pgcr-multi-timeline.tsx} | 68 +++--- src/components/pgcr/pgcr-view.tsx | 25 ++- src/hooks/pgcr/ClientStateManager.tsx | 7 +- src/lib/multi/merge-instances.ts | 182 ++++++++++++++++ src/lib/multi/multi-types.ts | 53 +---- 11 files changed, 297 insertions(+), 490 deletions(-) delete mode 100644 src/components/multi/multi-combinator.tsx delete mode 100644 src/components/multi/multi-player.tsx delete mode 100644 src/components/multi/multi-weapons-list.tsx rename src/components/{multi/multi-details.tsx => pgcr/pgcr-multi-timeline.tsx} (55%) create mode 100644 src/lib/multi/merge-instances.ts diff --git a/src/app/multi/[multiId]/page.tsx b/src/app/multi/[multiId]/page.tsx index ee222ab2..973a116f 100644 --- a/src/app/multi/[multiId]/page.tsx +++ b/src/app/multi/[multiId]/page.tsx @@ -29,7 +29,10 @@ export default async function Page({ params }: PageProps) {

{multi.name}

- instance.instanceId)} /> + instance.instanceId)} + /> ) diff --git a/src/components/multi/multi-combinator.tsx b/src/components/multi/multi-combinator.tsx deleted file mode 100644 index e5ff35d3..00000000 --- a/src/components/multi/multi-combinator.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Collection } from "@discordjs/collection" -import { useMemo } from "react" -import type { - CombinedCharacterStats, - CombinedData, - CombinedPlayerStats, - CombinedWeaponData -} from "~/lib/multi/multi-types" -import { generateSortScore } from "~/lib/pgcr/riis" -import { type RaidHubInstanceExtended } from "~/services/raidhub/types" -import { Card, CardContent, CardHeader } from "~/shad/card" -import { MultiDetails } from "./multi-details" -import { MultiPlayer } from "./multi-player" -import { MultiWeaponsList } from "./multi-weapons-list" - -export const MultiInstanceCombinator = (props: { data: RaidHubInstanceExtended[] }) => { - const combined = useMemo(() => { - const reduced = props.data.reduce>( - (acc, instance) => { - acc.instances.push({ - activityName: instance.metadata.activityName, - versionName: instance.metadata.versionName, - instanceId: instance.instanceId, - duration: instance.duration, - start: new Date(instance.dateStarted), - end: new Date(instance.dateCompleted), - completed: instance.completed - }) - - acc.duration += instance.duration - if (instance.completed) { - acc.completions += 1 - } - - instance.players.forEach(player => { - const existingPlayer = acc.players.get(player.playerInfo.membershipId) - - const playerWeapons = - existingPlayer?.weapons ?? new Collection() - - const characters = player.characters.reduce((charAcc, char) => { - const existingChar = charAcc.get(char.characterId) - - char.weapons.forEach(weap => { - playerWeapons.set(weap.weaponHash, { - hash: weap.weaponHash, - kills: - weap.kills + (playerWeapons.get(weap.weaponHash)?.kills ?? 0), - precisionKills: - weap.precisionKills + - (playerWeapons.get(weap.weaponHash)?.precisionKills ?? 0) - }) - acc.weapons.set(weap.weaponHash, { - hash: weap.weaponHash, - kills: weap.kills + (acc.weapons.get(weap.weaponHash)?.kills ?? 0), - precisionKills: - weap.precisionKills + - (acc.weapons.get(weap.weaponHash)?.precisionKills ?? 0) - }) - }) - - if (existingChar) { - existingChar.timePlayedSeconds += char.timePlayedSeconds - existingChar.score += char.score - existingChar.kills += char.kills - existingChar.deaths += char.deaths - existingChar.assists += char.assists - existingChar.precisionKills += char.precisionKills - existingChar.superKills += char.superKills - existingChar.grenadeKills += char.grenadeKills - existingChar.meleeKills += char.meleeKills - } else { - charAcc.set(char.characterId, { - classHash: char.classHash, - startSeconds: 0, - completed: char.completed, - timePlayedSeconds: char.timePlayedSeconds, - score: char.score, - kills: char.kills, - deaths: char.deaths, - assists: char.assists, - precisionKills: char.precisionKills, - superKills: char.superKills, - grenadeKills: char.grenadeKills, - meleeKills: char.meleeKills - }) - } - return charAcc - }, existingPlayer?.characters ?? new Collection()) - - const { kills, deaths, assists } = player.characters.reduce( - (stats, char) => { - stats.kills += char.kills - stats.deaths += char.deaths - stats.assists += char.assists - return stats - }, - { kills: 0, deaths: 0, assists: 0 } - ) - - if (existingPlayer) { - acc.players.set(player.playerInfo.membershipId, { - kills: existingPlayer.kills + kills, - deaths: existingPlayer.deaths + deaths, - assists: existingPlayer.assists + assists, - playerInfo: player.playerInfo, - characters, - completions: +player.completed + existingPlayer.completions, - timePlayedSeconds: - player.timePlayedSeconds + existingPlayer.timePlayedSeconds, - riis: 0, - weapons: playerWeapons - }) - } else { - return acc.players.set(player.playerInfo.membershipId, { - kills, - deaths, - assists, - playerInfo: player.playerInfo, - characters, - completions: +player.completed, - timePlayedSeconds: player.timePlayedSeconds, - riis: 0, - weapons: playerWeapons - }) - } - }) - - return acc - }, - { - instances: [], - duration: 0, - completions: 0, - players: new Collection(), - weapons: new Collection() - } - ) - - reduced.players.forEach(player => { - // Calculate the RIIS score for each player - player.riis = generateSortScore( - { - characters: Object.freeze(Array.from(player.characters.values())), - timePlayedSeconds: player.timePlayedSeconds - }, - { - capTPS: false - } - ) - }) - - return { - ...reduced, - players: reduced.players.sort((a, b) => - a.completions === b.completions ? b.riis - a.riis : b.completions - a.completions - ), - dates: reduced.instances.sort((a, b) => a.start.getTime() - b.start.getTime()), - overallStart: reduced.instances.reduce( - (min, d) => (d.start < min ? d.start : min), - new Date("9999-12-31") - ), - overallEnd: reduced.instances.reduce( - (max, d) => (d.end > max ? d.end : max), - new Date(0) - ) - } - }, [props.data]) - - return ( - - - - - -
-
KILLS
-
DEATHS
-
ASSISTS
-
TIME PLAYED
-
RIIS
-
-
- {combined.players.map((p, membershipId) => ( - - ))} -
- -
- -
-
-
- ) -} diff --git a/src/components/multi/multi-loader.tsx b/src/components/multi/multi-loader.tsx index fc10d795..2ce8c847 100644 --- a/src/components/multi/multi-loader.tsx +++ b/src/components/multi/multi-loader.tsx @@ -1,9 +1,16 @@ "use client" +import PGCR from "~/components/pgcr/pgcr-view" +import { mergeRaidHubInstances } from "~/lib/multi/merge-instances" import { useRaidHubInstanceList } from "~/services/raidhub/useRaidHubInstance" -import { MultiInstanceCombinator } from "./multi-combinator" -export const MultiLoader = ({ instances }: { instances: string[] }) => { +export const MultiLoader = ({ + multiId, + instances +}: { + multiId: string + instances: string[] +}) => { const queries = useRaidHubInstanceList(instances) const isLoading = queries.some(q => q.isLoading) @@ -12,12 +19,10 @@ export const MultiLoader = ({ instances }: { instances: string[] }) => { const progress = 1 - queries.filter(q => q.isLoading).length / queries.length return (
- {/* Progress bar shows how many instances have finished loading for the user */} Loading data: {Math.round(progress * 100)}%
- {/* The filled part represents the percentage of loaded instances */}
{
) } - return q.data!).filter(Boolean)} /> + + const loaded = queries.map(q => q.data).filter((d): d is NonNullable => !!d) + if (loaded.length === 0) { + return

Could not load instance data.

+ } + + const mergedMulti = loaded.length > 1 + const { merged, timeline } = mergedMulti + ? mergeRaidHubInstances(loaded, multiId) + : { merged: loaded[0], timeline: undefined } + + return ( + + ) } diff --git a/src/components/multi/multi-player.tsx b/src/components/multi/multi-player.tsx deleted file mode 100644 index a8940948..00000000 --- a/src/components/multi/multi-player.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { ChevronDown } from "lucide-react" -import { useState } from "react" -import { useGetCharacterClass } from "~/hooks/pgcr/useCharacterClass" -import { type CombinedPlayerStats } from "~/lib/multi/multi-types" -import { cn } from "~/lib/tw" -import { Avatar, AvatarFallback, AvatarImage } from "~/shad/avatar" -import { Badge } from "~/shad/badge" -import { Button } from "~/shad/button" -import { Card, CardContent, CardHeader, CardTitle } from "~/shad/card" -import { Tooltip, TooltipContent, TooltipTrigger } from "~/shad/tooltip" -import { bungieIconUrl, getBungieDisplayName } from "~/util/destiny" -import { secondsToHMS } from "~/util/presentation/formatting" -import { MultiWeaponsList } from "./multi-weapons-list" - -export const MultiPlayer = ({ player }: { player: CombinedPlayerStats }) => { - const [isExpanded, setIsExpanded] = useState(false) - const getCharacterIcon = useGetCharacterClass() - - return ( -
-
-
-
-
- {player.characters.map((character, characterId) => { - const [CharacterIcon, characterName] = getCharacterIcon( - character.classHash - ) - return ( - - - - - - {characterName} - - - ) - })} -
- - - - {player.playerInfo.displayName!.slice(0, 2).toUpperCase()} - - -
- {getBungieDisplayName(player.playerInfo, { excludeCode: true })} -
-
- -
-
- {player.completions > 0 ? ( - 0 && "bg-green-600", - player.completions > 1 && "bg-amber-400" - )}> - {player.completions > 1 - ? `Completed ${player.completions}x` - : "Completed"} - - ) : ( - Incomplete - )} -
-
-
{player.kills}
-
{player.deaths}
-
{player.assists}
-
- {secondsToHMS(player.timePlayedSeconds, false)} -
-
{player.riis.toFixed(3)}
-
- -
- -
-
- - {isExpanded && } -
- ) -} - -const MultiPlayerExpanded = ({ player }: { player: CombinedPlayerStats }) => { - const getCharacterIcon = useGetCharacterClass() - return ( -
- - - Stats - - -
-
- Kills - {player.kills} -
-
- Deaths - {player.deaths} -
-
- Assists - {player.assists} -
-
- Time Played - - {secondsToHMS(player.timePlayedSeconds, false)} - -
-
- RIIS - {player.riis.toFixed(3)} -
-
-
-
- - - Characters - - - {player.characters.map((ch, cid) => { - const [, characterClass] = getCharacterIcon(ch.classHash) - return ( -
-
-
-
{characterClass}
-
- K {ch.kills} • D {ch.deaths} • A {ch.assists} • Time{" "} - {secondsToHMS(ch.timePlayedSeconds, false)} -
-
-
- Melee {ch.meleeKills} • - Grenade {ch.grenadeKills} • - Super {ch.superKills} -
-
-
- ) - })} -
-
- - - - Weapons - - - - - -
- ) -} diff --git a/src/components/multi/multi-weapons-list.tsx b/src/components/multi/multi-weapons-list.tsx deleted file mode 100644 index e62086ba..00000000 --- a/src/components/multi/multi-weapons-list.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Image from "next/image" -import { useItemDefinition } from "~/hooks/dexie" -import { type CombinedWeaponData } from "~/lib/multi/multi-types" -import { bungieIconUrl } from "~/util/destiny" - -export const MultiWeaponsList = ({ weapons }: { weapons: CombinedWeaponData[] }) => { - return ( -
- {weapons - .sort((a, b) => b.kills - a.kills) - .map(weapon => ( - - ))} -
- ) -} - -const WeaponItem = ({ weapon }: { weapon: CombinedWeaponData }) => { - const definition = useItemDefinition(weapon.hash) - const icon = bungieIconUrl(definition?.displayProperties.icon) - return ( -
- {definition?.displayProperties.name -
-
- {definition?.displayProperties.name ?? "Unknown Weapon"} -
-
- Kills: {weapon.kills} ({weapon.precisionKills}) -
-
-
- ) -} diff --git a/src/components/pgcr/pgcr-menu.tsx b/src/components/pgcr/pgcr-menu.tsx index af4bd980..3416fa42 100644 --- a/src/components/pgcr/pgcr-menu.tsx +++ b/src/components/pgcr/pgcr-menu.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, MoreVertical, Share2 } from "lucide-react" import { useState } from "react" import { toast } from "sonner" import { useSession } from "~/hooks/app/useSession" +import { usePGCRContext } from "~/hooks/pgcr/ClientStateManager" import { Button } from "~/shad/button" import { DropdownMenu, @@ -16,6 +17,7 @@ import { ReportModal } from "./pgcr-report-modal" export const PGCRMenu = () => { const session = useSession() + const { allowsReporting } = usePGCRContext() const [isMenuOpen, setIsMenuOpen] = useState(false) const [isReportModalOpen, setIsReportModalOpen] = useState(false) @@ -44,7 +46,7 @@ export const PGCRMenu = () => { Share - {session.status === "authenticated" && ( + {session.status === "authenticated" && allowsReporting && ( <> diff --git a/src/components/multi/multi-details.tsx b/src/components/pgcr/pgcr-multi-timeline.tsx similarity index 55% rename from src/components/multi/multi-details.tsx rename to src/components/pgcr/pgcr-multi-timeline.tsx index fb493e76..4f18f387 100644 --- a/src/components/multi/multi-details.tsx +++ b/src/components/pgcr/pgcr-multi-timeline.tsx @@ -1,46 +1,68 @@ +"use client" + import Link from "next/link" -import { useCallback } from "react" -import { type CombinedData } from "~/lib/multi/multi-types" +import { useCallback, useMemo } from "react" +import { type MultiInstanceTimelineSegment } from "~/lib/multi/multi-types" import { cn } from "~/lib/tw" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/shad/card" import { Tooltip, TooltipContent, TooltipTrigger } from "~/shad/tooltip" import { secondsToHMS } from "~/util/presentation/formatting" -export const MultiDetails = ({ data }: { data: CombinedData }) => { +export const PGCRMultiTimeline = ({ segments }: { segments: MultiInstanceTimelineSegment[] }) => { + const { overallStart, overallEnd } = useMemo(() => { + if (!segments.length) { + return { overallStart: new Date(0), overallEnd: new Date(0) } + } + return { + overallStart: segments.reduce( + (min, d) => (d.start < min ? d.start : min), + segments[0].start + ), + overallEnd: segments.reduce( + (max, d) => (d.end > max ? d.end : max), + segments[0].end + ) + } + }, [segments]) + const getRatio = useCallback( (date: Date) => { - const total = data.overallEnd.getTime() - data.overallStart.getTime() - return (date.getTime() - data.overallStart.getTime()) / total + const total = overallEnd.getTime() - overallStart.getTime() + if (total <= 0) return 0 + return (date.getTime() - overallStart.getTime()) / total }, - [data.overallEnd, data.overallStart] + [overallEnd, overallStart] + ) + + const totalDurationSeconds = useMemo( + () => segments.reduce((s, d) => s + d.duration, 0), + [segments] ) + const totalElapsedSeconds = (overallEnd.getTime() - overallStart.getTime()) / 1000 + + if (!segments.length) { + return null + } return ( - - - Timeline + + + Session timeline - Total Playtime: {secondsToHMS(data.duration, false)} -
- Total Elapsed Time:{" "} - {secondsToHMS( - (data.overallEnd.getTime() - data.overallStart.getTime()) / 1000, - false - )} + Total in-activity time: {secondsToHMS(totalDurationSeconds, false)}
- {data.completions > 1 && `Completions: ${data.completions}`} + Total elapsed: {secondsToHMS(totalElapsedSeconds, false)}
- +
- {/* Base filled segments */} - {data.instances.map((d, i) => { + {segments.map((d, i) => { if (!d.start) return null const startPct = getRatio(d.start) * 100 const endPct = getRatio(d.end) * 100 return ( - + { })}
- {data.overallStart.toLocaleString()} - {data.overallEnd.toLocaleString()} + {overallStart.toLocaleString()} + {overallEnd.toLocaleString()}
diff --git a/src/components/pgcr/pgcr-view.tsx b/src/components/pgcr/pgcr-view.tsx index 0285b4bf..e1c98bfd 100644 --- a/src/components/pgcr/pgcr-view.tsx +++ b/src/components/pgcr/pgcr-view.tsx @@ -1,24 +1,35 @@ import { Collection } from "@discordjs/collection" import { ClientStateManager } from "~/hooks/pgcr/ClientStateManager" +import { type MultiInstanceTimelineSegment } from "~/lib/multi/multi-types" import { generateSortScore } from "~/lib/pgcr/riis" import { type RaidHubInstanceExtended } from "~/services/raidhub/types" import { Card } from "~/shad/card" import { Separator } from "~/shad/separator" import { PGCRHeader } from "./pgcr-header" +import { PGCRMultiTimeline } from "./pgcr-multi-timeline" import { PGCRPlayers } from "./pgcr-players" import { PlayerDetailsPanelWrapper } from "./player-details-panel" interface PGCRProps { data: RaidHubInstanceExtended + /** Merged multi-instance sessions use uncapped time in RIIS (matches legacy multi combinator). */ + sortScoreOptions?: { capTPS: boolean } + multiTimeline?: MultiInstanceTimelineSegment[] + allowsReporting?: boolean } -export default function PGCR({ data }: PGCRProps) { +export default function PGCR({ + data, + sortScoreOptions = { capTPS: true }, + multiTimeline, + allowsReporting = true +}: PGCRProps) { const sortScores = new Collection( data.players.map(p => [ p.playerInfo.membershipId, { completed: p.completed, - score: generateSortScore(p) + score: generateSortScore(p, sortScoreOptions) } ]) ).toSorted((a, b) => { @@ -67,13 +78,21 @@ export default function PGCR({ data }: PGCRProps) { data={data} mvp={mvp} scores={Array.from(sortScores.entries())} - playerStatsMerged={Array.from(playerMergedStats.entries())}> + playerStatsMerged={Array.from(playerMergedStats.entries())} + allowsReporting={allowsReporting}>
+ {multiTimeline && multiTimeline.length > 1 && ( + <> + + + + )} + + /** When false (e.g. merged multi-instance PGCR), hide reporting — no single RaidHub instance applies. */ + allowsReporting?: boolean children: ReactNode } @@ -57,6 +59,7 @@ interface PGCRState { setIsReportModalOpen: Dispatch> query: RaidHubQueryParams selectedFeats: RaidHubFeatDefinition[] + allowsReporting: boolean } const PGCRContext = createContext(undefined) @@ -73,6 +76,7 @@ export const ClientStateManager = ({ playerStatsMerged, mvp, scores, + allowsReporting = true, children }: ClientStateManagerProps) => { const queryClient = useQueryClient() @@ -131,7 +135,8 @@ export const ClientStateManager = ({ isReportModalOpen, setIsReportModalOpen, query, - selectedFeats + selectedFeats, + allowsReporting }}> {children} diff --git a/src/lib/multi/merge-instances.ts b/src/lib/multi/merge-instances.ts new file mode 100644 index 00000000..2a34b22a --- /dev/null +++ b/src/lib/multi/merge-instances.ts @@ -0,0 +1,182 @@ +import type { MultiInstanceTimelineSegment } from "~/lib/multi/multi-types" +import type { + RaidHubInstanceCharacter, + RaidHubInstanceExtended, + RaidHubInstancePlayerExtended +} from "~/services/raidhub/types" + +type InstanceCharacterWeapon = RaidHubInstanceCharacter["weapons"][number] + +function segmentFromInstance(instance: RaidHubInstanceExtended): MultiInstanceTimelineSegment { + return { + instanceId: instance.instanceId, + duration: instance.duration, + start: new Date(instance.dateStarted), + end: new Date(instance.dateCompleted), + activityName: instance.metadata.activityName, + versionName: instance.metadata.versionName, + completed: instance.completed + } +} + +function mergeWeaponLists( + a: readonly InstanceCharacterWeapon[], + b: readonly InstanceCharacterWeapon[] +): InstanceCharacterWeapon[] { + const map = new Map() + for (const w of [...a, ...b]) { + const prev = map.get(w.weaponHash) + map.set(w.weaponHash, { + weaponHash: w.weaponHash, + kills: (prev?.kills ?? 0) + w.kills, + precisionKills: (prev?.precisionKills ?? 0) + w.precisionKills + }) + } + return [...map.values()] +} + +function mergeCharacterStats( + existing: RaidHubInstanceCharacter, + incoming: RaidHubInstanceCharacter +): RaidHubInstanceCharacter { + return { + characterId: existing.characterId, + classHash: existing.classHash ?? incoming.classHash, + emblemHash: incoming.emblemHash ?? existing.emblemHash, + completed: existing.completed && incoming.completed, + timePlayedSeconds: existing.timePlayedSeconds + incoming.timePlayedSeconds, + startSeconds: Math.min(existing.startSeconds, incoming.startSeconds), + score: existing.score + incoming.score, + kills: existing.kills + incoming.kills, + deaths: existing.deaths + incoming.deaths, + assists: existing.assists + incoming.assists, + precisionKills: existing.precisionKills + incoming.precisionKills, + superKills: existing.superKills + incoming.superKills, + grenadeKills: existing.grenadeKills + incoming.grenadeKills, + meleeKills: existing.meleeKills + incoming.meleeKills, + weapons: mergeWeaponLists(existing.weapons, incoming.weapons) + } +} + +type PlayerMergeAcc = { + playerInfo: RaidHubInstancePlayerExtended["playerInfo"] + characters: Map + completions: number + instancesParticipated: number + isFirstClear: boolean + sherpas: number + timePlayedSeconds: number +} + +function mergeNullableBool(values: readonly (boolean | null)[]): boolean | null { + if (values.length === 0) return null + if (values.every(v => v === true)) return true + if (values.every(v => v === false)) return false + if (values.every(v => v === null)) return null + return false +} + +/** + * Merges multiple PGCR instances (same fireteam session) into one synthetic {@link RaidHubInstanceExtended} + * so the standard PGCR UI can render aggregated stats. + */ +export function mergeRaidHubInstances( + instances: RaidHubInstanceExtended[], + multiId: string +): { + merged: RaidHubInstanceExtended + timeline: MultiInstanceTimelineSegment[] +} { + const sorted = [...instances].sort( + (a, b) => new Date(a.dateStarted).getTime() - new Date(b.dateStarted).getTime() + ) + + const timeline = sorted.map(segmentFromInstance) + + if (sorted.length === 0) { + throw new Error("mergeRaidHubInstances: empty instances") + } + + if (sorted.length === 1) { + const only = sorted[0] + return { merged: only, timeline } + } + + const first = sorted[0] + const last = sorted[sorted.length - 1] + + const playerMap = new Map() + + for (const instance of sorted) { + for (const player of instance.players) { + const existing = playerMap.get(player.playerInfo.membershipId) + const charMap = new Map() + for (const c of player.characters) { + charMap.set(c.characterId, c) + } + + if (!existing) { + playerMap.set(player.playerInfo.membershipId, { + playerInfo: player.playerInfo, + characters: charMap, + completions: player.completed ? 1 : 0, + instancesParticipated: 1, + isFirstClear: player.isFirstClear, + sherpas: player.sherpas, + timePlayedSeconds: player.timePlayedSeconds + }) + } else { + existing.completions += player.completed ? 1 : 0 + existing.instancesParticipated += 1 + existing.isFirstClear = existing.isFirstClear || player.isFirstClear + existing.sherpas += player.sherpas + existing.timePlayedSeconds += player.timePlayedSeconds + existing.playerInfo = player.playerInfo + + for (const c of player.characters) { + const prevChar = existing.characters.get(c.characterId) + existing.characters.set( + c.characterId, + prevChar ? mergeCharacterStats(prevChar, c) : c + ) + } + } + } + } + + const players: RaidHubInstancePlayerExtended[] = [...playerMap.values()].map(acc => ({ + playerInfo: acc.playerInfo, + completed: acc.completions === acc.instancesParticipated, + isFirstClear: acc.isFirstClear, + sherpas: acc.sherpas, + timePlayedSeconds: acc.timePlayedSeconds, + characters: [...acc.characters.values()] + })) + + const merged: RaidHubInstanceExtended = { + ...first, + instanceId: `multi:${multiId}`, + completed: sorted.every(i => i.completed), + flawless: mergeNullableBool(sorted.map(i => i.flawless)), + fresh: mergeNullableBool(sorted.map(i => i.fresh)), + playerCount: players.length, + skullHashes: [...new Set(sorted.flatMap(i => [...i.skullHashes]))], + score: sorted.reduce((s, i) => s + i.score, 0), + dateStarted: first.dateStarted, + dateCompleted: last.dateCompleted, + season: first.season, + duration: sorted.reduce((s, i) => s + i.duration, 0), + platformType: first.platformType, + activityId: first.activityId, + versionId: first.versionId, + isDayOne: sorted.every(i => i.isDayOne), + isContest: sorted.every(i => i.isContest), + isWeekOne: sorted.every(i => i.isWeekOne), + isBlacklisted: sorted.some(i => i.isBlacklisted), + leaderboardRank: null, + metadata: first.metadata, + players + } + + return { merged, timeline } +} diff --git a/src/lib/multi/multi-types.ts b/src/lib/multi/multi-types.ts index 1df214f7..7cba2197 100644 --- a/src/lib/multi/multi-types.ts +++ b/src/lib/multi/multi-types.ts @@ -1,49 +1,10 @@ -import { type Collection } from "@discordjs/collection" -import { type RaidHubPlayerInfo } from "~/services/raidhub/types" - -export type CombinedData = { - instances: { - instanceId: string - duration: number - start: Date - end: Date - activityName: string - versionName: string - completed: boolean - }[] - overallStart: Date - overallEnd: Date +/** Timeline strip for multi-instance PGCR views (session composed of multiple instance IDs). */ +export type MultiInstanceTimelineSegment = { + instanceId: string duration: number - players: Collection - completions: number - weapons: Collection -} - -export type CombinedPlayerStats = { - playerInfo: RaidHubPlayerInfo - characters: Collection - completions: number - timePlayedSeconds: number - kills: number - deaths: number - assists: number - riis: number - weapons: Collection -} - -export type CombinedCharacterStats = { - classHash: number | null - startSeconds: number + start: Date + end: Date + activityName: string + versionName: string completed: boolean - timePlayedSeconds: number - score: number - kills: number - deaths: number - assists: number - precisionKills: number - superKills: number - grenadeKills: number - meleeKills: number } - -export type CombinedWeaponData = { hash: number; kills: number; precisionKills: number } From b062edb101a536fe621793e50221ef852607b516 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 14:56:26 -0400 Subject: [PATCH 02/13] fix(pgcr): unblock multi view and verify with e2e Mark the shared PGCR view as client-rendered so MultiLoader can render it without tripping RSC/Suspense failures, and add a single Playwright suite plus local multi seeding so the multi PGCR path is exercised end to end. Made-with: Cursor --- .github/workflows/e2e.yml | 44 +++++++++++++++++++++ .gitignore | 4 ++ bun.lockb | Bin 360845 -> 362351 bytes e2e/app.spec.ts | 41 +++++++++++++++++++ e2e/constants.ts | 8 ++++ package.json | 6 ++- playwright.config.ts | 28 +++++++++++++ scripts/e2e-seed.ts | 63 ++++++++++++++++++++++++++++++ src/components/pgcr/pgcr-view.tsx | 2 + 9 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/app.spec.ts create mode 100644 e2e/constants.ts create mode 100644 playwright.config.ts create mode 100644 scripts/e2e-seed.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..30944e92 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + playwright: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Generate Prisma client + run: bun prisma generate + + - name: Apply migrations (local SQLite schema for APP_ENV=local) + run: bun prisma migrate deploy + env: + APP_ENV: local + + - name: Seed E2E multi (pgcr_multi row + fixed instance ids from e2e/constants.ts) + run: bun run scripts/e2e-seed.ts + env: + APP_ENV: local + + - name: Install Playwright Chromium + run: bunx playwright install chromium --with-deps + + - name: Run Playwright tests + run: playwright test + env: + CI: true + APP_ENV: local + RAIDHUB_API_KEY: ${{ secrets.RAIDHUB_API_KEY }} diff --git a/.gitignore b/.gitignore index 050500df..b70e024e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ # testing /coverage +/playwright-report/ +/test-results/ +/blob-report/ +/playwright/.cache/ # next.js /.next/ diff --git a/bun.lockb b/bun.lockb index 6e14e0a296cb2920769c9cc25cb04cc89992459b..e6709b7d45486bec7a9d00aa59fabaef34d98f25 100755 GIT binary patch delta 52844 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 { + test("home responds", async ({ page }) => { + const res = await page.goto("/") + expect(res?.ok()).toBeTruthy() + }) + + test("faq loads with content", async ({ page }) => { + await page.goto("/faq") + await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 30_000 }) + }) + + test("pgcr: invalid instance id shows not found", async ({ page }) => { + await page.goto("/pgcr/not-a-valid-instance-id", { waitUntil: "domcontentloaded" }) + await expect(page.getByText("Not found :(")).toBeVisible({ timeout: 30_000 }) + }) + + test("pgcr: resolved instance shows Summary", async ({ page }) => { + await page.goto(`/pgcr/${E2E_PGCR_INSTANCE_ID}`, { waitUntil: "domcontentloaded" }) + await expect(page.getByRole("heading", { name: "Summary", exact: false })).toBeVisible({ + timeout: 60_000 + }) + }) + + test("multi: merged PGCR shows Summary", async ({ page }) => { + const multiId = process.env.E2E_MULTI_ID ?? DEFAULT_E2E_MULTI_ID + await page.goto(`/multi/${multiId}`, { waitUntil: "domcontentloaded" }) + await expect(page.getByRole("heading", { name: "PGCR Multi-View" })).toBeVisible({ + timeout: 30_000 + }) + await expect(page.getByRole("heading", { name: "Summary", exact: false })).toBeVisible({ + timeout: 120_000 + }) + }) +}) diff --git a/e2e/constants.ts b/e2e/constants.ts new file mode 100644 index 00000000..9a33cd53 --- /dev/null +++ b/e2e/constants.ts @@ -0,0 +1,8 @@ +/** + * Stable RaidHub instance ids for E2E (historical Vault of Glass master team-first entries). + * Replace if RaidHub ever stops serving these PGCRs. + */ +export const E2E_PGCR_INSTANCE_ID = "8779815161" + +export const E2E_MULTI_INSTANCE_FIRST = "8779815161" +export const E2E_MULTI_INSTANCE_SECOND = "8779834177" diff --git a/package.json b/package.json index e3caf124..90aaf4a0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "format": "prettier --write \"src/**/*.{ts,tsx,json}\" && prisma format", "format:check": "prettier --check \"src/**/*.{ts,tsx,json}\"", "build": "bun prisma generate && next build", - "deploy": "./vercel/dev-deploy.sh" + "deploy": "./vercel/dev-deploy.sh", + "dev:e2e": "next dev -p 3000", + "test:e2e": "bun prisma migrate deploy && bun run scripts/e2e-seed.ts && playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@auth/core": "0.32.0", @@ -79,6 +82,7 @@ "@libsql/linux-x64-gnu": "0.5.5", "@next/bundle-analyzer": "14.2.0-canary.26", "@next/eslint-plugin-next": "14.2.0-canary.26", + "@playwright/test": "^1.59.1", "@tanstack/react-query-devtools": "4.36", "@types/node": "20", "@types/react": "18.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..3a7cac4f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import "dotenv/config" + +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [["list"]], + timeout: 60_000, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: process.env.CI ? "retain-on-failure" : "off" + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "bun run dev:e2e", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: "pipe", + stderr: "pipe" + } +}) diff --git a/scripts/e2e-seed.ts b/scripts/e2e-seed.ts new file mode 100644 index 00000000..48aa3496 --- /dev/null +++ b/scripts/e2e-seed.ts @@ -0,0 +1,63 @@ +/** + * Seeds local SQLite (APP_ENV=local) with a PgcrMulti + fixed instance ids — see e2e/constants.ts. + */ +import "dotenv/config" + +import { + E2E_MULTI_INSTANCE_FIRST, + E2E_MULTI_INSTANCE_SECOND +} from "../e2e/constants" +import { PrismaClient } from "@prisma/client" + +const DEFAULT_MULTI_ID = "01KNYVV8NXWK2P4188VNQZTZJM" +/** Placeholder owner — must exist as bungie_user for FK. */ +const SEED_OWNER_ID = "4611686018488107374" + +async function main() { + if (process.env.APP_ENV !== "local") { + console.log("e2e-seed: skipping (APP_ENV is not local)") + return + } + + const multiId = process.env.E2E_MULTI_ID ?? DEFAULT_MULTI_ID + const i1 = E2E_MULTI_INSTANCE_FIRST + const i2 = E2E_MULTI_INSTANCE_SECOND + + const prisma = new PrismaClient() + + await prisma.user.upsert({ + where: { id: SEED_OWNER_ID }, + create: { + id: SEED_OWNER_ID, + role_: "USER" + }, + update: {} + }) + + await prisma.pgcrMulti.upsert({ + where: { id: multiId }, + create: { + id: multiId, + ownerId: SEED_OWNER_ID, + name: "E2E seed multi", + instances: { + create: [{ instanceId: i1 }, { instanceId: i2 }] + } + }, + update: { + name: "E2E seed multi", + instances: { + deleteMany: {}, + create: [{ instanceId: i1 }, { instanceId: i2 }] + } + } + }) + + await prisma.$disconnect() + console.log("e2e-seed: ok", { multiId, instances: [i1, i2] }) +} + +main().catch(e => { + console.error(e) + process.exit(1) +}) diff --git a/src/components/pgcr/pgcr-view.tsx b/src/components/pgcr/pgcr-view.tsx index e1c98bfd..73dda3e6 100644 --- a/src/components/pgcr/pgcr-view.tsx +++ b/src/components/pgcr/pgcr-view.tsx @@ -1,3 +1,5 @@ +"use client" + import { Collection } from "@discordjs/collection" import { ClientStateManager } from "~/hooks/pgcr/ClientStateManager" import { type MultiInstanceTimelineSegment } from "~/lib/multi/multi-types" From 95abb0c673d53dce20fdbb27c34294ba662b1dc3 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 17:11:04 -0400 Subject: [PATCH 03/13] fix(ci): repair formatting and playwright workflow Run Playwright through bunx in GitHub Actions so the binary resolves correctly, and commit the Prettier output needed for the formatting check. This keeps the seeded multi E2E flow green in CI and locally. Made-with: Cursor --- .github/workflows/e2e.yml | 2 +- src/components/multi/multi-loader.tsx | 8 +------- src/components/pgcr/pgcr-multi-timeline.tsx | 5 +---- src/hooks/profile/useRaidTags.ts | 5 ++++- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 30944e92..80968b33 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,7 +37,7 @@ jobs: run: bunx playwright install chromium --with-deps - name: Run Playwright tests - run: playwright test + run: bunx playwright test env: CI: true APP_ENV: local diff --git a/src/components/multi/multi-loader.tsx b/src/components/multi/multi-loader.tsx index 2ce8c847..986a0188 100644 --- a/src/components/multi/multi-loader.tsx +++ b/src/components/multi/multi-loader.tsx @@ -4,13 +4,7 @@ import PGCR from "~/components/pgcr/pgcr-view" import { mergeRaidHubInstances } from "~/lib/multi/merge-instances" import { useRaidHubInstanceList } from "~/services/raidhub/useRaidHubInstance" -export const MultiLoader = ({ - multiId, - instances -}: { - multiId: string - instances: string[] -}) => { +export const MultiLoader = ({ multiId, instances }: { multiId: string; instances: string[] }) => { const queries = useRaidHubInstanceList(instances) const isLoading = queries.some(q => q.isLoading) diff --git a/src/components/pgcr/pgcr-multi-timeline.tsx b/src/components/pgcr/pgcr-multi-timeline.tsx index 4f18f387..3a30d0a3 100644 --- a/src/components/pgcr/pgcr-multi-timeline.tsx +++ b/src/components/pgcr/pgcr-multi-timeline.tsx @@ -18,10 +18,7 @@ export const PGCRMultiTimeline = ({ segments }: { segments: MultiInstanceTimelin (min, d) => (d.start < min ? d.start : min), segments[0].start ), - overallEnd: segments.reduce( - (max, d) => (d.end > max ? d.end : max), - segments[0].end - ) + overallEnd: segments.reduce((max, d) => (d.end > max ? d.end : max), segments[0].end) } }, [segments]) diff --git a/src/hooks/profile/useRaidTags.ts b/src/hooks/profile/useRaidTags.ts index 97035220..e7a2fa41 100644 --- a/src/hooks/profile/useRaidTags.ts +++ b/src/hooks/profile/useRaidTags.ts @@ -14,7 +14,10 @@ const opItemEvents: Record< horseman: { start: new Date("2023-04-26T17:00:00Z"), end: new Date("2023-04-28T17:00:00Z") }, craftening: { start: new Date("2023-09-15T17:00:00Z"), end: new Date("2023-09-21T17:00:00Z") }, quickfang: { start: new Date("2025-09-16T17:00:00Z"), end: new Date("2025-09-23T17:00:00Z") }, - shattershafts: { start: new Date("2026-01-27T17:00:00Z"), end: new Date("2025-02-03T17:00:00Z") }, + shattershafts: { + start: new Date("2026-01-27T17:00:00Z"), + end: new Date("2025-02-03T17:00:00Z") + } } const isDuringOpItemEvent = (dateStr: string) => { From 2a8f69cbbe1d606199a7c485b008f291656fb86d Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 17:23:32 -0400 Subject: [PATCH 04/13] fix(ci): use absolute local sqlite path for prisma adapter Point the libsql-backed local Prisma adapter at the same sqlite file that migrations and E2E seeding use so Playwright can resolve pgcr_multi during CI and local runs. Made-with: Cursor --- src/lib/server/prisma.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index dc7801a2..5e520dfd 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -1,5 +1,6 @@ import "server-only" +import path from "node:path" import { createClient } from "@libsql/client" import { PrismaLibSQL } from "@prisma/adapter-libsql" import { PrismaClient } from "@prisma/client" @@ -12,10 +13,12 @@ interface GlobalThisWithPrisma { } const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma +const localSqliteUrl = `file:${path.resolve(process.cwd(), "prisma", "raidhub-sqlite.db")}` + const createPrismaWithExtension = () => { const libSQL = createClient( process.env.APP_ENV === "local" - ? { url: "file:./prisma/raidhub-sqlite.db" } + ? { url: localSqliteUrl } : { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, From de99d68f317c7434acec34c0718d4189612c1022 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:02:53 -0400 Subject: [PATCH 05/13] fix(ci): commit prisma path update and ignore stray sqlite file Commit the local Prisma adapter path change that CI needs for seeded multi E2E, and ignore the empty repo-root sqlite artifact so local runs stay clean. Made-with: Cursor --- .gitignore | 2 ++ src/lib/server/prisma.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b70e024e..475e5cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ src/app/test # certs certificates + +raidhub-sqlite.db diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index 5e520dfd..af039ea8 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -1,9 +1,9 @@ import "server-only" -import path from "node:path" import { createClient } from "@libsql/client" import { PrismaLibSQL } from "@prisma/adapter-libsql" import { PrismaClient } from "@prisma/client" +import path from "node:path" import { saferFetch } from "./saferFetch" export type PrismaClientWithExtensions = ReturnType From 43b17ab42e15838c9a50f90372440257c5e0417c Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:10:06 -0400 Subject: [PATCH 06/13] fix(ci): use file URL for local libsql sqlite path Use pathToFileURL for the local sqlite adapter path so libsql initializes correctly on Linux runners while still pointing at the seeded prisma database used by multi E2E. Made-with: Cursor --- src/lib/server/prisma.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index af039ea8..f94cb9c4 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -4,6 +4,7 @@ import { createClient } from "@libsql/client" import { PrismaLibSQL } from "@prisma/adapter-libsql" import { PrismaClient } from "@prisma/client" import path from "node:path" +import { pathToFileURL } from "node:url" import { saferFetch } from "./saferFetch" export type PrismaClientWithExtensions = ReturnType @@ -13,7 +14,9 @@ interface GlobalThisWithPrisma { } const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma -const localSqliteUrl = `file:${path.resolve(process.cwd(), "prisma", "raidhub-sqlite.db")}` +const localSqliteUrl = pathToFileURL( + path.resolve(process.cwd(), "prisma", "raidhub-sqlite.db") +).toString() const createPrismaWithExtension = () => { const libSQL = createClient( From 7cc87ada41ffc4395afd041cda5733974491527d Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:18:44 -0400 Subject: [PATCH 07/13] fix(ci): restore relative local libsql sqlite path Use the seeded prisma SQLite path for local APP_ENV runs because the absolute file URL form breaks libsql on the Linux Playwright runner. Made-with: Cursor --- src/lib/server/prisma.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index f94cb9c4..dc7801a2 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -3,8 +3,6 @@ import "server-only" import { createClient } from "@libsql/client" import { PrismaLibSQL } from "@prisma/adapter-libsql" import { PrismaClient } from "@prisma/client" -import path from "node:path" -import { pathToFileURL } from "node:url" import { saferFetch } from "./saferFetch" export type PrismaClientWithExtensions = ReturnType @@ -14,14 +12,10 @@ interface GlobalThisWithPrisma { } const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma -const localSqliteUrl = pathToFileURL( - path.resolve(process.cwd(), "prisma", "raidhub-sqlite.db") -).toString() - const createPrismaWithExtension = () => { const libSQL = createClient( process.env.APP_ENV === "local" - ? { url: localSqliteUrl } + ? { url: "file:./prisma/raidhub-sqlite.db" } : { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, From eba690405e799fe9f575079c0e147849705033b9 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:26:52 -0400 Subject: [PATCH 08/13] fix(ci): pass local app env to Playwright web server Ensure the spawned Next.js server runs with APP_ENV=local so E2E uses the seeded SQLite database instead of trying to initialize the remote libsql client in CI. Made-with: Cursor --- playwright.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/playwright.config.ts b/playwright.config.ts index 3a7cac4f..73c1c4f2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { command: "bun run dev:e2e", + env: { + ...process.env, + APP_ENV: process.env.APP_ENV ?? "local" + }, url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 180_000, From 071db28071dfba22bf89be65a6f557101836efc2 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:33:02 -0400 Subject: [PATCH 09/13] fix(ci): use bare local sqlite file path for libsql Point the local libsql adapter at the seeded prisma database without the leading ./ because the Linux Playwright runner still rejects that native sqlite path shape. Made-with: Cursor --- src/lib/server/prisma.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index dc7801a2..61e91b8b 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -15,7 +15,7 @@ const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma const createPrismaWithExtension = () => { const libSQL = createClient( process.env.APP_ENV === "local" - ? { url: "file:./prisma/raidhub-sqlite.db" } + ? { url: "file:prisma/raidhub-sqlite.db" } : { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, From 6725b86e806c2d22d92555f660dbf064128201aa Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:38:52 -0400 Subject: [PATCH 10/13] fix(ci): use native Prisma client for local sqlite Avoid the libsql adapter for APP_ENV=local so Playwright uses Prisma's built-in SQLite path handling while production still uses the Turso adapter. Made-with: Cursor --- src/lib/server/prisma.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index 61e91b8b..857c2640 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -13,24 +13,27 @@ interface GlobalThisWithPrisma { const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma const createPrismaWithExtension = () => { - const libSQL = createClient( + const baseClient = process.env.APP_ENV === "local" - ? { url: "file:prisma/raidhub-sqlite.db" } - : { - url: process.env.TURSO_DATABASE_URL!, - authToken: process.env.TURSO_AUTH_TOKEN, - fetch: async (request: Request) => { - return await saferFetch(request, { - cache: "no-store" + ? new PrismaClient({ + log: ["query", "error", "warn"] + }) + : new PrismaClient({ + log: ["error"], + adapter: new PrismaLibSQL( + createClient({ + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, + fetch: async (request: Request) => { + return await saferFetch(request, { + cache: "no-store" + }) + } }) - } - } - ) + ) + }) - return new PrismaClient({ - log: process.env.APP_ENV === "local" ? ["query", "error", "warn"] : ["error"], - adapter: new PrismaLibSQL(libSQL) - }).$extends({ + return baseClient.$extends({ name: "roleEnum", result: { user: { From 4b671faed121edfbe54c96892bc7feecd3e71d3f Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 18:47:55 -0400 Subject: [PATCH 11/13] fix(ci): fall back to a local manifest without API key Return an empty local manifest in APP_ENV=local when RAIDHUB_API_KEY is unavailable so the E2E app shell can render in CI while API-backed production behavior stays unchanged. Made-with: Cursor --- .../raidhub/prefetchRaidHubManifest.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/services/raidhub/prefetchRaidHubManifest.ts b/src/services/raidhub/prefetchRaidHubManifest.ts index 6849309b..44524a56 100644 --- a/src/services/raidhub/prefetchRaidHubManifest.ts +++ b/src/services/raidhub/prefetchRaidHubManifest.ts @@ -3,7 +3,29 @@ import { reactRequestDedupe } from "~/util/react-cache" import { getRaidHubApi } from "./common" import { type RaidHubManifestResponse } from "./types" +const localFallbackManifest: RaidHubManifestResponse = { + hashes: {}, + activityDefinitions: {}, + versionDefinitions: {}, + listedRaidIds: [], + sunsetRaidIds: [], + prestigeRaidIds: [], + masterRaidIds: [], + contestRaidIds: [], + resprisedRaidIds: [], + resprisedChallengeVersionIds: [], + pantheonIds: [], + versionsForActivity: {}, + rankingTiers: [], + feats: [], + splashUrls: {} +} + export const prefetchManifest = reactRequestDedupe(async (): Promise => { + if (process.env.APP_ENV === "local" && !process.env.RAIDHUB_API_KEY) { + return localFallbackManifest + } + return await getRaidHubApi( "/manifest", null, From c529d822823c222cef6ce04760ba71c8bb6919b3 Mon Sep 17 00:00:00 2001 From: owen Date: Sat, 11 Apr 2026 19:09:55 -0400 Subject: [PATCH 12/13] fix(ci): serve local PGCR fixtures for E2E Run Playwright entirely against local fixture-backed PGCR data when CI is missing external RaidHub auth so the seeded single and multi PGCR pages stay testable without live API dependencies. Made-with: Cursor --- e2e/fixtures/8779815161.json | 486 ++++++++++++++++++ e2e/fixtures/8779834177.json | 456 ++++++++++++++++ playwright.config.ts | 6 +- .../raidhub/instance/[instanceId]/route.ts | 34 ++ src/components/pgcr/pgcr-header-bg.tsx | 2 +- src/services/raidhub/common.ts | 32 +- 6 files changed, 1006 insertions(+), 10 deletions(-) create mode 100644 e2e/fixtures/8779815161.json create mode 100644 e2e/fixtures/8779834177.json create mode 100644 src/app/api/e2e/raidhub/instance/[instanceId]/route.ts diff --git a/e2e/fixtures/8779815161.json b/e2e/fixtures/8779815161.json new file mode 100644 index 00000000..cc464645 --- /dev/null +++ b/e2e/fixtures/8779815161.json @@ -0,0 +1,486 @@ +{ + "instanceId": "8779815161", + "hash": 1681562271, + "activityId": 9, + "versionId": 4, + "completed": true, + "playerCount": 6, + "score": 0, + "fresh": null, + "flawless": false, + "skullHashes": [], + "dateStarted": "2021-07-06T17:10:04.000Z", + "dateCompleted": "2021-07-06T17:46:58.000Z", + "season": 14, + "duration": 2214, + "platformType": 3, + "isDayOne": false, + "isContest": false, + "isWeekOne": false, + "isBlacklisted": false, + "leaderboardRank": 1, + "metadata": { + "activityName": "Vault of Glass", + "versionName": "Master", + "isRaid": true + }, + "players": [ + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2214, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/d55829b357cade6477878058ef11eeae.jpg", + "lastSeen": "2022-08-25T21:27:53.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "vPoots", + "membershipId": "4611686018453262663", + "membershipType": 1, + "bungieGlobalDisplayName": "poots", + "bungieGlobalDisplayNameCode": "2264" + }, + "characters": [ + { + "kills": 225, + "score": 0, + "deaths": 3, + "assists": 102, + "weapons": [ + { + "kills": 52, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 19, + "weaponHash": 1097616550, + "precisionKills": 4 + }, + { + "kills": 18, + "weaponHash": 2222560548, + "precisionKills": 7 + }, + { + "kills": 9, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 5, + "weaponHash": 2990047042, + "precisionKills": 3 + }, + { + "kills": 4, + "weaponHash": 821154603, + "precisionKills": 2 + }, + { + "kills": 3, + "weaponHash": 2060863616, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 2133500855, + "meleeKills": 5, + "superKills": 66, + "characterId": "2305843009467534048", + "grenadeKills": 22, + "startSeconds": 0, + "precisionKills": 16, + "timePlayedSeconds": 2214 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2213, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/8f4020df5dd0ee175360576354eda082.jpg", + "lastSeen": "2026-02-07T23:38:35.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Praeyx", + "membershipId": "4611686018437165105", + "membershipType": 1, + "bungieGlobalDisplayName": "Prae", + "bungieGlobalDisplayNameCode": "2221" + }, + "characters": [ + { + "kills": 158, + "score": 0, + "deaths": 4, + "assists": 77, + "weapons": [ + { + "kills": 20, + "weaponHash": 2171478765, + "precisionKills": 6 + }, + { + "kills": 18, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 14, + "weaponHash": 1946491241, + "precisionKills": 0 + }, + { + "kills": 12, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 5, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 3, + "weaponHash": 2990047042, + "precisionKills": 3 + }, + { + "kills": 1, + "weaponHash": 2448907086, + "precisionKills": 0 + }, + { + "kills": 1, + "weaponHash": 2807687156, + "precisionKills": 1 + }, + { + "kills": 1, + "weaponHash": 981718087, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 3605490914, + "meleeKills": 0, + "superKills": 63, + "characterId": "2305843009267088647", + "grenadeKills": 11, + "startSeconds": 1, + "precisionKills": 20, + "timePlayedSeconds": 2213 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2213, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/70038c6d33c095f109a9df3f2bfc28de.jpg", + "lastSeen": "2022-10-19T21:42:11.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "iAmShuffles", + "membershipId": "4611686018451336648", + "membershipType": 2, + "bungieGlobalDisplayName": "Shuffles", + "bungieGlobalDisplayNameCode": "9737" + }, + "characters": [ + { + "kills": 150, + "score": 0, + "deaths": 3, + "assists": 86, + "weapons": [ + { + "kills": 27, + "weaponHash": 2222560548, + "precisionKills": 14 + }, + { + "kills": 18, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 17, + "weaponHash": 4230965989, + "precisionKills": 6 + }, + { + "kills": 10, + "weaponHash": 2990047042, + "precisionKills": 8 + }, + { + "kills": 9, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 6, + "weaponHash": 4248569242, + "precisionKills": 3 + }, + { + "kills": 3, + "weaponHash": 3211806999, + "precisionKills": 2 + }, + { + "kills": 3, + "weaponHash": 614426548, + "precisionKills": 0 + } + ], + "classHash": 671679327, + "completed": true, + "emblemHash": 4182480224, + "meleeKills": 0, + "superKills": 31, + "characterId": "2305843009686254957", + "grenadeKills": 15, + "startSeconds": 1, + "precisionKills": 44, + "timePlayedSeconds": 2213 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2213, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/97d1647338c17bdddc79bfac4ed01519.jpg", + "lastSeen": "2024-09-11T19:52:13.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Maso", + "membershipId": "4611686018467289542", + "membershipType": 3, + "bungieGlobalDisplayName": "Maso", + "bungieGlobalDisplayNameCode": "9958" + }, + "characters": [ + { + "kills": 264, + "score": 0, + "deaths": 3, + "assists": 99, + "weapons": [ + { + "kills": 92, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 54, + "weaponHash": 2222560548, + "precisionKills": 18 + }, + { + "kills": 14, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 9, + "weaponHash": 4248569242, + "precisionKills": 4 + }, + { + "kills": 8, + "weaponHash": 821154603, + "precisionKills": 4 + }, + { + "kills": 6, + "weaponHash": 2990047042, + "precisionKills": 6 + }, + { + "kills": 5, + "weaponHash": 2060863616, + "precisionKills": 0 + } + ], + "classHash": 3655393761, + "completed": true, + "emblemHash": 1907674139, + "meleeKills": 2, + "superKills": 10, + "characterId": "2305843009836114017", + "grenadeKills": 17, + "startSeconds": 1, + "precisionKills": 32, + "timePlayedSeconds": 2213 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2213, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/7fb53db4f4074e73b790c9277d75a75c.jpg", + "lastSeen": "2023-09-03T11:41:35.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Hizxr", + "membershipId": "4611686018471330827", + "membershipType": 3, + "bungieGlobalDisplayName": "Hizxr", + "bungieGlobalDisplayNameCode": "5854" + }, + "characters": [ + { + "kills": 227, + "score": 0, + "deaths": 5, + "assists": 120, + "weapons": [ + { + "kills": 23, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 17, + "weaponHash": 2222560548, + "precisionKills": 2 + }, + { + "kills": 14, + "weaponHash": 3186018373, + "precisionKills": 10 + }, + { + "kills": 11, + "weaponHash": 304659313, + "precisionKills": 0 + }, + { + "kills": 9, + "weaponHash": 4248569242, + "precisionKills": 4 + }, + { + "kills": 7, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 4, + "weaponHash": 370712896, + "precisionKills": 0 + }, + { + "kills": 3, + "weaponHash": 2990047042, + "precisionKills": 3 + }, + { + "kills": 2, + "weaponHash": 1481892490, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 3970180843, + "meleeKills": 1, + "superKills": 91, + "characterId": "2305843009607965794", + "grenadeKills": 26, + "startSeconds": 1, + "precisionKills": 20, + "timePlayedSeconds": 2213 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2212, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/517e5ab20f9372aa2eb4a1b2bfdb6fe3.jpg", + "lastSeen": "2026-04-04T10:20:46.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Panora", + "membershipId": "4611686018472402376", + "membershipType": 3, + "bungieGlobalDisplayName": "Panora", + "bungieGlobalDisplayNameCode": "8240" + }, + "characters": [ + { + "kills": 198, + "score": 0, + "deaths": 3, + "assists": 91, + "weapons": [ + { + "kills": 51, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 21, + "weaponHash": 3682803680, + "precisionKills": 8 + }, + { + "kills": 19, + "weaponHash": 304659313, + "precisionKills": 0 + }, + { + "kills": 7, + "weaponHash": 2222560548, + "precisionKills": 2 + }, + { + "kills": 7, + "weaponHash": 3717177717, + "precisionKills": 2 + }, + { + "kills": 6, + "weaponHash": 821154603, + "precisionKills": 3 + }, + { + "kills": 3, + "weaponHash": 1395261499, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 2133500854, + "meleeKills": 3, + "superKills": 56, + "characterId": "2305843009349344641", + "grenadeKills": 17, + "startSeconds": 2, + "precisionKills": 20, + "timePlayedSeconds": 2212 + } + ] + } + ] +} diff --git a/e2e/fixtures/8779834177.json b/e2e/fixtures/8779834177.json new file mode 100644 index 00000000..8fe2cd8c --- /dev/null +++ b/e2e/fixtures/8779834177.json @@ -0,0 +1,456 @@ +{ + "instanceId": "8779834177", + "hash": 1681562271, + "activityId": 9, + "versionId": 4, + "completed": true, + "playerCount": 6, + "score": 0, + "fresh": null, + "flawless": false, + "skullHashes": [], + "dateStarted": "2021-07-06T17:06:11.000Z", + "dateCompleted": "2021-07-06T17:52:14.000Z", + "season": 14, + "duration": 2763, + "platformType": 3, + "isDayOne": false, + "isContest": false, + "isWeekOne": false, + "isBlacklisted": false, + "leaderboardRank": 2, + "metadata": { + "activityName": "Vault of Glass", + "versionName": "Master", + "isRaid": true + }, + "players": [ + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2763, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/07c13b7f1a0801fe7f37dd221c245f70.jpg", + "lastSeen": "2024-10-20T21:21:54.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Airborn", + "membershipId": "4611686018471162983", + "membershipType": 3, + "bungieGlobalDisplayName": "Airborn", + "bungieGlobalDisplayNameCode": "8179" + }, + "characters": [ + { + "kills": 466, + "score": 0, + "deaths": 10, + "assists": 171, + "weapons": [ + { + "kills": 198, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 78, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 73, + "weaponHash": 2171478765, + "precisionKills": 28 + }, + { + "kills": 23, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 18, + "weaponHash": 1946491241, + "precisionKills": 0 + }, + { + "kills": 15, + "weaponHash": 681067419, + "precisionKills": 11 + }, + { + "kills": 2, + "weaponHash": 3580904581, + "precisionKills": 0 + } + ], + "classHash": 3655393761, + "completed": true, + "emblemHash": 2133500855, + "meleeKills": 16, + "superKills": 12, + "characterId": "2305843009309634944", + "grenadeKills": 5, + "startSeconds": 0, + "precisionKills": 40, + "timePlayedSeconds": 2763 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2761, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/5e76df9deab0787741815ce5d4baf87b.jpg", + "lastSeen": "2026-04-11T05:54:20.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "Makro", + "membershipId": "4611686018471591897", + "membershipType": 3, + "bungieGlobalDisplayName": "Makro", + "bungieGlobalDisplayNameCode": "9053" + }, + "characters": [ + { + "kills": 227, + "score": 0, + "deaths": 14, + "assists": 132, + "weapons": [ + { + "kills": 54, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 39, + "weaponHash": 1946491241, + "precisionKills": 0 + }, + { + "kills": 27, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 21, + "weaponHash": 2171478765, + "precisionKills": 6 + }, + { + "kills": 15, + "weaponHash": 766323545, + "precisionKills": 2 + }, + { + "kills": 14, + "weaponHash": 681067419, + "precisionKills": 6 + }, + { + "kills": 11, + "weaponHash": 2990047042, + "precisionKills": 9 + }, + { + "kills": 2, + "weaponHash": 3186018373, + "precisionKills": 0 + } + ], + "classHash": 3655393761, + "completed": true, + "emblemHash": 2510169795, + "meleeKills": 3, + "superKills": 1, + "characterId": "2305843009307634550", + "grenadeKills": 13, + "startSeconds": 2, + "precisionKills": 24, + "timePlayedSeconds": 2761 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2761, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/1a5fe4dd02e08417395a423d6bd575e6.jpg", + "lastSeen": "2026-04-10T08:57:02.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "sacrifice", + "membershipId": "4611686018486225495", + "membershipType": 3, + "bungieGlobalDisplayName": "sacrifice", + "bungieGlobalDisplayNameCode": "4944" + }, + "characters": [ + { + "kills": 271, + "score": 0, + "deaths": 11, + "assists": 217, + "weapons": [ + { + "kills": 90, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 65, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 27, + "weaponHash": 681067419, + "precisionKills": 10 + }, + { + "kills": 22, + "weaponHash": 1946491241, + "precisionKills": 0 + }, + { + "kills": 9, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 9, + "weaponHash": 3037520408, + "precisionKills": 0 + }, + { + "kills": 5, + "weaponHash": 4184808992, + "precisionKills": 4 + }, + { + "kills": 2, + "weaponHash": 3487253372, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 1230660645, + "meleeKills": 0, + "superKills": 18, + "characterId": "2305843009425144851", + "grenadeKills": 4, + "startSeconds": 2, + "precisionKills": 14, + "timePlayedSeconds": 2761 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2713, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/4ccf74b4ccd7467b25919d6b7b4cf4dd.jpg", + "lastSeen": "2026-04-06T04:15:58.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "iiGamerr Connor", + "membershipId": "4611686018431899798", + "membershipType": 1, + "bungieGlobalDisplayName": "iiGamerr Connor", + "bungieGlobalDisplayNameCode": "1621" + }, + "characters": [ + { + "kills": 294, + "score": 0, + "deaths": 8, + "assists": 192, + "weapons": [ + { + "kills": 193, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 24, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 12, + "weaponHash": 2171478765, + "precisionKills": 5 + }, + { + "kills": 3, + "weaponHash": 1395261499, + "precisionKills": 0 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 2510169795, + "meleeKills": 3, + "superKills": 19, + "characterId": "2305843009263864933", + "grenadeKills": 9, + "startSeconds": 50, + "precisionKills": 5, + "timePlayedSeconds": 2713 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2631, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/eae7dcbb1996b21521ce26c5914af58f.jpg", + "lastSeen": "2026-04-06T04:11:09.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "bohdi", + "membershipId": "4611686018468621171", + "membershipType": 3, + "bungieGlobalDisplayName": "bohdi", + "bungieGlobalDisplayNameCode": "1165" + }, + "characters": [ + { + "kills": 227, + "score": 0, + "deaths": 11, + "assists": 142, + "weapons": [ + { + "kills": 61, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 53, + "weaponHash": 3186018373, + "precisionKills": 31 + }, + { + "kills": 20, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 16, + "weaponHash": 2060863616, + "precisionKills": 0 + }, + { + "kills": 15, + "weaponHash": 2222560548, + "precisionKills": 2 + }, + { + "kills": 6, + "weaponHash": 2990047042, + "precisionKills": 5 + }, + { + "kills": 3, + "weaponHash": 4281371574, + "precisionKills": 2 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 2510169795, + "meleeKills": 0, + "superKills": 22, + "characterId": "2305843009617404902", + "grenadeKills": 12, + "startSeconds": 132, + "precisionKills": 43, + "timePlayedSeconds": 2631 + } + ] + }, + { + "completed": true, + "isFirstClear": false, + "sherpas": 0, + "timePlayedSeconds": 2585, + "playerInfo": { + "iconPath": "/common/destiny2_content/icons/3c251b702026fee24488eac5cbd3a2e2.jpg", + "lastSeen": "2026-02-04T14:40:29.000Z", + "isPrivate": false, + "cheatLevel": 0, + "displayName": "argie", + "membershipId": "4611686018476362522", + "membershipType": 3, + "bungieGlobalDisplayName": "argie", + "bungieGlobalDisplayNameCode": "8521" + }, + "characters": [ + { + "kills": 262, + "score": 0, + "deaths": 7, + "assists": 197, + "weapons": [ + { + "kills": 63, + "weaponHash": 2376481550, + "precisionKills": 0 + }, + { + "kills": 55, + "weaponHash": 1946491241, + "precisionKills": 0 + }, + { + "kills": 36, + "weaponHash": 2171478765, + "precisionKills": 12 + }, + { + "kills": 10, + "weaponHash": 2990047042, + "precisionKills": 9 + }, + { + "kills": 8, + "weaponHash": 1395261499, + "precisionKills": 0 + }, + { + "kills": 4, + "weaponHash": 3504336176, + "precisionKills": 0 + }, + { + "kills": 1, + "weaponHash": 3186018373, + "precisionKills": 1 + } + ], + "classHash": 2271682572, + "completed": true, + "emblemHash": 2510169795, + "meleeKills": 5, + "superKills": 11, + "characterId": "2305843009411475001", + "grenadeKills": 44, + "startSeconds": 178, + "precisionKills": 22, + "timePlayedSeconds": 2585 + } + ] + } + ] +} diff --git a/playwright.config.ts b/playwright.config.ts index 73c1c4f2..0cd941e5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,11 @@ export default defineConfig({ command: "bun run dev:e2e", env: { ...process.env, - APP_ENV: process.env.APP_ENV ?? "local" + APP_ENV: process.env.APP_ENV ?? "local", + AUTH_SECRET: + process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "e2e-auth-secret", + NEXTAUTH_SECRET: + process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET || "e2e-auth-secret" }, url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, diff --git a/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts b/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts new file mode 100644 index 00000000..c94c3704 --- /dev/null +++ b/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { type RaidHubInstanceExtended } from "~/services/raidhub/types" + +const fixturePath = (instanceId: string) => + path.resolve(process.cwd(), "e2e", "fixtures", `${instanceId}.json`) + +export async function GET(_request: Request, { params }: { params: { instanceId: string } }) { + try { + const fixture = await readFile(fixturePath(params.instanceId), "utf8") + const response = JSON.parse(fixture) as RaidHubInstanceExtended + + return NextResponse.json({ + minted: new Date().toISOString(), + success: true, + response + }) + } catch { + return NextResponse.json( + { + minted: new Date().toISOString(), + success: false, + code: "InstanceNotFoundError", + error: { + instanceId: params.instanceId + } + }, + { + status: 404 + } + ) + } +} diff --git a/src/components/pgcr/pgcr-header-bg.tsx b/src/components/pgcr/pgcr-header-bg.tsx index ce36aaa1..3c8e1b40 100644 --- a/src/components/pgcr/pgcr-header-bg.tsx +++ b/src/components/pgcr/pgcr-header-bg.tsx @@ -15,7 +15,7 @@ export const PGCRHeaderBackground = ({ const imageVariants = getImageVariantsForActivity(activityId) const variant = imageVariants.find(v => v.size === "small") ?? imageVariants[0] - const backgroundImageUrl = variant.url ?? FallbackSplash + const backgroundImageUrl = variant?.url ?? FallbackSplash return (
{ + if (typeof window !== "undefined") { + return window.location.origin + } + + return process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 3000}` +} + +const isLocalNoApiKey = () => process.env.APP_ENV === "local" && !process.env.RAIDHUB_API_KEY + export async function getRaidHubApi< T extends RaidHubGetPath, P = "parameters" extends keyof paths[T]["get"] ? paths[T]["get"]["parameters"] : null, @@ -22,14 +32,20 @@ export async function getRaidHubApi< config?: Omit, fetchFn?: typeof fetch ): Promise> { - const url = new URL( - path.replace(/{([^}]+)}/g, (_, paramName) => { - // @ts-expect-error types don't really work here - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access - return pathParams[paramName] - }), - process.env.RAIDHUB_API_URL - ) + const resolvedPath = path.replace(/{([^}]+)}/g, (_, paramName) => { + // @ts-expect-error types don't really work here + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return pathParams[paramName] + }) + + const url = + isLocalNoApiKey() && path === "/instance/{instanceId}" + ? new URL( + `/api/e2e/raidhub/instance/${resolvedPath.split("/").at(-1)}`, + getLocalE2EBaseUrl() + ) + : new URL(resolvedPath, process.env.RAIDHUB_API_URL) + Object.entries(queryParams ?? {}).forEach(([key, value]) => { if (value !== undefined) url.searchParams.set(key, String(value)) }) From 22376fb7f6f00a3f4ca1a6d82cb761c4b5f02a10 Mon Sep 17 00:00:00 2001 From: owen Date: Wed, 22 Apr 2026 09:52:35 -0400 Subject: [PATCH 13/13] remove e2e changes --- .github/workflows/e2e.yml | 44 -- .gitignore | 6 - bun.lockb | Bin 362351 -> 360845 bytes e2e/app.spec.ts | 41 -- e2e/constants.ts | 8 - e2e/fixtures/8779815161.json | 486 ------------------ e2e/fixtures/8779834177.json | 456 ---------------- package.json | 6 +- playwright.config.ts | 36 -- scripts/e2e-seed.ts | 63 --- .../raidhub/instance/[instanceId]/route.ts | 34 -- src/lib/server/prisma.ts | 33 +- src/services/raidhub/common.ts | 32 +- .../raidhub/prefetchRaidHubManifest.ts | 22 - 14 files changed, 24 insertions(+), 1243 deletions(-) delete mode 100644 .github/workflows/e2e.yml delete mode 100644 e2e/app.spec.ts delete mode 100644 e2e/constants.ts delete mode 100644 e2e/fixtures/8779815161.json delete mode 100644 e2e/fixtures/8779834177.json delete mode 100644 playwright.config.ts delete mode 100644 scripts/e2e-seed.ts delete mode 100644 src/app/api/e2e/raidhub/instance/[instanceId]/route.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 80968b33..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: E2E - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - playwright: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Generate Prisma client - run: bun prisma generate - - - name: Apply migrations (local SQLite schema for APP_ENV=local) - run: bun prisma migrate deploy - env: - APP_ENV: local - - - name: Seed E2E multi (pgcr_multi row + fixed instance ids from e2e/constants.ts) - run: bun run scripts/e2e-seed.ts - env: - APP_ENV: local - - - name: Install Playwright Chromium - run: bunx playwright install chromium --with-deps - - - name: Run Playwright tests - run: bunx playwright test - env: - CI: true - APP_ENV: local - RAIDHUB_API_KEY: ${{ secrets.RAIDHUB_API_KEY }} diff --git a/.gitignore b/.gitignore index 475e5cfe..050500df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,6 @@ # testing /coverage -/playwright-report/ -/test-results/ -/blob-report/ -/playwright/.cache/ # next.js /.next/ @@ -55,5 +51,3 @@ src/app/test # certs certificates - -raidhub-sqlite.db diff --git a/bun.lockb b/bun.lockb index e6709b7d45486bec7a9d00aa59fabaef34d98f25..6e14e0a296cb2920769c9cc25cb04cc89992459b 100755 GIT binary patch delta 52157 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 S$WNJ(upqrGg^U{ { - test("home responds", async ({ page }) => { - const res = await page.goto("/") - expect(res?.ok()).toBeTruthy() - }) - - test("faq loads with content", async ({ page }) => { - await page.goto("/faq") - await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 30_000 }) - }) - - test("pgcr: invalid instance id shows not found", async ({ page }) => { - await page.goto("/pgcr/not-a-valid-instance-id", { waitUntil: "domcontentloaded" }) - await expect(page.getByText("Not found :(")).toBeVisible({ timeout: 30_000 }) - }) - - test("pgcr: resolved instance shows Summary", async ({ page }) => { - await page.goto(`/pgcr/${E2E_PGCR_INSTANCE_ID}`, { waitUntil: "domcontentloaded" }) - await expect(page.getByRole("heading", { name: "Summary", exact: false })).toBeVisible({ - timeout: 60_000 - }) - }) - - test("multi: merged PGCR shows Summary", async ({ page }) => { - const multiId = process.env.E2E_MULTI_ID ?? DEFAULT_E2E_MULTI_ID - await page.goto(`/multi/${multiId}`, { waitUntil: "domcontentloaded" }) - await expect(page.getByRole("heading", { name: "PGCR Multi-View" })).toBeVisible({ - timeout: 30_000 - }) - await expect(page.getByRole("heading", { name: "Summary", exact: false })).toBeVisible({ - timeout: 120_000 - }) - }) -}) diff --git a/e2e/constants.ts b/e2e/constants.ts deleted file mode 100644 index 9a33cd53..00000000 --- a/e2e/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stable RaidHub instance ids for E2E (historical Vault of Glass master team-first entries). - * Replace if RaidHub ever stops serving these PGCRs. - */ -export const E2E_PGCR_INSTANCE_ID = "8779815161" - -export const E2E_MULTI_INSTANCE_FIRST = "8779815161" -export const E2E_MULTI_INSTANCE_SECOND = "8779834177" diff --git a/e2e/fixtures/8779815161.json b/e2e/fixtures/8779815161.json deleted file mode 100644 index cc464645..00000000 --- a/e2e/fixtures/8779815161.json +++ /dev/null @@ -1,486 +0,0 @@ -{ - "instanceId": "8779815161", - "hash": 1681562271, - "activityId": 9, - "versionId": 4, - "completed": true, - "playerCount": 6, - "score": 0, - "fresh": null, - "flawless": false, - "skullHashes": [], - "dateStarted": "2021-07-06T17:10:04.000Z", - "dateCompleted": "2021-07-06T17:46:58.000Z", - "season": 14, - "duration": 2214, - "platformType": 3, - "isDayOne": false, - "isContest": false, - "isWeekOne": false, - "isBlacklisted": false, - "leaderboardRank": 1, - "metadata": { - "activityName": "Vault of Glass", - "versionName": "Master", - "isRaid": true - }, - "players": [ - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2214, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/d55829b357cade6477878058ef11eeae.jpg", - "lastSeen": "2022-08-25T21:27:53.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "vPoots", - "membershipId": "4611686018453262663", - "membershipType": 1, - "bungieGlobalDisplayName": "poots", - "bungieGlobalDisplayNameCode": "2264" - }, - "characters": [ - { - "kills": 225, - "score": 0, - "deaths": 3, - "assists": 102, - "weapons": [ - { - "kills": 52, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 19, - "weaponHash": 1097616550, - "precisionKills": 4 - }, - { - "kills": 18, - "weaponHash": 2222560548, - "precisionKills": 7 - }, - { - "kills": 9, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 5, - "weaponHash": 2990047042, - "precisionKills": 3 - }, - { - "kills": 4, - "weaponHash": 821154603, - "precisionKills": 2 - }, - { - "kills": 3, - "weaponHash": 2060863616, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 2133500855, - "meleeKills": 5, - "superKills": 66, - "characterId": "2305843009467534048", - "grenadeKills": 22, - "startSeconds": 0, - "precisionKills": 16, - "timePlayedSeconds": 2214 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2213, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/8f4020df5dd0ee175360576354eda082.jpg", - "lastSeen": "2026-02-07T23:38:35.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Praeyx", - "membershipId": "4611686018437165105", - "membershipType": 1, - "bungieGlobalDisplayName": "Prae", - "bungieGlobalDisplayNameCode": "2221" - }, - "characters": [ - { - "kills": 158, - "score": 0, - "deaths": 4, - "assists": 77, - "weapons": [ - { - "kills": 20, - "weaponHash": 2171478765, - "precisionKills": 6 - }, - { - "kills": 18, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 14, - "weaponHash": 1946491241, - "precisionKills": 0 - }, - { - "kills": 12, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 5, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 3, - "weaponHash": 2990047042, - "precisionKills": 3 - }, - { - "kills": 1, - "weaponHash": 2448907086, - "precisionKills": 0 - }, - { - "kills": 1, - "weaponHash": 2807687156, - "precisionKills": 1 - }, - { - "kills": 1, - "weaponHash": 981718087, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 3605490914, - "meleeKills": 0, - "superKills": 63, - "characterId": "2305843009267088647", - "grenadeKills": 11, - "startSeconds": 1, - "precisionKills": 20, - "timePlayedSeconds": 2213 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2213, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/70038c6d33c095f109a9df3f2bfc28de.jpg", - "lastSeen": "2022-10-19T21:42:11.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "iAmShuffles", - "membershipId": "4611686018451336648", - "membershipType": 2, - "bungieGlobalDisplayName": "Shuffles", - "bungieGlobalDisplayNameCode": "9737" - }, - "characters": [ - { - "kills": 150, - "score": 0, - "deaths": 3, - "assists": 86, - "weapons": [ - { - "kills": 27, - "weaponHash": 2222560548, - "precisionKills": 14 - }, - { - "kills": 18, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 17, - "weaponHash": 4230965989, - "precisionKills": 6 - }, - { - "kills": 10, - "weaponHash": 2990047042, - "precisionKills": 8 - }, - { - "kills": 9, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 6, - "weaponHash": 4248569242, - "precisionKills": 3 - }, - { - "kills": 3, - "weaponHash": 3211806999, - "precisionKills": 2 - }, - { - "kills": 3, - "weaponHash": 614426548, - "precisionKills": 0 - } - ], - "classHash": 671679327, - "completed": true, - "emblemHash": 4182480224, - "meleeKills": 0, - "superKills": 31, - "characterId": "2305843009686254957", - "grenadeKills": 15, - "startSeconds": 1, - "precisionKills": 44, - "timePlayedSeconds": 2213 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2213, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/97d1647338c17bdddc79bfac4ed01519.jpg", - "lastSeen": "2024-09-11T19:52:13.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Maso", - "membershipId": "4611686018467289542", - "membershipType": 3, - "bungieGlobalDisplayName": "Maso", - "bungieGlobalDisplayNameCode": "9958" - }, - "characters": [ - { - "kills": 264, - "score": 0, - "deaths": 3, - "assists": 99, - "weapons": [ - { - "kills": 92, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 54, - "weaponHash": 2222560548, - "precisionKills": 18 - }, - { - "kills": 14, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 9, - "weaponHash": 4248569242, - "precisionKills": 4 - }, - { - "kills": 8, - "weaponHash": 821154603, - "precisionKills": 4 - }, - { - "kills": 6, - "weaponHash": 2990047042, - "precisionKills": 6 - }, - { - "kills": 5, - "weaponHash": 2060863616, - "precisionKills": 0 - } - ], - "classHash": 3655393761, - "completed": true, - "emblemHash": 1907674139, - "meleeKills": 2, - "superKills": 10, - "characterId": "2305843009836114017", - "grenadeKills": 17, - "startSeconds": 1, - "precisionKills": 32, - "timePlayedSeconds": 2213 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2213, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/7fb53db4f4074e73b790c9277d75a75c.jpg", - "lastSeen": "2023-09-03T11:41:35.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Hizxr", - "membershipId": "4611686018471330827", - "membershipType": 3, - "bungieGlobalDisplayName": "Hizxr", - "bungieGlobalDisplayNameCode": "5854" - }, - "characters": [ - { - "kills": 227, - "score": 0, - "deaths": 5, - "assists": 120, - "weapons": [ - { - "kills": 23, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 17, - "weaponHash": 2222560548, - "precisionKills": 2 - }, - { - "kills": 14, - "weaponHash": 3186018373, - "precisionKills": 10 - }, - { - "kills": 11, - "weaponHash": 304659313, - "precisionKills": 0 - }, - { - "kills": 9, - "weaponHash": 4248569242, - "precisionKills": 4 - }, - { - "kills": 7, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 4, - "weaponHash": 370712896, - "precisionKills": 0 - }, - { - "kills": 3, - "weaponHash": 2990047042, - "precisionKills": 3 - }, - { - "kills": 2, - "weaponHash": 1481892490, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 3970180843, - "meleeKills": 1, - "superKills": 91, - "characterId": "2305843009607965794", - "grenadeKills": 26, - "startSeconds": 1, - "precisionKills": 20, - "timePlayedSeconds": 2213 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2212, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/517e5ab20f9372aa2eb4a1b2bfdb6fe3.jpg", - "lastSeen": "2026-04-04T10:20:46.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Panora", - "membershipId": "4611686018472402376", - "membershipType": 3, - "bungieGlobalDisplayName": "Panora", - "bungieGlobalDisplayNameCode": "8240" - }, - "characters": [ - { - "kills": 198, - "score": 0, - "deaths": 3, - "assists": 91, - "weapons": [ - { - "kills": 51, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 21, - "weaponHash": 3682803680, - "precisionKills": 8 - }, - { - "kills": 19, - "weaponHash": 304659313, - "precisionKills": 0 - }, - { - "kills": 7, - "weaponHash": 2222560548, - "precisionKills": 2 - }, - { - "kills": 7, - "weaponHash": 3717177717, - "precisionKills": 2 - }, - { - "kills": 6, - "weaponHash": 821154603, - "precisionKills": 3 - }, - { - "kills": 3, - "weaponHash": 1395261499, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 2133500854, - "meleeKills": 3, - "superKills": 56, - "characterId": "2305843009349344641", - "grenadeKills": 17, - "startSeconds": 2, - "precisionKills": 20, - "timePlayedSeconds": 2212 - } - ] - } - ] -} diff --git a/e2e/fixtures/8779834177.json b/e2e/fixtures/8779834177.json deleted file mode 100644 index 8fe2cd8c..00000000 --- a/e2e/fixtures/8779834177.json +++ /dev/null @@ -1,456 +0,0 @@ -{ - "instanceId": "8779834177", - "hash": 1681562271, - "activityId": 9, - "versionId": 4, - "completed": true, - "playerCount": 6, - "score": 0, - "fresh": null, - "flawless": false, - "skullHashes": [], - "dateStarted": "2021-07-06T17:06:11.000Z", - "dateCompleted": "2021-07-06T17:52:14.000Z", - "season": 14, - "duration": 2763, - "platformType": 3, - "isDayOne": false, - "isContest": false, - "isWeekOne": false, - "isBlacklisted": false, - "leaderboardRank": 2, - "metadata": { - "activityName": "Vault of Glass", - "versionName": "Master", - "isRaid": true - }, - "players": [ - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2763, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/07c13b7f1a0801fe7f37dd221c245f70.jpg", - "lastSeen": "2024-10-20T21:21:54.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Airborn", - "membershipId": "4611686018471162983", - "membershipType": 3, - "bungieGlobalDisplayName": "Airborn", - "bungieGlobalDisplayNameCode": "8179" - }, - "characters": [ - { - "kills": 466, - "score": 0, - "deaths": 10, - "assists": 171, - "weapons": [ - { - "kills": 198, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 78, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 73, - "weaponHash": 2171478765, - "precisionKills": 28 - }, - { - "kills": 23, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 18, - "weaponHash": 1946491241, - "precisionKills": 0 - }, - { - "kills": 15, - "weaponHash": 681067419, - "precisionKills": 11 - }, - { - "kills": 2, - "weaponHash": 3580904581, - "precisionKills": 0 - } - ], - "classHash": 3655393761, - "completed": true, - "emblemHash": 2133500855, - "meleeKills": 16, - "superKills": 12, - "characterId": "2305843009309634944", - "grenadeKills": 5, - "startSeconds": 0, - "precisionKills": 40, - "timePlayedSeconds": 2763 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2761, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/5e76df9deab0787741815ce5d4baf87b.jpg", - "lastSeen": "2026-04-11T05:54:20.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "Makro", - "membershipId": "4611686018471591897", - "membershipType": 3, - "bungieGlobalDisplayName": "Makro", - "bungieGlobalDisplayNameCode": "9053" - }, - "characters": [ - { - "kills": 227, - "score": 0, - "deaths": 14, - "assists": 132, - "weapons": [ - { - "kills": 54, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 39, - "weaponHash": 1946491241, - "precisionKills": 0 - }, - { - "kills": 27, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 21, - "weaponHash": 2171478765, - "precisionKills": 6 - }, - { - "kills": 15, - "weaponHash": 766323545, - "precisionKills": 2 - }, - { - "kills": 14, - "weaponHash": 681067419, - "precisionKills": 6 - }, - { - "kills": 11, - "weaponHash": 2990047042, - "precisionKills": 9 - }, - { - "kills": 2, - "weaponHash": 3186018373, - "precisionKills": 0 - } - ], - "classHash": 3655393761, - "completed": true, - "emblemHash": 2510169795, - "meleeKills": 3, - "superKills": 1, - "characterId": "2305843009307634550", - "grenadeKills": 13, - "startSeconds": 2, - "precisionKills": 24, - "timePlayedSeconds": 2761 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2761, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/1a5fe4dd02e08417395a423d6bd575e6.jpg", - "lastSeen": "2026-04-10T08:57:02.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "sacrifice", - "membershipId": "4611686018486225495", - "membershipType": 3, - "bungieGlobalDisplayName": "sacrifice", - "bungieGlobalDisplayNameCode": "4944" - }, - "characters": [ - { - "kills": 271, - "score": 0, - "deaths": 11, - "assists": 217, - "weapons": [ - { - "kills": 90, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 65, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 27, - "weaponHash": 681067419, - "precisionKills": 10 - }, - { - "kills": 22, - "weaponHash": 1946491241, - "precisionKills": 0 - }, - { - "kills": 9, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 9, - "weaponHash": 3037520408, - "precisionKills": 0 - }, - { - "kills": 5, - "weaponHash": 4184808992, - "precisionKills": 4 - }, - { - "kills": 2, - "weaponHash": 3487253372, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 1230660645, - "meleeKills": 0, - "superKills": 18, - "characterId": "2305843009425144851", - "grenadeKills": 4, - "startSeconds": 2, - "precisionKills": 14, - "timePlayedSeconds": 2761 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2713, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/4ccf74b4ccd7467b25919d6b7b4cf4dd.jpg", - "lastSeen": "2026-04-06T04:15:58.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "iiGamerr Connor", - "membershipId": "4611686018431899798", - "membershipType": 1, - "bungieGlobalDisplayName": "iiGamerr Connor", - "bungieGlobalDisplayNameCode": "1621" - }, - "characters": [ - { - "kills": 294, - "score": 0, - "deaths": 8, - "assists": 192, - "weapons": [ - { - "kills": 193, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 24, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 12, - "weaponHash": 2171478765, - "precisionKills": 5 - }, - { - "kills": 3, - "weaponHash": 1395261499, - "precisionKills": 0 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 2510169795, - "meleeKills": 3, - "superKills": 19, - "characterId": "2305843009263864933", - "grenadeKills": 9, - "startSeconds": 50, - "precisionKills": 5, - "timePlayedSeconds": 2713 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2631, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/eae7dcbb1996b21521ce26c5914af58f.jpg", - "lastSeen": "2026-04-06T04:11:09.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "bohdi", - "membershipId": "4611686018468621171", - "membershipType": 3, - "bungieGlobalDisplayName": "bohdi", - "bungieGlobalDisplayNameCode": "1165" - }, - "characters": [ - { - "kills": 227, - "score": 0, - "deaths": 11, - "assists": 142, - "weapons": [ - { - "kills": 61, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 53, - "weaponHash": 3186018373, - "precisionKills": 31 - }, - { - "kills": 20, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 16, - "weaponHash": 2060863616, - "precisionKills": 0 - }, - { - "kills": 15, - "weaponHash": 2222560548, - "precisionKills": 2 - }, - { - "kills": 6, - "weaponHash": 2990047042, - "precisionKills": 5 - }, - { - "kills": 3, - "weaponHash": 4281371574, - "precisionKills": 2 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 2510169795, - "meleeKills": 0, - "superKills": 22, - "characterId": "2305843009617404902", - "grenadeKills": 12, - "startSeconds": 132, - "precisionKills": 43, - "timePlayedSeconds": 2631 - } - ] - }, - { - "completed": true, - "isFirstClear": false, - "sherpas": 0, - "timePlayedSeconds": 2585, - "playerInfo": { - "iconPath": "/common/destiny2_content/icons/3c251b702026fee24488eac5cbd3a2e2.jpg", - "lastSeen": "2026-02-04T14:40:29.000Z", - "isPrivate": false, - "cheatLevel": 0, - "displayName": "argie", - "membershipId": "4611686018476362522", - "membershipType": 3, - "bungieGlobalDisplayName": "argie", - "bungieGlobalDisplayNameCode": "8521" - }, - "characters": [ - { - "kills": 262, - "score": 0, - "deaths": 7, - "assists": 197, - "weapons": [ - { - "kills": 63, - "weaponHash": 2376481550, - "precisionKills": 0 - }, - { - "kills": 55, - "weaponHash": 1946491241, - "precisionKills": 0 - }, - { - "kills": 36, - "weaponHash": 2171478765, - "precisionKills": 12 - }, - { - "kills": 10, - "weaponHash": 2990047042, - "precisionKills": 9 - }, - { - "kills": 8, - "weaponHash": 1395261499, - "precisionKills": 0 - }, - { - "kills": 4, - "weaponHash": 3504336176, - "precisionKills": 0 - }, - { - "kills": 1, - "weaponHash": 3186018373, - "precisionKills": 1 - } - ], - "classHash": 2271682572, - "completed": true, - "emblemHash": 2510169795, - "meleeKills": 5, - "superKills": 11, - "characterId": "2305843009411475001", - "grenadeKills": 44, - "startSeconds": 178, - "precisionKills": 22, - "timePlayedSeconds": 2585 - } - ] - } - ] -} diff --git a/package.json b/package.json index 90aaf4a0..e3caf124 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,7 @@ "format": "prettier --write \"src/**/*.{ts,tsx,json}\" && prisma format", "format:check": "prettier --check \"src/**/*.{ts,tsx,json}\"", "build": "bun prisma generate && next build", - "deploy": "./vercel/dev-deploy.sh", - "dev:e2e": "next dev -p 3000", - "test:e2e": "bun prisma migrate deploy && bun run scripts/e2e-seed.ts && playwright test", - "test:e2e:ui": "playwright test --ui" + "deploy": "./vercel/dev-deploy.sh" }, "dependencies": { "@auth/core": "0.32.0", @@ -82,7 +79,6 @@ "@libsql/linux-x64-gnu": "0.5.5", "@next/bundle-analyzer": "14.2.0-canary.26", "@next/eslint-plugin-next": "14.2.0-canary.26", - "@playwright/test": "^1.59.1", "@tanstack/react-query-devtools": "4.36", "@types/node": "20", "@types/react": "18.3.0", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 0cd941e5..00000000 --- a/playwright.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import "dotenv/config" - -import { defineConfig, devices } from "@playwright/test" - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 2 : undefined, - reporter: [["list"]], - timeout: 60_000, - use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000", - trace: "on-first-retry", - screenshot: "only-on-failure", - video: process.env.CI ? "retain-on-failure" : "off" - }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], - webServer: { - command: "bun run dev:e2e", - env: { - ...process.env, - APP_ENV: process.env.APP_ENV ?? "local", - AUTH_SECRET: - process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "e2e-auth-secret", - NEXTAUTH_SECRET: - process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET || "e2e-auth-secret" - }, - url: "http://127.0.0.1:3000", - reuseExistingServer: !process.env.CI, - timeout: 180_000, - stdout: "pipe", - stderr: "pipe" - } -}) diff --git a/scripts/e2e-seed.ts b/scripts/e2e-seed.ts deleted file mode 100644 index 48aa3496..00000000 --- a/scripts/e2e-seed.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Seeds local SQLite (APP_ENV=local) with a PgcrMulti + fixed instance ids — see e2e/constants.ts. - */ -import "dotenv/config" - -import { - E2E_MULTI_INSTANCE_FIRST, - E2E_MULTI_INSTANCE_SECOND -} from "../e2e/constants" -import { PrismaClient } from "@prisma/client" - -const DEFAULT_MULTI_ID = "01KNYVV8NXWK2P4188VNQZTZJM" -/** Placeholder owner — must exist as bungie_user for FK. */ -const SEED_OWNER_ID = "4611686018488107374" - -async function main() { - if (process.env.APP_ENV !== "local") { - console.log("e2e-seed: skipping (APP_ENV is not local)") - return - } - - const multiId = process.env.E2E_MULTI_ID ?? DEFAULT_MULTI_ID - const i1 = E2E_MULTI_INSTANCE_FIRST - const i2 = E2E_MULTI_INSTANCE_SECOND - - const prisma = new PrismaClient() - - await prisma.user.upsert({ - where: { id: SEED_OWNER_ID }, - create: { - id: SEED_OWNER_ID, - role_: "USER" - }, - update: {} - }) - - await prisma.pgcrMulti.upsert({ - where: { id: multiId }, - create: { - id: multiId, - ownerId: SEED_OWNER_ID, - name: "E2E seed multi", - instances: { - create: [{ instanceId: i1 }, { instanceId: i2 }] - } - }, - update: { - name: "E2E seed multi", - instances: { - deleteMany: {}, - create: [{ instanceId: i1 }, { instanceId: i2 }] - } - } - }) - - await prisma.$disconnect() - console.log("e2e-seed: ok", { multiId, instances: [i1, i2] }) -} - -main().catch(e => { - console.error(e) - process.exit(1) -}) diff --git a/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts b/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts deleted file mode 100644 index c94c3704..00000000 --- a/src/app/api/e2e/raidhub/instance/[instanceId]/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server" -import { readFile } from "node:fs/promises" -import path from "node:path" -import { type RaidHubInstanceExtended } from "~/services/raidhub/types" - -const fixturePath = (instanceId: string) => - path.resolve(process.cwd(), "e2e", "fixtures", `${instanceId}.json`) - -export async function GET(_request: Request, { params }: { params: { instanceId: string } }) { - try { - const fixture = await readFile(fixturePath(params.instanceId), "utf8") - const response = JSON.parse(fixture) as RaidHubInstanceExtended - - return NextResponse.json({ - minted: new Date().toISOString(), - success: true, - response - }) - } catch { - return NextResponse.json( - { - minted: new Date().toISOString(), - success: false, - code: "InstanceNotFoundError", - error: { - instanceId: params.instanceId - } - }, - { - status: 404 - } - ) - } -} diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts index 857c2640..dc7801a2 100644 --- a/src/lib/server/prisma.ts +++ b/src/lib/server/prisma.ts @@ -13,27 +13,24 @@ interface GlobalThisWithPrisma { const globalForPrisma = globalThis as unknown as GlobalThisWithPrisma const createPrismaWithExtension = () => { - const baseClient = + const libSQL = createClient( process.env.APP_ENV === "local" - ? new PrismaClient({ - log: ["query", "error", "warn"] - }) - : new PrismaClient({ - log: ["error"], - adapter: new PrismaLibSQL( - createClient({ - url: process.env.TURSO_DATABASE_URL!, - authToken: process.env.TURSO_AUTH_TOKEN, - fetch: async (request: Request) => { - return await saferFetch(request, { - cache: "no-store" - }) - } + ? { url: "file:./prisma/raidhub-sqlite.db" } + : { + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, + fetch: async (request: Request) => { + return await saferFetch(request, { + cache: "no-store" }) - ) - }) + } + } + ) - return baseClient.$extends({ + return new PrismaClient({ + log: process.env.APP_ENV === "local" ? ["query", "error", "warn"] : ["error"], + adapter: new PrismaLibSQL(libSQL) + }).$extends({ name: "roleEnum", result: { user: { diff --git a/src/services/raidhub/common.ts b/src/services/raidhub/common.ts index 0cea43ac..e923a07f 100644 --- a/src/services/raidhub/common.ts +++ b/src/services/raidhub/common.ts @@ -7,16 +7,6 @@ import type { import { RaidHubError } from "./RaidHubError" import type { paths } from "./openapi" -const getLocalE2EBaseUrl = () => { - if (typeof window !== "undefined") { - return window.location.origin - } - - return process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 3000}` -} - -const isLocalNoApiKey = () => process.env.APP_ENV === "local" && !process.env.RAIDHUB_API_KEY - export async function getRaidHubApi< T extends RaidHubGetPath, P = "parameters" extends keyof paths[T]["get"] ? paths[T]["get"]["parameters"] : null, @@ -32,20 +22,14 @@ export async function getRaidHubApi< config?: Omit, fetchFn?: typeof fetch ): Promise> { - const resolvedPath = path.replace(/{([^}]+)}/g, (_, paramName) => { - // @ts-expect-error types don't really work here - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access - return pathParams[paramName] - }) - - const url = - isLocalNoApiKey() && path === "/instance/{instanceId}" - ? new URL( - `/api/e2e/raidhub/instance/${resolvedPath.split("/").at(-1)}`, - getLocalE2EBaseUrl() - ) - : new URL(resolvedPath, process.env.RAIDHUB_API_URL) - + const url = new URL( + path.replace(/{([^}]+)}/g, (_, paramName) => { + // @ts-expect-error types don't really work here + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return pathParams[paramName] + }), + process.env.RAIDHUB_API_URL + ) Object.entries(queryParams ?? {}).forEach(([key, value]) => { if (value !== undefined) url.searchParams.set(key, String(value)) }) diff --git a/src/services/raidhub/prefetchRaidHubManifest.ts b/src/services/raidhub/prefetchRaidHubManifest.ts index 44524a56..6849309b 100644 --- a/src/services/raidhub/prefetchRaidHubManifest.ts +++ b/src/services/raidhub/prefetchRaidHubManifest.ts @@ -3,29 +3,7 @@ import { reactRequestDedupe } from "~/util/react-cache" import { getRaidHubApi } from "./common" import { type RaidHubManifestResponse } from "./types" -const localFallbackManifest: RaidHubManifestResponse = { - hashes: {}, - activityDefinitions: {}, - versionDefinitions: {}, - listedRaidIds: [], - sunsetRaidIds: [], - prestigeRaidIds: [], - masterRaidIds: [], - contestRaidIds: [], - resprisedRaidIds: [], - resprisedChallengeVersionIds: [], - pantheonIds: [], - versionsForActivity: {}, - rankingTiers: [], - feats: [], - splashUrls: {} -} - export const prefetchManifest = reactRequestDedupe(async (): Promise => { - if (process.env.APP_ENV === "local" && !process.env.RAIDHUB_API_KEY) { - return localFallbackManifest - } - return await getRaidHubApi( "/manifest", null,