This commit is contained in:
Linrador 2025-09-03 15:01:53 +02:00
parent f1773a0924
commit 728b5cb6f6
23 changed files with 834 additions and 218 deletions

78
package-lock.json generated
View File

@ -15,7 +15,7 @@
"@fortawesome/fontawesome-free": "^7.0.0",
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.14.0",
"@prisma/client": "^6.15.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
@ -61,7 +61,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.14.0",
"prisma": "^6.15.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",
@ -1599,9 +1599,9 @@
"license": "Licensed under MIT and Preline UI Fair Use License"
},
"node_modules/@prisma/client": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz",
"integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz",
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -1621,9 +1621,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -1634,53 +1634,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/fetch-engine": "6.14.0",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/fetch-engine": "6.15.0",
"@prisma/get-platform": "6.15.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/get-platform": "6.15.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0"
"@prisma/debug": "6.15.0"
}
},
"node_modules/@rtsao/scc": {
@ -6663,9 +6663,9 @@
}
},
"node_modules/pkg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -6781,15 +6781,15 @@
"peer": true
},
"node_modules/prisma": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.14.0",
"@prisma/engines": "6.14.0"
"@prisma/config": "6.15.0",
"@prisma/engines": "6.15.0"
},
"bin": {
"prisma": "build/index.js"

View File

@ -19,7 +19,7 @@
"@fortawesome/fontawesome-free": "^7.0.0",
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.14.0",
"@prisma/client": "^6.15.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
@ -65,7 +65,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.14.0",
"prisma": "^6.15.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",

View File

@ -323,6 +323,8 @@ model MapVote {
currentIdx Int @default(0)
locked Boolean @default(false)
opensAt DateTime?
leadMinutes Int @default(60)
adminEditingBy String?
adminEditingSince DateTime?

View File

@ -66,7 +66,7 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
] as const
}
function shapeState(vote: any) {
function shapeState(vote: any, match?: any) {
const steps = [...vote.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
@ -78,20 +78,24 @@ function shapeState(vote: any) {
chosenBy: s.chosenBy ?? null,
}))
const opensAtDate = vote.opensAt ? new Date(vote.opensAt) : null
const baseDate = match?.matchDate ?? match?.demoDate ?? null
const leadMinutes =
opensAtDate && baseDate
? Math.max(0, Math.round((new Date(baseDate).getTime() - opensAtDate.getTime()) / 60000))
: null
return {
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
opensAt : opensAtDate ? opensAtDate.toISOString() : null,
leadMinutes,
steps,
// Admin-Edit Shape
adminEdit: vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
? { enabled: true, by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null }
: { enabled: false, by: null, since: null },
}
}
@ -418,7 +422,7 @@ function deriveChosenSteps(vote: any) {
export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) {
try {
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
@ -427,7 +431,7 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
const mapVisuals = buildMapVisuals(match.id, vote.mapPool)
return NextResponse.json(
{ ...shapeState(vote), mapVisuals, teams },
{ ...shapeState(vote, match), mapVisuals, teams }, // 👈 match mitgeben
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (e) {
@ -461,13 +465,16 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}
const updated = await setAdminEdit(vote.id, body.adminEdit ? me.steamId : null)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
/* -------- Wenn anderer Admin editiert: Voting sperren -------- */
@ -492,7 +499,10 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } })
const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
@ -519,7 +529,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
const available = computeAvailableMaps(vote.mapPool, stepsSorted)
@ -546,7 +556,10 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
include: { steps: true },
})
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
@ -573,7 +586,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
// Rechte prüfen (Admin oder Leader des Teams am Zug)
@ -639,7 +652,10 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
include: { steps: true },
})
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
@ -665,7 +681,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
} catch (e) {
console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })

View File

@ -1,18 +1,76 @@
// /app/api/matches/[id]/meta/route.ts
// /app/api/matches/[matchId]/meta/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
const id = params.matchId
export const runtime = 'nodejs' // 👈 wie bei mapvote
export const dynamic = 'force-dynamic' // 👈 wie bei mapvote
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined
function parseDateOrNull(v: unknown): Date | null | undefined {
if (typeof v === 'undefined') return undefined
if (v === null) return null
if (typeof v === 'number' && Number.isFinite(v)) {
const ms = v >= 1e12 ? v : v * 1000
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? undefined : d
}
if (typeof v === 'string') {
const s = v.trim()
if (s === '') return null
const hasTZ = /[zZ]|[+\-]\d{2}:\d{2}$/.test(s)
if (!hasTZ) {
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (mDate) {
const d = new Date(+mDate[1], +mDate[2] - 1, +mDate[3], 0, 0, 0, 0)
return Number.isNaN(d.getTime()) ? undefined : d
}
const mDateTime = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(s)
if (mDateTime) {
const d = new Date(
+mDateTime[1], +mDateTime[2] - 1, +mDateTime[3],
+mDateTime[4], +mDateTime[5], mDateTime[6] ? +mDateTime[6] : 0, 0
)
return Number.isNaN(d.getTime()) ? undefined : d
}
}
const t = Date.parse(s)
if (!Number.isNaN(t)) return new Date(t)
return undefined
}
if (v instanceof Date && !Number.isNaN(v.getTime())) return v
return undefined
}
// wie in mapvote: Basiszeit -> opensAt
function voteOpensAt(base: Date, leadMinutes: number) {
return new Date(base.getTime() - leadMinutes * 60_000)
}
export async function PUT(
req: NextRequest,
{ params }: { params: { matchId: string } }
) {
const id = params?.matchId
if (!id) return NextResponse.json({ error: 'Missing matchId in route params' }, { status: 400 })
const session = await getServerSession(authOptions(req))
const me = session?.user
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { title, matchType, teamAId, teamBId, matchDate, map, voteLeadMinutes } = body ?? {}
const {
title,
matchType,
teamAId,
teamBId,
matchDate,
map,
voteLeadMinutes, // optional
demoDate,
} = body ?? {}
try {
const match = await prisma.match.findUnique({
@ -32,24 +90,38 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// 1) Matching-Daten zusammenbauen
const updateData: any = {}
if (typeof title !== 'undefined') updateData.title = title
if (typeof matchType === 'string') updateData.matchType = matchType
if (typeof map !== 'undefined') updateData.map = map
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
if (typeof matchDate === 'string' || matchDate === null) {
updateData.matchDate = matchDate ? new Date(matchDate) : null
const parsedMatchDate = parseDateOrNull(matchDate)
if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate
const parsedDemoDate = parseDateOrNull(demoDate)
if (parsedDemoDate !== undefined) {
updateData.demoDate = parsedDemoDate
} else if (parsedMatchDate instanceof Date) {
// demoDate mitziehen, wenn matchDate geändert und demoDate nicht gesendet wurde
updateData.demoDate = parsedMatchDate
}
const lead = Number.isFinite(Number(voteLeadMinutes)) ? Number(voteLeadMinutes) : 60
let opensAt: Date | null = null
if (updateData.matchDate instanceof Date) {
opensAt = new Date(updateData.matchDate.getTime() - lead * 60 * 1000)
} else if (match.matchDate) {
opensAt = new Date(match.matchDate.getTime() - lead * 60 * 1000)
}
// 2) Lead bestimmen (Body > gespeicherter Wert > default 60)
const leadBodyRaw = Number(voteLeadMinutes)
const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined
const currentLead = match.mapVote?.leadMinutes ?? 60 // erfordert Feld im Schema
const leadMinutes = leadBody ?? currentLead
// 3) Basiszeit (neu oder alt)
const baseDate: Date | null =
(updateData.matchDate instanceof Date ? updateData.matchDate : null) ??
(match.matchDate ?? null) ??
(match.demoDate ?? null)
// 4) Updaten & opensAt ggf. neu setzen analog zu mapvote
const updated = await prisma.$transaction(async (tx) => {
const m = await tx.match.update({
where: { id },
@ -57,21 +129,34 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
include: { mapVote: true },
})
if (opensAt) {
// Wenn wir eine Basiszeit haben → opensAt neu berechnen
if (baseDate) {
const opensAt = voteOpensAt(baseDate, leadMinutes)
if (!m.mapVote) {
// MapVote existiert noch nicht → nur opensAt/leadMinutes anlegen (Schritte erstellt get /mapvote)
await tx.mapVote.create({
data: {
matchId: m.id,
leadMinutes: leadMinutes,
opensAt,
// entferne Felder, die es im Schema nicht gibt (z. B. isOpen)
},
})
} else {
await tx.mapVote.update({
where: { id: m.mapVote.id },
data: { opensAt },
data: {
...(leadBody !== undefined ? { leadMinutes: leadMinutes } : {}),
opensAt,
},
})
}
} else if (leadBody !== undefined && m.mapVote) {
// Keine Basiszeit-Änderung, aber Lead explizit gesetzt → nur leadMinutes persistieren
await tx.mapVote.update({
where: { id: m.mapVote.id },
data: { leadMinutes: leadMinutes },
})
}
return tx.match.findUnique({
@ -86,16 +171,32 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
// 5) Events senden Shape identisch zur mapvote-Route
// a) Für das Voting/Countdown: map-vote-updated (liefert opensAt)
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: {
matchId: updated.id,
opensAt: updated.mapVote?.opensAt ?? null, // JSON.stringify -> ISO
},
})
// b) Zusätzlich Meta-Event für andere UIs
await sendServerSSEMessage({
type: 'match-meta-updated',
payload: {
matchId: updated.id,
updatedAt: new Date().toISOString(),
},
})
// (Optional) allgemeines match-updated, falls du andere Bereiche triggern willst
await sendServerSSEMessage({
type: 'match-updated',
payload: { matchId: updated.id, updatedAt: new Date().toISOString() },
})
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId: updated.id, opensAt: updated.mapVote?.opensAt ?? null },
})
// 6) Response (no-store)
return NextResponse.json({
id: updated.id,
title: updated.title,
@ -103,6 +204,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
teamAId: updated.teamAId,
teamBId: updated.teamBId,
matchDate: updated.matchDate,
demoDate: updated.demoDate,
map: updated.map,
mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } })
@ -110,4 +212,4 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
console.error(`PUT /matches/${id}/meta failed:`, err)
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 })
}
}
}

View File

@ -24,6 +24,14 @@ export async function GET(_: Request, { params }: { params: { matchId: string }
players: { include: { user: true, stats: true, team: true } },
teamAUsers: { include: { team: true } },
teamBUsers: { include: { team: true } },
mapVote: {
include: {
steps: {
orderBy: { order: 'asc' },
select: { order: true, action: true, map: true, teamId: true, chosenAt: true, chosenBy: true },
},
},
},
},
})
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
@ -31,9 +39,24 @@ export async function GET(_: Request, { params }: { params: { matchId: string }
const payload =
m.matchType === 'community' && isFuture(m)
? await buildCommunityFuturePayload(m)
: buildDefaultPayload(m)
: buildDefaultPayload(m);
return NextResponse.json(payload, { headers: { 'Cache-Control': 'no-store' } })
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null;
const opensAt = m.mapVote?.opensAt ?? null;
const leadMinutes =
opensAt && baseTs != null
? Math.max(0, Math.round((baseTs - opensAt.getTime()) / 60000))
: null;
return NextResponse.json({
...payload,
mapVote: {
...(payload as any).mapVote,
opensAt, // <- wichtig
leadMinutes, // optional, aber nett zu haben
},
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
console.error(`GET /matches/${params.matchId} failed:`, err)
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
@ -184,6 +207,14 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
players: { include: { user: true, stats: true, team: true } },
teamAUsers: { include: { team: true } },
teamBUsers: { include: { team: true } },
mapVote: {
include: {
steps: {
orderBy: { order: 'asc' },
select: { order: true, action: true, map: true, teamId: true, chosenAt: true, chosenBy: true },
},
},
},
},
})
if (!updated) return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })

View File

@ -5,7 +5,7 @@ import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { format } from 'date-fns'
import { format } from 'date-fns' // 👈 neu
import { de } from 'date-fns/locale'
import Switch from '@/app/components/Switch'
import Button from './Button'
@ -41,6 +41,20 @@ function getNextHourDefaults() {
return { dateStr, timeStr }
}
// 👇 Helper für Map-Vote-Status
function getMapVoteState(m: Match, nowMs: number) {
const opensAt = m?.mapVote?.opensAt ? new Date(m.mapVote.opensAt) : null
const locked = m?.mapVote?.locked ?? false
if (!opensAt) return { hasVote: false as const }
const opensAtMs = opensAt.getTime()
const isOpen = !locked && opensAtMs <= nowMs
const opensInMs = Math.max(0, opensAtMs - nowMs) // nie negativ
return { hasVote: true as const, isOpen, opensAt, opensInMs }
}
export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession()
const router = useRouter()
@ -71,6 +85,13 @@ export default function CommunityMatchList({ matchType }: Props) {
[teams]
)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const id = setInterval(() => {
// force re-render, damit isOpen (Vergleich mit Date.now) neu bewertet wird
@ -252,52 +273,85 @@ export default function CommunityMatchList({ matchType }: Props) {
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished
const isOwnTeam = !!session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
const dimmed = onlyOwn && !isOwnTeam
// 👇 Map-Vote Status berechnen
const mv = getMapVoteState(m, now)
const opensText =
mv.hasVote && !mv.isOpen && mv.opensAt
? `öffnet in ${formatCountdown(mv.opensInMs)}`
: null
return (
<Link
key={m.id}
href={`/match-details/${m.id}`}
className={`
relative flex flex-col items-center gap-4 bg-neutral-300 dark:bg-neutral-800
text-gray-800 dark:text-white rounded-sm py-4
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700
hover:shadow-md h-[172px]
grid grid-rows-[auto_1fr_auto] justify-items-center gap-3
bg-neutral-300 dark:bg-neutral-800 text-gray-800 dark:text-white
rounded-sm p-4 min-h-[210px]
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700 hover:shadow-md
transition-transform transition-opacity duration-300 ease-in-out
${dimmed ? 'opacity-40' : 'opacity-100'}
`}
>
{/* Live / Map-Vote Badge */}
{isLive ? (
<span className="absolute top-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-red-500 text-white shadow">
LIVE
</span>
) : (m.mapVote?.isOpen ? (
<span className="absolute top-2 px-2 py-0.5 text-[11px] font-semibold rounded-full bg-green-600 text-white shadow">
Map-Vote offen
</span>
) : null
)
}
{/* Zeile 1: Badges (immer Platz, auch wenn leer) */}
<div className="flex flex-col items-center gap-1 min-h-6">
{isLive ? (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-500 text-white shadow">
LIVE
</span>
) : mv.hasVote && (mv.isOpen || opensText) ? (
<>
{mv.isOpen && (
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-green-600 text-white shadow">
Map-Vote offen
</span>
)}
{!mv.isOpen && opensText && (
<span
title={mv.opensAt?.toLocaleString('de-DE') ?? undefined}
className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-300 text-gray-900 dark:bg-yellow-500 dark:text-black shadow"
>
Map-Vote {opensText}
</span>
)}
</>
) : null}
</div>
{/* Zeile 2: Teams */}
<div className="flex w-full justify-around items-center">
<div className="flex flex-col items-center w-1/3">
<Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
<span className="mt-2 text-xs">{m.teamA.name}</span>
<Image
src={getTeamLogo(m.teamA.logo)}
alt={m.teamA.name}
width={56}
height={56}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs text-center line-clamp-1">{m.teamA.name}</span>
</div>
<span className="font-bold">vs</span>
<div className="flex flex-col items-center w-1/3">
<Image src={getTeamLogo(m.teamB.logo)} alt={m.teamB.name} width={48} height={48} className="rounded-full border bg-white" />
<span className="mt-2 text-xs">{m.teamB.name}</span>
<Image
src={getTeamLogo(m.teamB.logo)}
alt={m.teamB.name}
width={56}
height={56}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs text-center line-clamp-1">{m.teamB.name}</span>
</div>
</div>
{/* Datum + Uhrzeit: höher & highlight */}
{/* Zeile 3: Datum & Uhrzeit */}
<div className="flex flex-col items-center -mt-1 space-y-1">
<span
className={`px-3 py-1 rounded-full text-[13px] font-bold shadow ring-1 ring-black/10
${isLive ? 'bg-red-500 text-white' : 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-black'}
`}
${isLive ? 'bg-red-500 text-white' : 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-black'}
`}
>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span>
@ -441,3 +495,13 @@ export default function CommunityMatchList({ matchType }: Props) {
</div>
)
}
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}

View File

@ -157,8 +157,10 @@ export default function EditMatchMetaModal({
}
setSaved(true)
onSaved?.()
onClose()
setTimeout(() => {
onSaved?.()
}, 0)
} catch (e: any) {
console.error('[EditMatchMetaModal] save error:', e)
setError(e?.message || 'Speichern fehlgeschlagen')
@ -167,8 +169,6 @@ export default function EditMatchMetaModal({
}
}
if (!show) return null
// Platzhalter mit aktuellem Namen (falls Options noch laden)
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
@ -177,7 +177,7 @@ export default function EditMatchMetaModal({
<Modal
id="edit-match-meta"
title="Matchdaten bearbeiten"
show
show={show}
onClose={onClose}
onSave={handleSave}
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'}

View File

@ -149,10 +149,7 @@ export default function EditMatchPlayersModal (props: Props) {
try {
const body = {
players: [
/* akt. Auswahl für die bearbeitete Seite */
...selected.map(steamId => ({ steamId, teamId: team.id })),
/* unveränderte Gegenseite unbedingt mitschicken! */
...otherInit.map(steamId => ({ steamId, teamId: other.id })),
],
}
@ -165,7 +162,13 @@ export default function EditMatchPlayersModal (props: Props) {
if (!res.ok) throw new Error()
setSaved(true)
onSaved?.()
// ⏳ 3 Sekunden warten, dann schließen und danach refreshen
setTimeout(() => {
onClose?.()
// onSaved (z. B. router.refresh) im nächsten Tick nach dem Schließen
setTimeout(() => { onSaved?.() }, 0)
}, 1500)
} catch (e) {
console.error('[EditMatchPlayersModal] save error:', e)
} finally {
@ -173,18 +176,18 @@ export default function EditMatchPlayersModal (props: Props) {
}
}
/* ---- Listen trennen ------------------------------------- */
const active = players.filter(p => selected.includes(p.steamId))
const inactive = players.filter(p => !selected.includes(p.steamId))
/* ---- UI -------------------------------------------------- */
if (!show) return null
return (
<Modal
id="edit-match-players"
title={`Spieler bearbeiten ${team.name ?? 'Team'}`}
show
show={show}
onClose={onClose}
onSave={handleSave}
closeButtonTitle={

View File

@ -1,24 +1,52 @@
// MapVoteBanner.tsx
// /app/components/MapVoteBanner.tsx
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { MapVoteState } from '../types/mapvote'
import { MATCH_EVENTS } from '@/app/lib/sseEvents'
type Props = { match: any; initialNow: number }
type Props = {
match: any
initialNow: number
matchBaseTs: number | null
sseOpensAtTs?: number | null
sseLeadMinutes?: number | null
}
export default function MapVoteBanner({ match, initialNow }: Props) {
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}
function formatLead(minutes: number) {
if (!Number.isFinite(minutes) || minutes <= 0) return 'zum Matchbeginn'
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h > 0 && m > 0) return `${h}h ${m}min`
if (h > 0) return `${h}h`
return `${m}min`
}
export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes }: Props) {
const router = useRouter()
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
// ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server)
const [now, setNow] = useState(initialNow)
const [state, setState] = useState<MapVoteState | null>(null)
const [error, setError] = useState<string | null>(null)
const [leadOverride, setLeadOverride] = useState<number | null>(null);
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
// deterministische Hydration + 1s-Ticker
const [now, setNow] = useState(initialNow)
const load = useCallback(async () => {
try {
@ -36,33 +64,90 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
setError(e?.message ?? 'Unbekannter Fehler')
}
}, [match.id])
const matchDateTs = useMemo(
() => (typeof matchBaseTs === 'number' ? matchBaseTs : null),
[matchBaseTs]
)
// ✅ tickt NUR im Client, nach Hydration
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// initial
useEffect(() => { load() }, [load])
// Live-Refresh via SSE
// 🔁 Neu laden, wenn Match-Metadaten (z. B. matchDate/bestOf) sich durch refresh ändern
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
// 🔁 Live-Refresh via SSE
useEffect(() => {
if (!lastEvent) return
// ⬇️ reagiert auf alle match-bezogenen Events: vote-updated, admin-edit, reset, ready, lineup, ...
if (!MATCH_EVENTS.has(lastEvent.type)) return
const { type } = lastEvent as any
const evt = (lastEvent as any).payload ?? lastEvent
if (evt?.matchId !== match.id) return
const matchId = lastEvent.payload?.matchId
if (matchId !== match.id) return
const RELOAD_TYPES = new Set([
'map-vote-updated','map-vote-reset','map-vote-locked','map-vote-unlocked',
'match-updated','match-lineup-updated',
])
if (!RELOAD_TYPES.has(type)) return
load()
}, [lastEvent, match.id, load])
const rawLead = evt?.leadMinutes
const parsedLead = (rawLead !== undefined && rawLead !== null) ? Number(rawLead) : undefined
const nextOpensAtISO =
evt?.opensAt
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
: undefined
// Öffnet 1h vor Match-/Demotermin (stabil, ohne Date.now() im Render)
// sofortige lokale Overrides, ohne auf fetch zu warten
if (nextOpensAtISO) {
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
} else if (Number.isFinite(parsedLead) && matchDateTs != null) {
setOpensAtOverride(matchDateTs - (parsedLead as number) * 60_000)
}
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
// sichtbares Mergen (für UI-Text)
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
setState(prev => ({
...(prev ?? {} as any),
...(nextOpensAtISO !== undefined ? { opensAt: nextOpensAtISO } : {}),
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
}) as any)
} else {
// nur nachladen, wenn Event keine konkreten Werte trug
load()
}
}, [lastEvent, match.id, matchDateTs, load])
// Öffnet wann?
const opensAt = useMemo(() => {
// höchste Priorität: vom Parent (MatchDetails) gereichter TS
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
// dann lokaler SSE-Override
if (opensAtOverride != null) return opensAtOverride
// dann Serverwert aus /mapvote
if (state?.opensAt) return new Date(state.opensAt).getTime()
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow)
return base.getTime() - 60 * 60 * 1000
}, [state?.opensAt, match.matchDate, match.demoDate, initialNow])
// Fallback aus Basis + Lead
if (matchDateTs == null) return new Date(initialNow).getTime()
const lead = (typeof sseLeadMinutes === 'number')
? sseLeadMinutes
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? state!.leadMinutes as number : 60))
return matchDateTs - lead * 60_000
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
// Text „startet X vor Matchbeginn“
const leadMinutes = useMemo(() => {
if (matchDateTs != null && opensAt != null) {
return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
}
if (typeof sseLeadMinutes === 'number') return sseLeadMinutes
if (leadOverride != null) return leadOverride
if (Number.isFinite(state?.leadMinutes)) return state!.leadMinutes as number
return 60
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
const isOpen = now >= opensAt
const msToOpen = Math.max(opensAt - now, 0)
@ -72,7 +157,6 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
: null
// ⚠️ leader ist bei dir ein Player-Objekt → .steamId vergleichen
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId
const isAdmin = !!session?.user?.isAdmin
@ -128,7 +212,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
? ' • Auswahl fixiert'
: isOpen
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
: ' • startet 1h vor Matchbeginn'}
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
</div>
{error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-0.5">
@ -219,14 +303,4 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
`}</style>
</div>
)
}
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}
}

View File

@ -1,3 +1,4 @@
// /app/components/MapVotePanel.tsx
'use client'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
@ -35,6 +36,20 @@ const fmtCountdown = (ms: number) => {
return `${h}:${pad(m)}:${pad(s)}`
}
const toMs = (v: unknown): number | null => {
if (v == null) return null
if (typeof v === 'number' && Number.isFinite(v)) return v >= 1e12 ? v : v * 1000
if (typeof v === 'string') {
const t = Date.parse(v)
return Number.isNaN(t) ? null : t
}
if (v instanceof Date) {
const t = v.getTime()
return Number.isNaN(t) ? null : t
}
return null
}
/* =================== Component =================== */
export default function MapVotePanel({ match }: Props) {
@ -50,18 +65,13 @@ export default function MapVotePanel({ match }: Props) {
const [error, setError] = useState<string | null>(null)
const [adminEditMode, setAdminEditMode] = useState(false)
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null);
const [leadOverride, setLeadOverride] = useState<number | null>(null);
/* -------- Timers / open window -------- */
const opensAtTs = useMemo(() => {
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000
}, [match.matchDate, match.demoDate])
const [nowTs, setNowTs] = useState(() => Date.now())
useEffect(() => {
const t = setInterval(() => setNowTs(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? Date.now();
return new Date(raw).getTime();
}, [match.matchDate, match.demoDate]);
/* -------- Overlay integration -------- */
const overlayIsForThisMatch = overlayData?.matchId === match.id
@ -107,14 +117,115 @@ export default function MapVotePanel({ match }: Props) {
}
}, [match.id])
useEffect(() => { load() }, [load])
useEffect(() => { load() }, [load, match.matchDate, match.demoDate])
useEffect(() => {
if (!lastEvent) return
if (!MATCH_EVENTS.has(lastEvent.type)) return
if (lastEvent.payload?.matchId !== match.id) return
load()
}, [lastEvent, match.id, load])
setOpensAtOverride(null)
}, [match.matchDate, match.demoDate])
// 🔔 SSE: wie in MatchDetails — opensAt/leadMinutes direkt aus dem Event übernehmen
useEffect(() => {
console.log("lastEvent: ", lastEvent);
if (!lastEvent) return;
const { type, payload } = lastEvent as any;
const evt = payload ?? lastEvent;
const evtMatchId = evt?.matchId ?? (lastEvent as any)?.matchId;
if (evtMatchId !== match.id) return;
if (type === 'map-vote-updated' || type === 'match-meta-updated') {
// 1) opensAt aus Event direkt übernehmen (ISO → ms)
if (evt?.opensAt) {
const ts =
typeof evt.opensAt === 'string'
? new Date(evt.opensAt).getTime()
: new Date(evt.opensAt).getTime();
if (Number.isFinite(ts)) setOpensAtOverride(ts);
}
// 2) leadMinutes mitschneiden und ggf. opensAt daraus ableiten,
// falls im Event kein opensAt enthalten war
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes);
setLeadOverride(lead);
if (!evt?.opensAt) {
const base = new Date(
match.matchDate ?? match.demoDate ?? Date.now()
).getTime();
setOpensAtOverride(base - lead * 60_000);
}
}
// WICHTIG: Kein load()/refresh hier genau wie in MatchDetails
return;
}
// Refresh bei Events, die Meta/Lineup betreffen wie in MatchDetails
const REFRESH_TYPES = new Set([
'map-vote-reset',
'map-vote-locked',
'map-vote-unlocked',
'match-updated',
'match-lineup-updated',
'match-meta-updated',
]);
if (REFRESH_TYPES.has(type)) {
// analog zu MatchDetails: UI sanft aktualisieren
router.refresh?.();
return;
}
// Fallback: bekannte Match-Events → Daten nachladen
if (MATCH_EVENTS.has(type)) load();
}, [lastEvent, match.id, match.matchDate, match.demoDate, router, load]);
// 📅 Öffnungszeit robust ableiten (Server -> SSE-Override -> Lead-Fallback)
const openTs = useMemo(() => {
if (opensAtOverride != null) return opensAtOverride
const srv = toMs(state?.opensAt)
if (srv != null) return srv
const lead = leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000
}, [opensAtOverride, state?.opensAt, state?.leadMinutes, leadOverride, matchBaseTs])
// --- Hydration-Schutz ---
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// --- Countdown-State: NICHT mit Date.now() initialisieren ---
const [msLeft, setMsLeft] = useState<number>(0)
useEffect(() => {
if (!Number.isFinite(openTs)) console.warn('[MapVotePanel] openTs invalid:', openTs, { state, opensAtOverride, leadOverride, matchBaseTs })
}, [openTs, state, opensAtOverride, leadOverride, matchBaseTs])
// --- Intervall erst nach Mount starten, an Sekundengrenze ausrichten ---
useEffect(() => {
if (!mounted) return
if (!Number.isFinite(openTs)) { setMsLeft(0); return }
const update = () => setMsLeft(Math.max(openTs - Date.now(), 0))
update()
const drift = 1000 - (Date.now() % 1000)
let intervalId: number | null = null
const timeoutId = window.setTimeout(() => {
update()
intervalId = window.setInterval(update, 1000)
}, drift)
return () => {
window.clearTimeout(timeoutId)
if (intervalId) window.clearInterval(intervalId)
}
}, [openTs, mounted])
// --- Ab hier nur noch msLeft benutzen ---
const isOpen = mounted && msLeft <= 0
const msToOpen = msLeft
/* -------- Admin-Edit Mirror -------- */
const adminEditingBy = state?.adminEdit?.by ?? null
@ -125,14 +236,6 @@ export default function MapVotePanel({ match }: Props) {
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
/* -------- Derived flags & memoized maps -------- */
const opensAt = useMemo(
() => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
[state?.opensAt]
)
const isOpenFromMatch = nowTs >= opensAtTs
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
const me = session?.user
const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId
@ -477,7 +580,7 @@ export default function MapVotePanel({ match }: Props) {
</div>
{/* Countdown / Status */}
<div className="mb-4">
<div className="mb-4" key={openTs}>
<div className="mx-auto w-full max-w-xl">
<div className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
<div className="w-10 h-10" />
@ -519,8 +622,11 @@ export default function MapVotePanel({ match }: Props) {
</span>
)
) : (
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center">
Öffnet in {fmtCountdown(msToOpen)}
<span
className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center"
suppressHydrationWarning
>
Öffnet in {mounted ? fmtCountdown(msToOpen) : '::'}
</span>
)}
</div>

View File

@ -1,9 +1,5 @@
/*
/app/components/MatchDetails.tsx
- Zeigt pro Team einen eigenen Spieler bearbeiten-Button
- Öffnet das Modal nur für das angeklickte Team
- Reagiert auf SSE-Events (match-lineup-updated / matches-updated)
*/
// /app/components/MatchDetails.tsx
'use client'
import { useState, useEffect, useMemo } from 'react'
@ -48,6 +44,107 @@ const adr = (dmg?: number, rounds?: number) =>
const normalizeMapKey = (raw?: string) =>
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
type VoteStep = { order: number; action: VoteAction; map?: string | null }
const mapLabelFromKey = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
return (
MAP_OPTIONS.find(o => o.key === k)?.label ??
(k ? k : 'TBD')
)
}
// Maps aus dem MapVote (nur PICK/DECIDER, sortiert)
function extractSeriesMaps(match: Match): string[] {
const steps = (match.mapVote?.steps ?? []) as unknown as VoteStep[]
const picks = steps
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
.map(s => s.map ?? '')
// auf bestOf begrenzen
const n = Math.max(1, match.bestOf ?? 1)
return picks.slice(0, n)
}
function SeriesStrip({
bestOf,
scoreA = 0,
scoreB = 0,
maps,
}: {
bestOf: number
scoreA?: number | null
scoreB?: number | null
maps: string[]
}) {
const winsA = Math.max(0, scoreA ?? 0)
const winsB = Math.max(0, scoreB ?? 0)
const needed = Math.ceil(bestOf / 2)
const total = Math.max(bestOf, maps.length || 1)
// index der "aktuellen" Map: sobald jemand fertig ist → keine aktuelle Markierung
const finished = winsA >= needed || winsB >= needed
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
return (
<div className="w-full">
{/* Kopfzeile der Serie */}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-400">
Best of {bestOf} First to {needed}
</div>
<div className="text-sm font-semibold">
{winsA}:{winsB}
</div>
</div>
{/* Kartenleiste */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: total }).map((_, i) => {
const key = maps[i] ?? ''
const label = mapLabelFromKey(key)
// Siegerbadge pro Map (heuristisch): mapsiegereihenfolge = Sum wins so far?
// Da wir kein per-Map Ergebnis haben, markieren wir nur globalen Fortschritt:
const isDone = i < winsA + winsB
const isCurrent = i === currentIdx
const isFuture = i > winsA + winsB
return (
<div
key={`series-map-${i}`}
className={[
'rounded-md px-3 py-2 border flex items-center justify-between',
isCurrent
? 'border-blue-500 ring-2 ring-blue-300/50 bg-blue-500/10'
: isDone
? 'border-emerald-500 bg-emerald-500/10'
: 'border-gray-600 bg-neutral-800/40',
].join(' ')}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs opacity-70 shrink-0">Map {i + 1}</span>
<span className="font-medium truncate">{label}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isDone && (
<span className="text-emerald-400 text-xs font-semibold"></span>
)}
{isCurrent && !isDone && !isFuture && (
<span className="text-blue-400 text-[11px] font-semibold">LIVE / als Nächstes</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
const { data: session } = useSession()
@ -56,6 +153,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const isAdmin = !!session?.user?.isAdmin
const [now, setNow] = useState(initialNow)
const [editMetaOpen, setEditMetaOpen] = useState(false)
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null);
const [leadOverride, setLeadOverride] = useState<number | null>(null);
/* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user
@ -79,6 +179,13 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
const seriesMaps = useMemo(() => {
const fromVote = extractSeriesMaps(match)
const n = Math.max(1, match.bestOf ?? 1)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length])
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
const [editSide, setEditSide] = useState<EditSide | null>(null)
@ -88,12 +195,22 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
return () => clearInterval(id)
}, [])
// Basiszeit des Matches einmal berechnen
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? initialNow;
return new Date(raw).getTime();
}, [match.matchDate, match.demoDate, initialNow]);
const voteOpensAtTs = useMemo(() => {
const base = match.mapVote?.opensAt
? new Date(match.mapVote.opensAt).getTime()
: new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000
return base
}, [match.mapVote?.opensAt, match.matchDate, match.demoDate, initialNow])
if (opensAtOverride != null) return opensAtOverride; // SSE hat Vorrang
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime(); // vom Server
const lead = (leadOverride != null) ? leadOverride : 60; // kein 60-min Zwang
return matchBaseTs - lead * 60_000;
}, [opensAtOverride, match.mapVote?.opensAt, matchBaseTs, leadOverride]);
const sseOpensAtTs = voteOpensAtTs;
const sseLeadMinutes = leadOverride;
const endDate = new Date(voteOpensAtTs)
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
@ -103,19 +220,37 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
/* ─── SSE-Listener ─────────────────────────────────────────── */
useEffect(() => {
if (!lastEvent) return
if (!lastEvent) return;
const evt = (lastEvent as any).payload ?? lastEvent;
if (evt?.matchId !== match.id) return;
// Match gelöscht? → zurück zur Liste
if (lastEvent.type === 'match-deleted' && lastEvent.payload?.matchId === match.id) {
router.replace('/schedule')
return
if (lastEvent.type === 'map-vote-updated') {
// opensAt aus Event übernehmen
if (evt?.opensAt) {
const ts = typeof evt.opensAt === 'string'
? new Date(evt.opensAt).getTime()
: new Date(evt.opensAt).getTime();
setOpensAtOverride(ts);
}
// leadMinutes mitschneiden u. ggf. opensAt daraus ableiten
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes);
setLeadOverride(lead);
if (!evt?.opensAt) {
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime();
setOpensAtOverride(base - lead * 60_000);
}
}
}
// Alle Match-Events → Seite frisch rendern
if (MATCH_EVENTS.has(lastEvent.type) && lastEvent.payload?.matchId === match.id) {
router.refresh()
const REFRESH_TYPES = new Set([
'map-vote-reset','map-vote-locked','map-vote-unlocked',
'match-updated','match-lineup-updated',
]);
if (REFRESH_TYPES.has(lastEvent.type) && evt?.matchId === match.id) {
router.refresh();
}
}, [lastEvent, match.id, router])
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]);
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
const ColGroup = () => (
@ -281,7 +416,18 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
<div className="text-md">
{(match.bestOf ?? 1) > 1 && (
<div className="mt-3">
<SeriesStrip
bestOf={match.bestOf ?? 3}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={seriesMaps}
/>
</div>
)}
<div className="text-md mt-2">
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
</div>
@ -289,7 +435,13 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
<MapVoteBanner match={match} initialNow={initialNow} />
<MapVoteBanner
match={match}
initialNow={initialNow}
matchBaseTs={matchBaseTs}
sseOpensAtTs={sseOpensAtTs}
sseLeadMinutes={sseLeadMinutes}
/>
{/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
@ -449,7 +601,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={60}
onSaved={() => { router.refresh() }}
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}
</div>

View File

@ -77,6 +77,9 @@ export default function Modal({
return () => {
modalEl.removeEventListener('hsOverlay:close', handleClose)
destroyIfExists()
// Fallback: Globale Backdrops wegräumen, falls die Lib zickt
document.querySelectorAll('.hs-overlay-backdrop')?.forEach(el => el.remove())
document.body.classList.remove('overflow-hidden','[&.hs-overlay-open]') // je nach Lib-Version
}
}, [show, id, onClose])
@ -90,7 +93,10 @@ export default function Modal({
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose?.()
}}
className="hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden"
className={
"hs-overlay hidden fixed inset-0 z-80 overflow-y-auto overflow-x-hidden " +
(show ? "" : "pointer-events-none")
}
>
{/* Backdrop */}
<div className="fixed inset-0 -z-10 bg-black/50 dark:bg-neutral-900/70" />

View File

@ -1,4 +1,4 @@
// sseEvents.ts
// /app/lib/sseEvents.ts
export const SSE_EVENT_TYPES = [
// Kanonisch
@ -23,6 +23,7 @@ export const SSE_EVENT_TYPES = [
'expired-sharecode',
'team-invite-revoked',
'map-vote-updated',
'match-meta-updated',
'map-vote-admin-edit',
'match-created',
'matches-updated',

View File

@ -41,10 +41,11 @@ export type MapVoteState = {
currentIndex: number
locked: boolean
opensAt: string | null
leadMinutes?: number | null
steps: MapVoteStep[]
teams?: {
teamA: MapVoteTeam
teamB: MapVoteTeam
}
adminEdit?: MapVoteAdminEdit // ⬅️ NEU
adminEdit?: MapVoteAdminEdit
}

View File

@ -2,6 +2,15 @@
import { Player, Team } from './team'
export type MapVoteStep = {
order : number
action: 'BAN' | 'PICK' | 'DECIDER'
map? : string | null
teamId?: string | null
chosenAt?: string | null
chosenBy?: string | null
}
export type Match = {
/* Basis-Infos ---------------------------------------------------- */
id : string
@ -22,9 +31,11 @@ export type Match = {
teamB: Team
mapVote?: {
status: 'not_started' | 'in_progress' | 'completed' | null
status : 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | null
isOpen: boolean | null
isOpen : boolean | null
locked?: boolean | null
steps? : MapVoteStep[]
} | null
}

File diff suppressed because one or more lines are too long

View File

@ -286,6 +286,7 @@ exports.Prisma.MapVoteScalarFieldEnum = {
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
leadMinutes: 'leadMinutes',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt',

View File

@ -16185,11 +16185,13 @@ export namespace Prisma {
export type MapVoteAvgAggregateOutputType = {
bestOf: number | null
currentIdx: number | null
leadMinutes: number | null
}
export type MapVoteSumAggregateOutputType = {
bestOf: number | null
currentIdx: number | null
leadMinutes: number | null
}
export type MapVoteMinAggregateOutputType = {
@ -16199,6 +16201,7 @@ export namespace Prisma {
currentIdx: number | null
locked: boolean | null
opensAt: Date | null
leadMinutes: number | null
adminEditingBy: string | null
adminEditingSince: Date | null
createdAt: Date | null
@ -16212,6 +16215,7 @@ export namespace Prisma {
currentIdx: number | null
locked: boolean | null
opensAt: Date | null
leadMinutes: number | null
adminEditingBy: string | null
adminEditingSince: Date | null
createdAt: Date | null
@ -16226,6 +16230,7 @@ export namespace Prisma {
currentIdx: number
locked: number
opensAt: number
leadMinutes: number
adminEditingBy: number
adminEditingSince: number
createdAt: number
@ -16237,11 +16242,13 @@ export namespace Prisma {
export type MapVoteAvgAggregateInputType = {
bestOf?: true
currentIdx?: true
leadMinutes?: true
}
export type MapVoteSumAggregateInputType = {
bestOf?: true
currentIdx?: true
leadMinutes?: true
}
export type MapVoteMinAggregateInputType = {
@ -16251,6 +16258,7 @@ export namespace Prisma {
currentIdx?: true
locked?: true
opensAt?: true
leadMinutes?: true
adminEditingBy?: true
adminEditingSince?: true
createdAt?: true
@ -16264,6 +16272,7 @@ export namespace Prisma {
currentIdx?: true
locked?: true
opensAt?: true
leadMinutes?: true
adminEditingBy?: true
adminEditingSince?: true
createdAt?: true
@ -16278,6 +16287,7 @@ export namespace Prisma {
currentIdx?: true
locked?: true
opensAt?: true
leadMinutes?: true
adminEditingBy?: true
adminEditingSince?: true
createdAt?: true
@ -16379,6 +16389,7 @@ export namespace Prisma {
currentIdx: number
locked: boolean
opensAt: Date | null
leadMinutes: number
adminEditingBy: string | null
adminEditingSince: Date | null
createdAt: Date
@ -16412,6 +16423,7 @@ export namespace Prisma {
currentIdx?: boolean
locked?: boolean
opensAt?: boolean
leadMinutes?: boolean
adminEditingBy?: boolean
adminEditingSince?: boolean
createdAt?: boolean
@ -16429,6 +16441,7 @@ export namespace Prisma {
currentIdx?: boolean
locked?: boolean
opensAt?: boolean
leadMinutes?: boolean
adminEditingBy?: boolean
adminEditingSince?: boolean
createdAt?: boolean
@ -16444,6 +16457,7 @@ export namespace Prisma {
currentIdx?: boolean
locked?: boolean
opensAt?: boolean
leadMinutes?: boolean
adminEditingBy?: boolean
adminEditingSince?: boolean
createdAt?: boolean
@ -16459,13 +16473,14 @@ export namespace Prisma {
currentIdx?: boolean
locked?: boolean
opensAt?: boolean
leadMinutes?: boolean
adminEditingBy?: boolean
adminEditingSince?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type MapVoteOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "matchId" | "bestOf" | "mapPool" | "currentIdx" | "locked" | "opensAt" | "adminEditingBy" | "adminEditingSince" | "createdAt" | "updatedAt", ExtArgs["result"]["mapVote"]>
export type MapVoteOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "matchId" | "bestOf" | "mapPool" | "currentIdx" | "locked" | "opensAt" | "leadMinutes" | "adminEditingBy" | "adminEditingSince" | "createdAt" | "updatedAt", ExtArgs["result"]["mapVote"]>
export type MapVoteInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
match?: boolean | MatchDefaultArgs<ExtArgs>
steps?: boolean | MapVote$stepsArgs<ExtArgs>
@ -16492,6 +16507,7 @@ export namespace Prisma {
currentIdx: number
locked: boolean
opensAt: Date | null
leadMinutes: number
adminEditingBy: string | null
adminEditingSince: Date | null
createdAt: Date
@ -16928,6 +16944,7 @@ export namespace Prisma {
readonly currentIdx: FieldRef<"MapVote", 'Int'>
readonly locked: FieldRef<"MapVote", 'Boolean'>
readonly opensAt: FieldRef<"MapVote", 'DateTime'>
readonly leadMinutes: FieldRef<"MapVote", 'Int'>
readonly adminEditingBy: FieldRef<"MapVote", 'String'>
readonly adminEditingSince: FieldRef<"MapVote", 'DateTime'>
readonly createdAt: FieldRef<"MapVote", 'DateTime'>
@ -18768,6 +18785,7 @@ export namespace Prisma {
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
leadMinutes: 'leadMinutes',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt',
@ -20066,6 +20084,7 @@ export namespace Prisma {
currentIdx?: IntFilter<"MapVote"> | number
locked?: BoolFilter<"MapVote"> | boolean
opensAt?: DateTimeNullableFilter<"MapVote"> | Date | string | null
leadMinutes?: IntFilter<"MapVote"> | number
adminEditingBy?: StringNullableFilter<"MapVote"> | string | null
adminEditingSince?: DateTimeNullableFilter<"MapVote"> | Date | string | null
createdAt?: DateTimeFilter<"MapVote"> | Date | string
@ -20082,6 +20101,7 @@ export namespace Prisma {
currentIdx?: SortOrder
locked?: SortOrder
opensAt?: SortOrderInput | SortOrder
leadMinutes?: SortOrder
adminEditingBy?: SortOrderInput | SortOrder
adminEditingSince?: SortOrderInput | SortOrder
createdAt?: SortOrder
@ -20101,6 +20121,7 @@ export namespace Prisma {
currentIdx?: IntFilter<"MapVote"> | number
locked?: BoolFilter<"MapVote"> | boolean
opensAt?: DateTimeNullableFilter<"MapVote"> | Date | string | null
leadMinutes?: IntFilter<"MapVote"> | number
adminEditingBy?: StringNullableFilter<"MapVote"> | string | null
adminEditingSince?: DateTimeNullableFilter<"MapVote"> | Date | string | null
createdAt?: DateTimeFilter<"MapVote"> | Date | string
@ -20117,6 +20138,7 @@ export namespace Prisma {
currentIdx?: SortOrder
locked?: SortOrder
opensAt?: SortOrderInput | SortOrder
leadMinutes?: SortOrder
adminEditingBy?: SortOrderInput | SortOrder
adminEditingSince?: SortOrderInput | SortOrder
createdAt?: SortOrder
@ -20139,6 +20161,7 @@ export namespace Prisma {
currentIdx?: IntWithAggregatesFilter<"MapVote"> | number
locked?: BoolWithAggregatesFilter<"MapVote"> | boolean
opensAt?: DateTimeNullableWithAggregatesFilter<"MapVote"> | Date | string | null
leadMinutes?: IntWithAggregatesFilter<"MapVote"> | number
adminEditingBy?: StringNullableWithAggregatesFilter<"MapVote"> | string | null
adminEditingSince?: DateTimeNullableWithAggregatesFilter<"MapVote"> | Date | string | null
createdAt?: DateTimeWithAggregatesFilter<"MapVote"> | Date | string
@ -21425,6 +21448,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -21441,6 +21465,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -21455,6 +21480,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -21471,6 +21497,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -21486,6 +21513,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -21499,6 +21527,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -21513,6 +21542,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -22678,6 +22708,7 @@ export namespace Prisma {
currentIdx?: SortOrder
locked?: SortOrder
opensAt?: SortOrder
leadMinutes?: SortOrder
adminEditingBy?: SortOrder
adminEditingSince?: SortOrder
createdAt?: SortOrder
@ -22687,6 +22718,7 @@ export namespace Prisma {
export type MapVoteAvgOrderByAggregateInput = {
bestOf?: SortOrder
currentIdx?: SortOrder
leadMinutes?: SortOrder
}
export type MapVoteMaxOrderByAggregateInput = {
@ -22696,6 +22728,7 @@ export namespace Prisma {
currentIdx?: SortOrder
locked?: SortOrder
opensAt?: SortOrder
leadMinutes?: SortOrder
adminEditingBy?: SortOrder
adminEditingSince?: SortOrder
createdAt?: SortOrder
@ -22709,6 +22742,7 @@ export namespace Prisma {
currentIdx?: SortOrder
locked?: SortOrder
opensAt?: SortOrder
leadMinutes?: SortOrder
adminEditingBy?: SortOrder
adminEditingSince?: SortOrder
createdAt?: SortOrder
@ -22718,6 +22752,7 @@ export namespace Prisma {
export type MapVoteSumOrderByAggregateInput = {
bestOf?: SortOrder
currentIdx?: SortOrder
leadMinutes?: SortOrder
}
export type EnumMapVoteActionFilter<$PrismaModel = never> = {
@ -26846,6 +26881,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -26860,6 +26896,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -27108,6 +27145,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -27122,6 +27160,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -29149,6 +29188,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -29164,6 +29204,7 @@ export namespace Prisma {
currentIdx?: number
locked?: boolean
opensAt?: Date | string | null
leadMinutes?: number
adminEditingBy?: string | null
adminEditingSince?: Date | string | null
createdAt?: Date | string
@ -29303,6 +29344,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@ -29318,6 +29360,7 @@ export namespace Prisma {
currentIdx?: IntFieldUpdateOperationsInput | number
locked?: BoolFieldUpdateOperationsInput | boolean
opensAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
leadMinutes?: IntFieldUpdateOperationsInput | number
adminEditingBy?: NullableStringFieldUpdateOperationsInput | string | null
adminEditingSince?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-6d46bb441ad6771c9d4c18422a89623b795f3a4d205bdd0a7fbf78b6d9d34ce6",
"name": "prisma-client-da0847ed2b650f4e980bae112054fc21d3b01519299b5975e3d05dad9fd53b68",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@ -323,6 +323,8 @@ model MapVote {
locked Boolean @default(false)
opensAt DateTime?
leadMinutes Int @default(60)
adminEditingBy String?
adminEditingSince DateTime?

View File

@ -286,6 +286,7 @@ exports.Prisma.MapVoteScalarFieldEnum = {
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
leadMinutes: 'leadMinutes',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt',