Files
polylan-submitter/polylan_submitter/src/Planning.vue
T
2026-10-16 05:28:15 +02:00

223 lines
8.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query";
import { eventPlanningQuery } from "@/queries";
import type { PlanningSlotOut } from "./api/types.gen";
import PlanningCover from "./components/PlanningCover.vue";
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("planning.title")));
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
const HOUR = 3600e3;
const HOUR_PX = 104; // a 2h slot ends up close to the 2:3 cover ratio
const BREAK_PX = 34;
const DAY_PX = 32;
const AXIS_PX = 56;
const COL_PX = 150;
const GAP_PX = 10;
// Empty runs at least this long (nights) get squashed into a thin break
const MIN_BREAK_HOURS = 3;
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
interface Segment {
from: number;
to: number;
y: number;
h: number;
kind: "hour" | "idle" | "break" | "day";
label?: string;
}
interface Placed {
slot: PlanningSlotOut;
top: number;
height: number;
when: string;
}
const layout = computed(() => {
const list = (slots.value ?? []).map((slot) => ({
slot,
b: Date.parse(slot.begin),
e: Date.parse(slot.end),
}));
if (!list.length) return null;
// Multi-anims run the whole event, only the other slots decide what's busy
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
const segments: Segment[] = [];
let y = 0;
let lastDay = "";
for (let t = start; t < end;) {
if (busy(t)) {
// Each day gets its own header row, takes no time
const day = fmtDay.format(t);
if (day !== lastDay) {
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
y += DAY_PX;
lastDay = day;
}
segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" });
y += HOUR_PX;
t += HOUR;
continue;
}
let u = t;
while (u < end && !busy(u)) u += HOUR;
const hours = (u - t) / HOUR;
const isBreak = hours >= MIN_BREAK_HOURS;
const h = isBreak ? BREAK_PX : HOUR_PX * hours;
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
y += h;
t = u;
}
// Day headers have no duration, they never contain a time
const timed = segments.filter((s) => s.kind !== "day");
const yOf = (t: number) => {
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
};
const days = segments
.filter((s) => s.kind === "day")
.map((s) => ({ y: s.y, label: s.label! }));
// No tick right under a day header, it would sit on its line
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
const ticks = timed
.filter((s) => s.kind !== "break")
.flatMap((s) => {
const out = [];
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
return out;
})
.filter((tick) => !dayEnds.has(tick.y));
const breaks = segments
.filter((s) => s.kind === "break")
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
// Unused columns are dropped
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
let left = AXIS_PX;
const columns = columnIds.map((id) => {
const placed = list
.filter(({ slot }) => slot.column === id)
.map<Placed>(({ slot, b, e }) => ({
slot,
top: yOf(b) + 3,
height: yOf(e) - yOf(b) - 6,
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
}));
// The multi-anims column is the big one, twice as wide
const width = placed.some(({ slot }) => slot.is_multi_anims) ? 2 * COL_PX + GAP_PX : COL_PX;
const column = { left, width, placed };
left += width + GAP_PX;
return column;
});
return {
height: y,
width: left - GAP_PX,
days,
ticks,
breaks,
columns,
};
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-7xl">
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
</div>
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
{{ t("planning.empty") }}
</p>
<div v-else class="overflow-x-auto pb-4">
<div class="relative mx-auto" :style="{ width: `${layout.width}px`, height: `${layout.height}px` }">
<!-- Time axis: a header row per day, hour ticks on the side -->
<!-- Above the cards: slots running past midnight go over the header row -->
<div v-for="day in layout.days" :key="`day-${day.y}`"
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
<!-- Ends at the time axis and grows to the left, never over the cards -->
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
</div>
<template v-for="tick in layout.ticks" :key="tick.y">
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
{{ tick.label }}
</span>
<div class="absolute right-0 border-t border-dashed border-base-content/5"
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
</template>
<!-- Nights -->
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
{{ b.label }}
</div>
<!-- Slots -->
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
:style="{ top: `${top}px`, height: `${height}px` }" />
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
:style="{ top: `${top}px`, height: `${height}px` }">
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
group-hover:opacity-100 transition-opacity">
{{ when }}
</span>
</component>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.planning-break {
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
}
</style>