wip: planning-slot test
This commit is contained in:
@@ -7,7 +7,7 @@ from django_object_actions import (
|
|||||||
|
|
||||||
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||||
|
|
||||||
from .models import EventGame, Game
|
from .models import EventGame, Game, PlanningSlot
|
||||||
from .tasks import refresh_steam_games_task
|
from .tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
@@ -90,3 +90,10 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||||
|
|
||||||
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanningSlot)
|
||||||
|
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||||
|
list_filter = ["event", "is_multi_anims"]
|
||||||
|
search_fields = ["title"]
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from typing import List
|
|||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
|
||||||
from .models import Game
|
from .models import Game
|
||||||
from .schemas import GameOut, SteamGameOut
|
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||||
|
from .services.planning import planning_with_games
|
||||||
from .tasks import schedule_steam_refresh
|
from .tasks import schedule_steam_refresh
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -28,3 +29,9 @@ def list_event_steam_games(request, event_path: str):
|
|||||||
return Game.objects.enabled_for_event(request.event).filter(
|
return Game.objects.enabled_for_event(request.event).filter(
|
||||||
steam_app_id__isnull=False
|
steam_app_id__isnull=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||||
|
def list_event_planning(request, event_path: str):
|
||||||
|
"""The event planning, with the covers of the games we know."""
|
||||||
|
return planning_with_games(request.event.planning_slots.all())
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.services.planning import import_planning
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Import an event planning from the legacy PolyLAN planning JSON "
|
||||||
|
"(slots missing from the file are deleted)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
||||||
|
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event_path"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path {options['event_path']!r}")
|
||||||
|
|
||||||
|
if options["json_file"] == "-":
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
else:
|
||||||
|
with open(options["json_file"], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
entries = data["list"] if isinstance(data, dict) else data
|
||||||
|
with transaction.atomic():
|
||||||
|
created, updated, deleted = import_planning(event, entries)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0003_event_is_available"),
|
||||||
|
("games", "0011_game_library_image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlanningSlot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("source_pk", models.PositiveIntegerField()),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("begin", models.DateTimeField()),
|
||||||
|
("end", models.DateTimeField()),
|
||||||
|
("column", models.PositiveSmallIntegerField(default=0)),
|
||||||
|
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||||
|
("is_multi_anims", models.BooleanField(default=False)),
|
||||||
|
("game_names", models.JSONField(blank=True, default=list)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["begin", "column"],
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -42,6 +42,19 @@ class Game(models.Model):
|
|||||||
return f"{self.name} ({self.steam_app_id})"
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store_url(self) -> str:
|
||||||
|
if not self.steam_app_id:
|
||||||
|
return ""
|
||||||
|
return f"https://store.steampowered.com/app/{self.steam_app_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def library_image_url(self) -> str:
|
||||||
|
"""Vertical cover, like in the Steam library. Empty if there's none."""
|
||||||
|
if self.library_image or not self.steam_app_id:
|
||||||
|
return self.library_image
|
||||||
|
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
|
||||||
|
|
||||||
|
|
||||||
class EventGame(models.Model):
|
class EventGame(models.Model):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
@@ -76,3 +89,37 @@ class EventGame(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlot(models.Model):
|
||||||
|
"""One slot of the event planning, imported from the legacy PolyLAN planning
|
||||||
|
(see the import_planning command)."""
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
)
|
||||||
|
# pk of the slot in the legacy planning, to update it on re-import
|
||||||
|
source_pk = models.PositiveIntegerField()
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
begin = models.DateTimeField()
|
||||||
|
end = models.DateTimeField()
|
||||||
|
column = models.PositiveSmallIntegerField(default=0)
|
||||||
|
color = models.CharField(max_length=7, blank=True, default="")
|
||||||
|
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
|
||||||
|
is_multi_anims = models.BooleanField(default=False)
|
||||||
|
# Names of the games run in this slot (the legacy "aandts")
|
||||||
|
game_names = models.JSONField(default=list, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["begin", "column"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["event", "source_pk"],
|
||||||
|
name="unique_event_planning_slot",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SteamGameOut(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_store_url(obj) -> str:
|
def resolve_store_url(obj) -> str:
|
||||||
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
|
return obj.store_url
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_header_image(obj) -> str:
|
def resolve_header_image(obj) -> str:
|
||||||
@@ -47,11 +47,7 @@ class SteamGameOut(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_library_image(obj) -> str:
|
def resolve_library_image(obj) -> str:
|
||||||
# Vertical capsule, like in the Steam library
|
return obj.library_image_url
|
||||||
return (
|
|
||||||
obj.library_image
|
|
||||||
or f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{obj.steam_app_id}/library_600x900.jpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_short_description(obj) -> str:
|
def resolve_short_description(obj) -> str:
|
||||||
@@ -80,3 +76,23 @@ class SteamGameOut(Schema):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_updated_at(obj) -> Optional[datetime]:
|
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||||
return obj.steam_info_updated_at
|
return obj.steam_info_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningGameOut(Schema):
|
||||||
|
name: str
|
||||||
|
# Empty when the name didn't match any of our games
|
||||||
|
library_image: str = ""
|
||||||
|
store_url: str = ""
|
||||||
|
steam_app_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlotOut(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
begin: datetime
|
||||||
|
end: datetime
|
||||||
|
column: int
|
||||||
|
color: str
|
||||||
|
is_multi_anims: bool
|
||||||
|
# Single slot: the slot's game. Multi-anims: each of their games.
|
||||||
|
games: list[PlanningGameOut]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from ..models import Game, PlanningSlot
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name: str) -> str:
|
||||||
|
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||||
|
no accents, no case, letters and digits only."""
|
||||||
|
name = unicodedata.normalize("NFKD", name)
|
||||||
|
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||||
|
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _game_out(name: str, game: Game | None) -> dict:
|
||||||
|
if game is None:
|
||||||
|
return {"name": name}
|
||||||
|
return {
|
||||||
|
"name": game.name,
|
||||||
|
"library_image": game.library_image_url,
|
||||||
|
"store_url": game.store_url,
|
||||||
|
"steam_app_id": game.steam_app_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def planning_with_games(slots) -> list[dict]:
|
||||||
|
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
|
||||||
|
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
|
||||||
|
|
||||||
|
def match(name: str) -> Game | None:
|
||||||
|
return games_by_name.get(normalize_name(name))
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for slot in slots:
|
||||||
|
if slot.is_multi_anims:
|
||||||
|
games = [_game_out(name, match(name)) for name in slot.game_names]
|
||||||
|
else:
|
||||||
|
# The title is the usual match, the legacy game names are a fallback
|
||||||
|
# ("Golf it!" slot running the "Golf It!" game)
|
||||||
|
game = next(
|
||||||
|
filter(None, map(match, [slot.title, *slot.game_names])), None
|
||||||
|
)
|
||||||
|
games = [_game_out(slot.title, game)]
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": slot.id,
|
||||||
|
"title": slot.title,
|
||||||
|
"begin": slot.begin,
|
||||||
|
"end": slot.end,
|
||||||
|
"column": slot.column,
|
||||||
|
"color": slot.color,
|
||||||
|
"is_multi_anims": slot.is_multi_anims,
|
||||||
|
"games": games,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
|
||||||
|
"""Sync the event planning with the legacy planning entries (its "list").
|
||||||
|
Returns (created, updated, deleted)."""
|
||||||
|
created = updated = 0
|
||||||
|
for entry in entries:
|
||||||
|
_, was_created = PlanningSlot.objects.update_or_create(
|
||||||
|
event=event,
|
||||||
|
source_pk=entry["pk"],
|
||||||
|
defaults={
|
||||||
|
"title": entry["title"],
|
||||||
|
"begin": entry["begin"],
|
||||||
|
"end": entry["end"],
|
||||||
|
"column": entry.get("colonneS") or 0,
|
||||||
|
"color": entry.get("color_code") or "",
|
||||||
|
"is_multi_anims": bool(entry.get("is_multi_anims")),
|
||||||
|
"game_names": [a["name"] for a in entry.get("aandts") or []],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
deleted, _ = (
|
||||||
|
PlanningSlot.objects.filter(event=event)
|
||||||
|
.exclude(source_pk__in=[entry["pk"] for entry in entries])
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
return created, updated, deleted
|
||||||
@@ -56,6 +56,12 @@ def games_home(request: HttpRequest, event_path: str):
|
|||||||
return render(request, "app.html")
|
return render(request, "app.html")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def planning_home(request: HttpRequest, event_path: str):
|
||||||
|
get_object_or_404(Event, path=event_path)
|
||||||
|
return render(request, "app.html")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||||
def opus_magnum_home(request: HttpRequest, event_path: str):
|
def opus_magnum_home(request: HttpRequest, event_path: str):
|
||||||
@@ -102,6 +108,7 @@ urlpatterns = [
|
|||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
path("<slug:event_path>/games", games_home, name="games.home"),
|
path("<slug:event_path>/games", games_home, name="games.home"),
|
||||||
|
path("<slug:event_path>/planning", planning_home, name="planning.home"),
|
||||||
path("<slug:event_path>/market", market_home, name="market.home"),
|
path("<slug:event_path>/market", market_home, name="market.home"),
|
||||||
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "vue-router";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useTitle } from "@vueuse/core";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { eventGamesQuery } from "@/queries";
|
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
|
||||||
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +19,8 @@ useTitle(computed(() => t("home.title")));
|
|||||||
|
|
||||||
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
||||||
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
||||||
|
const { data: planning } = useQuery(eventPlanningQuery(props.eventPath));
|
||||||
|
const hasPlanning = computed(() => !!planning.value?.length);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
||||||
@@ -86,9 +88,13 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link to the Steam games page -->
|
<!-- Links to the planning and the Steam games page -->
|
||||||
<div v-if="!loading && hasSteamGames" class="text-center mt-12">
|
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
|
||||||
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
<RouterLink v-if="hasPlanning" :to="{ name: 'planning', params: { eventPath } }" class="btn btn-outline">
|
||||||
|
<i class="mdi mdi-calendar-clock mr-2"></i>
|
||||||
|
{{ t("home.planning") }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink v-if="hasSteamGames" :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
||||||
<i class="mdi mdi-steam mr-2"></i>
|
<i class="mdi mdi-steam mr-2"></i>
|
||||||
{{ t("home.getTheGames") }}
|
{{ t("home.getTheGames") }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
const columns = columnIds.map((id) =>
|
||||||
|
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)}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: y,
|
||||||
|
width: AXIS_PX + columns.length * (COL_PX + GAP_PX) - GAP_PX,
|
||||||
|
days,
|
||||||
|
ticks,
|
||||||
|
breaks,
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
||||||
|
</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 -->
|
||||||
|
<div v-for="day in layout.days" :key="`day-${day.y}`"
|
||||||
|
class="absolute left-0 right-0 border-b border-base-content/20 flex items-end pb-1 text-sm font-semibold uppercase tracking-wide"
|
||||||
|
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||||
|
{{ day.label }}
|
||||||
|
</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, index) in layout.columns" :key="index" class="absolute top-0 h-full"
|
||||||
|
:style="{ left: `${columnLeft(index)}px`, width: `${COL_PX}px` }">
|
||||||
|
<template v-for="{ slot, top, height, when } in column" :key="slot.id">
|
||||||
|
<!-- Multi-anims: one long card, its games stay in view while scrolling -->
|
||||||
|
<div v-if="slot.is_multi_anims" class="planning-multi absolute inset-x-0 rounded-md p-2 shadow-xl"
|
||||||
|
:style="{ top: `${top}px`, height: `${height}px`, '--slot-color': slot.color }">
|
||||||
|
<div class="font-bold uppercase text-sm tracking-wide mb-2">{{ slot.title }}</div>
|
||||||
|
<component :is="game.store_url ? 'a' : 'div'" v-for="game in slot.games" :key="game.name"
|
||||||
|
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="sticky top-2 block aspect-[2/3] rounded overflow-hidden mb-3 shadow-lg bg-base-200">
|
||||||
|
<PlanningCover :game="game" :color="slot.color" />
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-multi {
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
color-mix(in srgb, var(--slot-color) 30%, var(--color-base-200)),
|
||||||
|
var(--color-base-200));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
|
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen';
|
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventPlanning, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen';
|
||||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen';
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventPlanningData, GamesApiListEventPlanningResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen';
|
||||||
|
|
||||||
export type QueryKey<TOptions extends Options> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||||
@@ -551,6 +551,26 @@ export const gamesApiListEventSteamGamesOptions = (options: Options<GamesApiList
|
|||||||
queryKey: gamesApiListEventSteamGamesQueryKey(options)
|
queryKey: gamesApiListEventSteamGamesQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const gamesApiListEventPlanningQueryKey = (options: Options<GamesApiListEventPlanningData>) => createQueryKey('gamesApiListEventPlanning', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Event Planning
|
||||||
|
*
|
||||||
|
* The event planning, with the covers of the games we know.
|
||||||
|
*/
|
||||||
|
export const gamesApiListEventPlanningOptions = (options: Options<GamesApiListEventPlanningData>) => queryOptions<GamesApiListEventPlanningResponse, DefaultError, GamesApiListEventPlanningResponse, ReturnType<typeof gamesApiListEventPlanningQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await gamesApiListEventPlanning({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: gamesApiListEventPlanningQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options);
|
export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||||
/**
|
/**
|
||||||
@@ -203,6 +203,13 @@ export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(opt
|
|||||||
*/
|
*/
|
||||||
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
|
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Event Planning
|
||||||
|
*
|
||||||
|
* The event planning, with the covers of the games we know.
|
||||||
|
*/
|
||||||
|
export const gamesApiListEventPlanning = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventPlanningData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventPlanningResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/planning', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List My Scopes
|
* List My Scopes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -986,6 +986,66 @@ export type SteamGameOut = {
|
|||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlanningGameOut
|
||||||
|
*/
|
||||||
|
export type PlanningGameOut = {
|
||||||
|
/**
|
||||||
|
* Name
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Library Image
|
||||||
|
*/
|
||||||
|
library_image?: string;
|
||||||
|
/**
|
||||||
|
* Store Url
|
||||||
|
*/
|
||||||
|
store_url?: string;
|
||||||
|
/**
|
||||||
|
* Steam App Id
|
||||||
|
*/
|
||||||
|
steam_app_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlanningSlotOut
|
||||||
|
*/
|
||||||
|
export type PlanningSlotOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Title
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Begin
|
||||||
|
*/
|
||||||
|
begin: string;
|
||||||
|
/**
|
||||||
|
* End
|
||||||
|
*/
|
||||||
|
end: string;
|
||||||
|
/**
|
||||||
|
* Column
|
||||||
|
*/
|
||||||
|
column: number;
|
||||||
|
/**
|
||||||
|
* Color
|
||||||
|
*/
|
||||||
|
color: string;
|
||||||
|
/**
|
||||||
|
* Is Multi Anims
|
||||||
|
*/
|
||||||
|
is_multi_anims: boolean;
|
||||||
|
/**
|
||||||
|
* Games
|
||||||
|
*/
|
||||||
|
games: Array<PlanningGameOut>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScopeOut
|
* ScopeOut
|
||||||
*/
|
*/
|
||||||
@@ -1997,6 +2057,29 @@ export type GamesApiListEventSteamGamesResponses = {
|
|||||||
|
|
||||||
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
|
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/planning';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<PlanningSlotOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GamesApiListEventPlanningResponse = GamesApiListEventPlanningResponses[keyof GamesApiListEventPlanningResponses];
|
||||||
|
|
||||||
export type SubmissionsApiListMyScopesData = {
|
export type SubmissionsApiListMyScopesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { PlanningGameOut } from "../api/types.gen";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
game: PlanningGameOut;
|
||||||
|
color: string;
|
||||||
|
// Slot time, shown on the colored card (the cover has its own hover badge)
|
||||||
|
when?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Not every game has a library capsule on Steam, fall back to the colored card
|
||||||
|
const coverFailed = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img v-if="game.library_image && !coverFailed" :src="game.library_image" :alt="game.name" :title="game.name"
|
||||||
|
loading="lazy" class="w-full h-full object-cover object-top" @error="coverFailed = true" />
|
||||||
|
<div v-else class="planning-fallback w-full h-full flex flex-col justify-center gap-1 p-3 border-l-4"
|
||||||
|
:style="{ '--slot-color': color }">
|
||||||
|
<span class="font-semibold leading-tight text-base-content">{{ game.name }}</span>
|
||||||
|
<span v-if="when" class="text-xs text-base-content/70">{{ when }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.planning-fallback {
|
||||||
|
border-color: var(--slot-color);
|
||||||
|
background: linear-gradient(160deg,
|
||||||
|
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
|
||||||
|
var(--color-base-200));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"subtitle": "Choose a game and submit your best solutions",
|
"subtitle": "Choose a game and submit your best solutions",
|
||||||
"submitResults": "Submit results",
|
"submitResults": "Submit results",
|
||||||
"getTheGames": "Get the games",
|
"getTheGames": "Get the games",
|
||||||
|
"planning": "Planning",
|
||||||
"footer": "Select a game above to begin submitting"
|
"footer": "Select a game above to begin submitting"
|
||||||
},
|
},
|
||||||
"games": {
|
"games": {
|
||||||
@@ -79,6 +80,11 @@
|
|||||||
"play": "Play",
|
"play": "Play",
|
||||||
"showPrices": "Show prices"
|
"showPrices": "Show prices"
|
||||||
},
|
},
|
||||||
|
"planning": {
|
||||||
|
"header": "Planning",
|
||||||
|
"title": "Planning",
|
||||||
|
"empty": "No planning for this event yet"
|
||||||
|
},
|
||||||
"clock": {
|
"clock": {
|
||||||
"pageTitle": "Beat the Clock",
|
"pageTitle": "Beat the Clock",
|
||||||
"header": "Beat the Clock",
|
"header": "Beat the Clock",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persistQueryClient } from "@tanstack/query-persist-client-core";
|
|||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import {
|
import {
|
||||||
gamesApiListEventGamesOptions,
|
gamesApiListEventGamesOptions,
|
||||||
|
gamesApiListEventPlanningOptions,
|
||||||
polylanSubmitterApiGetUserInfoOptions,
|
polylanSubmitterApiGetUserInfoOptions,
|
||||||
polylanSubmitterApiGetUserInfoQueryKey,
|
polylanSubmitterApiGetUserInfoQueryKey,
|
||||||
} from "@/api/@tanstack/vue-query.gen";
|
} from "@/api/@tanstack/vue-query.gen";
|
||||||
@@ -25,6 +26,12 @@ export const eventGamesQuery = (eventPath: string) => ({
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Imported by staff a handful of times per event.
|
||||||
|
export const eventPlanningQuery = (eventPath: string) => ({
|
||||||
|
...gamesApiListEventPlanningOptions({ path: { event_path: eventPath } }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
|
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
|
||||||
|
|
||||||
// Restores user + games from localStorage before the app mounts, so pages
|
// Restores user + games from localStorage before the app mounts, so pages
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Home from "@/Home.vue";
|
|||||||
import Market from "@/Market.vue";
|
import Market from "@/Market.vue";
|
||||||
import Noita from "@/Noita.vue";
|
import Noita from "@/Noita.vue";
|
||||||
import OpusMagnum from "@/OpusMagnum.vue";
|
import OpusMagnum from "@/OpusMagnum.vue";
|
||||||
|
import Planning from "@/Planning.vue";
|
||||||
import Place from "@/Place.vue";
|
import Place from "@/Place.vue";
|
||||||
import PlaceEmbed from "@/PlaceEmbed.vue";
|
import PlaceEmbed from "@/PlaceEmbed.vue";
|
||||||
import Submissions from "@/Submissions.vue";
|
import Submissions from "@/Submissions.vue";
|
||||||
@@ -45,6 +46,13 @@ export const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { titleKey: "games.header", getBackRoute: eventHome },
|
meta: { titleKey: "games.header", getBackRoute: eventHome },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/:eventPath/planning",
|
||||||
|
name: "planning",
|
||||||
|
component: Planning,
|
||||||
|
props: true,
|
||||||
|
meta: { titleKey: "planning.header", getBackRoute: eventHome },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:eventPath/market",
|
path: "/:eventPath/market",
|
||||||
name: "market",
|
name: "market",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Games Planning Preview</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #1d232a; --surface: #2a323c; --text: #a6adbb; --muted: #6b7280; --line: #ffffff10;
|
||||||
|
--hour: 104px; --col: 150px; --gap: 10px; --break: 34px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||||
|
.page { max-width: 1280px; margin: 0 auto; padding: 48px 16px; }
|
||||||
|
h1 { text-align: center; font-size: 3rem; margin: 0 0 16px; color: #e5e7eb; }
|
||||||
|
.sub { text-align: center; font-size: 1.25rem; margin: 0 0 32px; }
|
||||||
|
.toolbar { display: flex; justify-content: flex-end; gap: 8px; align-items: center; margin-bottom: 24px; }
|
||||||
|
.toggle { width: 44px; height: 24px; border-radius: 999px; border: 2px solid var(--muted); position: relative; }
|
||||||
|
.toggle::after { content: ""; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.scroller { overflow-x: auto; }
|
||||||
|
.planning { display: grid; gap: var(--gap); width: max-content; margin: 0 auto; }
|
||||||
|
.axis, .column { position: relative; }
|
||||||
|
.axis { width: 56px; }
|
||||||
|
.tick { position: absolute; right: 8px; font-size: 12px; color: var(--muted); transform: translateY(-50%); }
|
||||||
|
.day { position: absolute; left: 0; right: calc(-1 * (var(--col) + var(--gap)) * 6); border-top: 1px solid #ffffff30;
|
||||||
|
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #e5e7eb; padding-top: 2px; }
|
||||||
|
.hourline { position: absolute; left: 56px; right: 0; border-top: 1px dashed var(--line); }
|
||||||
|
.break { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; color: var(--muted); background: repeating-linear-gradient(45deg, #ffffff06 0 6px, transparent 6px 12px); border-radius: 4px; }
|
||||||
|
.column { width: var(--col); }
|
||||||
|
.card { position: absolute; left: 0; right: 0; border-radius: 6px; overflow: hidden; background: var(--surface);
|
||||||
|
box-shadow: 0 20px 25px -5px #0006; transition: all .15s; cursor: pointer; }
|
||||||
|
.card:hover { transform: translateY(-8px) scale(1.05); box-shadow: 0 25px 50px -12px #000a; z-index: 5; }
|
||||||
|
.card img.cover { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
|
||||||
|
.card .time { position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
|
||||||
|
background: #000a; color: #fff; opacity: 0; transition: opacity .15s; }
|
||||||
|
.card:hover .time { opacity: 1; }
|
||||||
|
.fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 10px;
|
||||||
|
border-left: 4px solid var(--c); background: linear-gradient(160deg, color-mix(in srgb, var(--c) 35%, var(--surface)), var(--surface)); }
|
||||||
|
.fallback .title { color: #f3f4f6; font-weight: 600; line-height: 1.2; }
|
||||||
|
.fallback .when { font-size: 12px; color: #d1d5db; }
|
||||||
|
/* multi-anims: one long card, its games stacked & sticky while you scroll */
|
||||||
|
.multi { background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, var(--surface)), var(--surface)); padding: 8px; overflow: visible; }
|
||||||
|
.multi:hover { transform: none; }
|
||||||
|
.multi .head { color: #f3f4f6; font-weight: 700; text-transform: uppercase; font-size: 13px; margin-bottom: 8px; letter-spacing: .05em; }
|
||||||
|
.multi .sub-card { position: sticky; top: 8px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; margin-bottom: 12px;
|
||||||
|
box-shadow: 0 10px 15px -3px #0008; background: var(--surface); }
|
||||||
|
.multi .sub-card .fallback { border-left: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>Get the games</h1>
|
||||||
|
<p class="sub">Don't own them yet? Grab them on Steam</p>
|
||||||
|
<div class="toolbar"><span>Show prices</span><span class="toggle"></span></div>
|
||||||
|
<div class="scroller"><div class="planning" id="planning"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script>
|
||||||
|
// Preview only: in the app this comes from Game.name matching + library_image
|
||||||
|
const STEAM = {
|
||||||
|
"factorio": 427520, "fall guys": 1097150, "lethal company": 1966720, "rocket league": 252950,
|
||||||
|
"r.e.p.o.": 3241660, "tricky towers": 437920, "rounds": 1557740, "cosmoteer": 799600,
|
||||||
|
"golf it!": 571740, "towerfall: ascension": 251470, "puyo puyo — tetris": 546050,
|
||||||
|
"peak": 3527290, "straftat": 2386720, "trackmania": 2225070, "slay the spire 2": 2868840,
|
||||||
|
};
|
||||||
|
const coverOf = (name) => {
|
||||||
|
const id = STEAM[name.toLowerCase()];
|
||||||
|
return id && `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/library_600x900.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOUR = 3600e3;
|
||||||
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
const HOUR_PX = parseFloat(css.getPropertyValue("--hour"));
|
||||||
|
const BREAK_PX = parseFloat(css.getPropertyValue("--break"));
|
||||||
|
const TZ = "Europe/Zurich";
|
||||||
|
const fmtTime = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
|
||||||
|
const fmtDay = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short" });
|
||||||
|
|
||||||
|
const slots = PLANNING.list.map((s) => ({ ...s, b: Date.parse(s.begin), e: Date.parse(s.end) }));
|
||||||
|
const singles = slots.filter((s) => !s.is_multi_anims);
|
||||||
|
|
||||||
|
// Hours with something going on; long empty runs (nights) get squashed into a thin break
|
||||||
|
const start = Math.floor(Math.min(...slots.map((s) => s.b)) / HOUR) * HOUR;
|
||||||
|
const end = Math.ceil(Math.max(...slots.map((s) => s.e)) / HOUR) * HOUR;
|
||||||
|
const busy = (t) => singles.some((s) => s.b < t + HOUR && s.e > t);
|
||||||
|
const segments = []; // {from, to, y, h, kind}
|
||||||
|
let y = 0;
|
||||||
|
for (let t = start; t < end;) {
|
||||||
|
if (busy(t)) { 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 short = (u - t) / HOUR < 3;
|
||||||
|
const h = short ? HOUR_PX * (u - t) / HOUR : BREAK_PX;
|
||||||
|
segments.push({ from: t, to: u, y, h, kind: short ? "idle" : "break" }); y += h; t = u;
|
||||||
|
}
|
||||||
|
const total = y;
|
||||||
|
const yOf = (t) => {
|
||||||
|
const s = segments.find((s) => t >= s.from && t <= s.to) || segments.at(-1);
|
||||||
|
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cols = [...new Set(slots.map((s) => s.colonneS))].sort((a, b) => a - b); // drop unused columns
|
||||||
|
const root = document.getElementById("planning");
|
||||||
|
root.style.gridTemplateColumns = `56px repeat(${cols.length}, var(--col))`;
|
||||||
|
|
||||||
|
// Time axis + day headers + hour lines
|
||||||
|
const axis = document.createElement("div");
|
||||||
|
axis.className = "axis"; axis.style.height = total + "px";
|
||||||
|
root.appendChild(axis);
|
||||||
|
let lastDay = null;
|
||||||
|
for (const s of segments) {
|
||||||
|
if (s.kind === "break") {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "break"; el.style.top = s.y + "px"; el.style.height = s.h + "px";
|
||||||
|
el.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||||
|
el.textContent = `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}`;
|
||||||
|
axis.appendChild(el);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let t = s.from; t < s.to; t += HOUR) {
|
||||||
|
const ty = yOf(t), day = fmtDay.format(t);
|
||||||
|
if (day !== lastDay) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = "day"; d.style.top = ty + "px"; d.textContent = day;
|
||||||
|
d.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||||
|
axis.appendChild(d); lastDay = day;
|
||||||
|
} else {
|
||||||
|
const tick = document.createElement("div");
|
||||||
|
tick.className = "tick"; tick.style.top = ty + "px"; tick.textContent = fmtTime.format(t);
|
||||||
|
axis.appendChild(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = (title, when, color) =>
|
||||||
|
`<div class="fallback" style="--c:${color}"><div class="title">${title}</div>${when ? `<div class="when">${when}</div>` : ""}</div>`;
|
||||||
|
const coverOrFallback = (name, when, color) => {
|
||||||
|
const src = coverOf(name);
|
||||||
|
if (!src) return fallback(name, when, color);
|
||||||
|
// Not every game has a library capsule, swap to the colored card if it 404s
|
||||||
|
return `<img class="cover" src="${src}" alt="${name}" title="${name}"
|
||||||
|
onerror="this.outerHTML = decodeURIComponent('${encodeURIComponent(fallback(name, when, color))}')">`;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const c of cols) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "column"; col.style.height = total + "px";
|
||||||
|
for (const s of slots.filter((s) => s.colonneS === c)) {
|
||||||
|
const top = yOf(s.b) + 3, height = yOf(s.e) - yOf(s.b) - 6;
|
||||||
|
const when = `${fmtTime.format(s.b)} – ${fmtTime.format(s.e)}`;
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.style.top = top + "px"; card.style.height = height + "px";
|
||||||
|
if (s.is_multi_anims) {
|
||||||
|
card.className = "card multi"; card.style.setProperty("--c", s.color_code);
|
||||||
|
card.innerHTML = `<div class="head">${s.title}</div>` +
|
||||||
|
s.aandts.map((a) => `<div class="sub-card">${coverOrFallback(a.name, "", s.color_code)}</div>`).join("");
|
||||||
|
} else {
|
||||||
|
card.className = "card";
|
||||||
|
card.innerHTML = coverOrFallback(s.title, when, s.color_code) + `<span class="time">${when}</span>`;
|
||||||
|
}
|
||||||
|
col.appendChild(card);
|
||||||
|
}
|
||||||
|
root.appendChild(col);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user