diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..07e4a2d38 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: tests + +on: + pull_request: + push: + branches: + - "main" + - "beta" + workflow_dispatch: + +concurrency: + group: tests-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + vitest: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out the repo + uses: actions/checkout@v5 + + # setup-node runs twice: its yarn cache probe needs Corepack first (see i18n.yml). + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - name: Enable Corepack + run: | + corepack enable + corepack prepare --activate + + - name: Restore the yarn cache + uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install dependencies + run: yarn install --immutable + + - name: Run tests + run: yarn test diff --git a/.nvmrc b/.nvmrc index 8fdd954df..a45fd52cc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 \ No newline at end of file +24 diff --git a/Dockerfile b/Dockerfile index 2b2a06f78..27a09f98f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /opt/5stack @@ -8,7 +8,7 @@ RUN corepack enable && corepack prepare RUN yarn install --immutable -FROM node:22-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /opt/5stack @@ -25,7 +25,7 @@ ENV NODE_OPTIONS=--max-old-space-size=8192 RUN yarn build -FROM node:22-alpine +FROM node:24-alpine WORKDIR /opt/5stack diff --git a/Dockerfile.dev b/Dockerfile.dev index a170d7995..88b3e229c 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:22-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /opt/5stack @@ -8,7 +8,7 @@ RUN corepack enable && corepack prepare RUN yarn install --immutable -FROM node:22-alpine +FROM node:24-alpine WORKDIR /opt/5stack diff --git a/components/InstallPWA.vue b/components/InstallPWA.vue index a922473cb..f62b24f7e 100644 --- a/components/InstallPWA.vue +++ b/components/InstallPWA.vue @@ -1,9 +1,9 @@ diff --git a/components/InstallPWADrawer.vue b/components/InstallPWADrawer.vue new file mode 100644 index 000000000..f71bc1bd3 --- /dev/null +++ b/components/InstallPWADrawer.vue @@ -0,0 +1,94 @@ + + + diff --git a/components/MatchTableRow.vue b/components/MatchTableRow.vue index e70a831de..7024f5514 100644 --- a/components/MatchTableRow.vue +++ b/components/MatchTableRow.vue @@ -10,6 +10,7 @@ import { UsersIcon, X, } from "lucide-vue-next"; +import { NuxtLink } from "#components"; import TimeAgo from "~/components/TimeAgo.vue"; import { e_match_status_enum } from "~/generated/zeus"; import mapLabel from "~/utilities/mapLabel"; @@ -270,7 +271,7 @@ import MatchOverviewDrawer from "~/components/match/MatchOverviewDrawer.vue"; :title="match.lineup_1.name" >
- {{ - grant.tournament_award?.custom_name || - grant.tournament?.name || - "Tournament" - }} + {{ awardName(grant) }}
{{ placementLabel(grant.placement) }} - · - - {{ formatAwardDate(grant.tournament.start) }} + · + + {{ formatAwardDate(awardDate(grant)) }}
- {{ $t("ui.click_to_view_tournament") }} + {{ + linksToTournament(grant) + ? $t("ui.click_to_view_tournament") + : $t("ui.click_to_view_award") + }}
@@ -224,6 +222,7 @@ interface AwardEntry { id: string; placement?: number | null; placement_tier?: string | null; + source?: string | null; tournament_id?: string | null; created_at?: string | null; award?: { @@ -289,12 +288,8 @@ export default { recentAwards(team: { id: string }): AwardEntry[] { return [...this.teamAwards(team)] .sort((a, b) => { - const da = a.tournament?.start - ? new Date(a.tournament.start).getTime() - : 0; - const db = b.tournament?.start - ? new Date(b.tournament.start).getTime() - : 0; + const da = new Date(this.awardDate(a) || 0).getTime(); + const db = new Date(this.awardDate(b) || 0).getTime(); return db - da; }) .slice(0, 5); @@ -302,14 +297,17 @@ export default { extraAwards(team: { id: string }): number { return Math.max(0, this.teamAwards(team).length - 5); }, - placementLabel(placement: number): string { + placementLabel(placement?: number | null): string { + if (placement === null || placement === undefined) { + return this.$t("awards.granted"); + } if (placement === 0) return "MVP"; if (placement === 1) return "1st Place"; if (placement === 2) return "2nd Place"; if (placement === 3) return "3rd Place"; return `#${placement}`; }, - placementColor(placement: number): string { + placementColor(placement?: number | null): string { if (placement === 0) return "hsl(195 85% 60%)"; if (placement === 1) return "hsl(45 95% 60%)"; if (placement === 2) return "hsl(0 0% 78%)"; @@ -328,16 +326,45 @@ export default { }) .toUpperCase(); }, - awardAriaLabel(grant: AwardEntry): string { - const name = + isManual(grant: AwardEntry): boolean { + return grant.source === "manual"; + }, + linksToTournament(grant: AwardEntry): boolean { + return !!grant.tournament_id && !this.isManual(grant); + }, + awardName(grant: AwardEntry): string { + if (this.isManual(grant)) { + return ( + grant.tournament_award?.custom_name || + grant.award?.name || + grant.tournament?.name || + "" + ); + } + return ( grant.tournament_award?.custom_name || grant.tournament?.name || - "tournament"; - return `${this.placementLabel(grant.placement)} — ${name}`; + grant.award?.name || + "" + ); + }, + awardDate(grant: AwardEntry): string | null { + if (this.isManual(grant)) { + return grant.created_at || null; + } + return grant.tournament?.start || grant.created_at || null; }, - goToTournament(tournamentId?: string | null) { - if (!tournamentId) return; - this.$router.push(`/tournaments/${tournamentId}`); + awardAriaLabel(grant: AwardEntry): string { + return `${this.placementLabel(grant.placement)} — ${this.awardName(grant)}`; + }, + openAward(grant: AwardEntry) { + if (this.linksToTournament(grant)) { + this.$router.push(`/tournaments/${grant.tournament_id}`); + return; + } + if (grant.award?.id) { + this.$router.push(`/awards/${grant.award.id}`); + } }, topStarters(team: { roster?: RosterEntry[] }): RosterEntry[] { const roster = team.roster ?? []; diff --git a/components/award/AwardCase.vue b/components/award/AwardCase.vue index d0f9cfcd5..670311e0c 100644 --- a/components/award/AwardCase.vue +++ b/components/award/AwardCase.vue @@ -9,6 +9,7 @@ interface AwardGrant { id: string; placement?: number | null; placement_tier?: string | null; + source?: string | null; tournament_id?: string | null; team_id?: string | null; note?: string | null; @@ -67,6 +68,14 @@ function grantTier(grant: AwardGrant): AwardTier { } function grantName(grant: AwardGrant): string { + if (grant.source === "manual") { + return ( + grant.tournament_award?.custom_name || + grant.award?.name || + grant.tournament?.name || + "" + ); + } return ( grant.tournament_award?.custom_name || grant.tournament?.name || @@ -75,6 +84,13 @@ function grantName(grant: AwardGrant): string { ); } +function grantDate(grant: AwardGrant): string | null { + if (grant.source === "manual") { + return grant.created_at || null; + } + return grant.tournament?.start || grant.created_at || null; +} + // Placement medals lead, ordered mvp -> bronze; standalone awards trail them // newest-first so a long tournament history never buries a fresh grant. const TIER_ORDER: Record = { @@ -91,8 +107,8 @@ const sorted = computed(() => { const ta = TIER_ORDER[grantTier(a)]; const tb = TIER_ORDER[grantTier(b)]; if (ta !== tb) return ta - tb; - const da = new Date(a.tournament?.start || a.created_at || 0).getTime(); - const db = new Date(b.tournament?.start || b.created_at || 0).getTime(); + const da = new Date(grantDate(a) || 0).getTime(); + const db = new Date(grantDate(b) || 0).getTime(); return db - da; }); }); @@ -291,11 +307,11 @@ const tierRack = computed(() => { {{ grantName(grant) }}
- {{ formatAwardDate(grant.tournament?.start || grant.created_at) }} + {{ formatAwardDate(grantDate(grant)) }}
diff --git a/components/award/AwardComposer.vue b/components/award/AwardComposer.vue index 35cfbf87d..ae636c06c 100644 --- a/components/award/AwardComposer.vue +++ b/components/award/AwardComposer.vue @@ -681,6 +681,7 @@ async function submit() { diff --git a/components/award/AwardModal.vue b/components/award/AwardModal.vue index c410cbae5..47e811972 100644 --- a/components/award/AwardModal.vue +++ b/components/award/AwardModal.vue @@ -19,6 +19,7 @@ interface AwardGrant { id: string; placement?: number | null; placement_tier?: string | null; + source?: string | null; tournament_id?: string | null; team_id?: string | null; note?: string | null; @@ -91,8 +92,12 @@ const placementLabelKey = computed(() => { const tierColor = computed(() => TIER_COLORS[tier.value]); +const manual = computed(() => props.award.source === "manual"); + const formattedDate = computed(() => { - const iso = props.award.tournament?.start || props.award.created_at; + const iso = manual.value + ? props.award.created_at + : props.award.tournament?.start || props.award.created_at; if (!iso) return null; const d = new Date(iso); if (Number.isNaN(d.getTime())) return null; @@ -110,13 +115,22 @@ const tournamentType = computed( () => props.award.tournament?.stages?.[0]?.type || null, ); -const title = computed( - () => +const title = computed(() => { + if (manual.value) { + return ( + props.award.tournament_award?.custom_name || + props.award.award?.name || + tournamentName.value || + "" + ); + } + return ( props.award.tournament_award?.custom_name || tournamentName.value || props.award.award?.name || - "", -); + "" + ); +}); const awardTeam = computed(() => { const team = props.award.team || props.award.tournament_team?.team || null; diff --git a/components/events/EventHero.vue b/components/events/EventHero.vue index 5af21131c..d9a42ebfd 100644 --- a/components/events/EventHero.vue +++ b/components/events/EventHero.vue @@ -1,5 +1,5 @@ + diff --git a/components/events/EventStandings.vue b/components/events/EventStandings.vue index 2c8196035..02bcda7bf 100644 --- a/components/events/EventStandings.vue +++ b/components/events/EventStandings.vue @@ -3,6 +3,7 @@ import gql from "graphql-tag"; import { computed, ref, watch, onMounted } from "vue"; import { useApolloClient } from "@vue/apollo-composable"; import { Users } from "lucide-vue-next"; +import { NuxtLink } from "#components"; import AwardBadge from "~/components/award/AwardBadge.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; import { Skeleton } from "~/components/ui/skeleton"; @@ -343,7 +344,7 @@ const medalTable = computed(() => { size="sm" /> +import { NuxtLink } from "#components"; import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar"; import Empty from "~/components/ui/empty/Empty.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; @@ -53,7 +54,7 @@ function initials(team: { name: string; short_name?: string | null }): string { class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" > import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; +import { dateLocale } from "~/utilities/dateLocale"; const props = withDefaults( defineProps<{ @@ -33,10 +34,19 @@ const now = ref(Date.now()); let ticker: ReturnType | null = null; let rollTimer: ReturnType | null = null; -onMounted(() => { +const DAY_SECONDS = 86400; + +function startTicker(intervalMs: number) { + if (ticker) { + clearInterval(ticker); + } ticker = setInterval(() => { now.value = Date.now(); - }, 1000); + }, intervalMs); +} + +onMounted(() => { + startTicker(coarse.value ? 60_000 : 1000); }); onBeforeUnmount(() => { @@ -65,11 +75,40 @@ const remaining = computed(() => { return Math.max(0, Math.floor((at - now.value) / 1000)); }); +// A match check-in is fifteen minutes, so H:MM:SS always reads as a clock. The +// same readout is reused for a tournament check-in window that can open weeks +// out, where the clock degenerates into an unreadable "1390:29:38". Past a day +// the countdown stops being a clock and becomes a distance: days and hours, +// and no seconds -- nothing about a two-month wait is per-second news. +const coarse = computed( + () => remaining.value !== null && remaining.value >= DAY_SECONDS, +); + +// Intl carries the unit suffix per locale ("57d", "57 j", "57天"), which keeps +// this out of the translation files entirely. +function formatUnit(value: number, unit: "day" | "hour") { + try { + return new Intl.NumberFormat(dateLocale(), { + style: "unit", + unit, + unitDisplay: "narrow", + }).format(value); + } catch { + return `${value}${unit === "day" ? "d" : "h"}`; + } +} + const label = computed(() => { if (remaining.value === null) { return null; } + if (coarse.value) { + const days = Math.floor(remaining.value / DAY_SECONDS); + const hours = Math.floor((remaining.value % DAY_SECONDS) / 3600); + return `${formatUnit(days, "day")} ${formatUnit(hours, "hour")}`; + } + const hours = Math.floor(remaining.value / 3600); const minutes = Math.floor((remaining.value % 3600) / 60); const seconds = (remaining.value % 60).toString().padStart(2, "0"); @@ -79,6 +118,15 @@ const label = computed(() => { : `${minutes}:${seconds}`; }); +// Crossing back under a day swaps the readout from hourly to per-second, so +// the ticker has to follow it -- otherwise the last day of the countdown ticks +// once a minute, and the final seconds never move. +watch(coarse, (isCoarse) => { + if (ticker) { + startTicker(isCoarse ? 60_000 : 1000); + } +}); + // Strictly under a minute: at exactly 60 the readout still says "1:00", and an // alarm that fires while the clock reads a full minute left looks broken. const urgent = computed(() => remaining.value !== null && remaining.value < 60); @@ -93,7 +141,12 @@ const critical = computed( const rolling = ref(false); watch( - () => (remaining.value === null ? null : Math.floor(remaining.value / 60)), + () => + remaining.value === null + ? null + : // In coarse mode the smallest digit on screen is the hour, so a + // per-minute roll would animate a value that never changed. + Math.floor(remaining.value / (coarse.value ? 3600 : 60)), (minute, previous) => { if (minute === null || previous === null || previous === undefined) { return; diff --git a/components/match/MatchOptionsDisplay.vue b/components/match/MatchOptionsDisplay.vue index 5c0993ef7..13c5df464 100644 --- a/components/match/MatchOptionsDisplay.vue +++ b/components/match/MatchOptionsDisplay.vue @@ -251,7 +251,7 @@ const BooleanPill = defineComponent({ $t("match.options.substitutes") }}
{{ - options.number_of_substitutes + substitutes ?? options.number_of_substitutes }}
@@ -307,6 +307,11 @@ export default { type: Boolean, default: false, }, + // Tournaments can override options.number_of_substitutes (Duel has none). + substitutes: { + type: Number, + default: null, + }, }, data() { return { diff --git a/components/match/MatchTabs.vue b/components/match/MatchTabs.vue index 85d016483..ca102d320 100644 --- a/components/match/MatchTabs.vue +++ b/components/match/MatchTabs.vue @@ -678,6 +678,9 @@ provide("commander", commander);
diff --git a/components/tournament/TournamentCheckInPanel.vue b/components/tournament/TournamentCheckInPanel.vue index a32c35187..c78596b15 100644 --- a/components/tournament/TournamentCheckInPanel.vue +++ b/components/tournament/TournamentCheckInPanel.vue @@ -209,7 +209,33 @@ function formatClock(iso: string | null) { }); } +// The window's shape ("between 4:00 PM and 4:15 PM") is a clock, but the +// headline announcing when it opens is a date: check-in on a tournament two +// months out opens on a specific day, and "opens at 4:00 PM" next to a +// multi-week countdown reads as today. +function formatWhen(iso: string | null) { + if (!iso) { + return "--:--"; + } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return "--:--"; + } + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return formatClock(iso); + } + return date.toLocaleString(dateLocale(), { + month: "short", + day: "numeric", + year: date.getFullYear() === today.getFullYear() ? undefined : "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + const opensAtLabel = computed(() => formatClock(opensAtIso.value)); +const opensAtWhen = computed(() => formatWhen(opensAtIso.value)); const closesAtLabel = computed(() => formatClock(closesAtIso.value)); const checkedInTeamCount = computed( @@ -254,7 +280,10 @@ const myRosterCheckedInCount = computed( // tail of the un-confirmed players, recomputed as people check in, rather than // a fixed starter/substitute split the schema does not actually have. const myRosterRows = computed(() => { - let stillNeeded = Math.max(0, minPlayers.value - myRosterCheckedInCount.value); + let stillNeeded = Math.max( + 0, + minPlayers.value - myRosterCheckedInCount.value, + ); return myRoster.value.map((member) => { const checkedIn = !!member.checked_in_at; let state: "checked_in" | "waiting" | "not_needed"; @@ -320,8 +349,7 @@ const canCheckInSelf = computed(() => { }); const registrationOpen = computed( - () => - props.tournament?.status === e_tournament_status_enum.RegistrationOpen, + () => props.tournament?.status === e_tournament_status_enum.RegistrationOpen, ); // An organizer watching a Captains-mode window is reading the field, not being @@ -352,7 +380,9 @@ const openHint = computed(() => { }); } if (isObserver.value && !isAdminMode.value) { - return t("tournament.check_in.observer_hint", { time: closesAtLabel.value }); + return t("tournament.check_in.observer_hint", { + time: closesAtLabel.value, + }); } if (isPlayersMode.value) { return t("tournament.check_in.players_hint", { count: minPlayers.value }); @@ -597,7 +627,7 @@ async function checkIn(teamId?: string | null) { class="mb-1 mt-[0.6rem] font-sans text-[1.05rem] font-bold tracking-[0.01em] text-foreground" > {{ - $t("tournament.check_in.opens_at_heading", { time: opensAtLabel }) + $t("tournament.check_in.opens_at_heading", { time: opensAtWhen }) }}

@@ -624,7 +654,9 @@ async function checkIn(teamId?: string | null) {