From 231e1d97994be7e54efa90fee1de40f6dec2e4ba Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:29:06 +0200 Subject: [PATCH] Rotations feature --- app/db/seed/index.ts | 74 +++++ app/db/tables.ts | 14 + app/entry.server.tsx | 7 + .../components/SplatoonRotations.module.css | 128 ++++++++ .../components/SplatoonRotations.tsx | 290 ++++++++++++++++++ .../front-page/loaders/index.server.ts | 8 +- app/features/front-page/routes/index.tsx | 5 +- .../SplatoonRotationRepository.server.ts | 44 +++ .../splatoon-rotations.server.ts | 159 ++++++++++ app/routines/list.server.ts | 4 + app/routines/syncSplatoonRotations.ts | 15 + app/utils/urls.ts | 2 + locales/da/front.json | 15 +- locales/de/front.json | 15 +- locales/en/front.json | 15 +- locales/es-ES/front.json | 15 +- locales/es-US/front.json | 15 +- locales/fr-CA/front.json | 15 +- locales/fr-EU/front.json | 15 +- locales/he/front.json | 15 +- locales/it/front.json | 15 +- locales/ja/front.json | 15 +- locales/ko/front.json | 15 +- locales/nl/front.json | 15 +- locales/pl/front.json | 15 +- locales/pt-BR/front.json | 15 +- locales/ru/front.json | 15 +- locales/zh/front.json | 15 +- migrations/122-splatoon-rotation.js | 16 + scripts/sync-splatoon-rotations.ts | 19 ++ 30 files changed, 1006 insertions(+), 19 deletions(-) create mode 100644 app/features/front-page/components/SplatoonRotations.module.css create mode 100644 app/features/front-page/components/SplatoonRotations.tsx create mode 100644 app/features/splatoon-rotations/SplatoonRotationRepository.server.ts create mode 100644 app/features/splatoon-rotations/splatoon-rotations.server.ts create mode 100644 app/routines/syncSplatoonRotations.ts create mode 100644 migrations/122-splatoon-rotation.js create mode 100644 scripts/sync-splatoon-rotations.ts diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 977124994..a8f783d25 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -239,6 +239,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ notifications, () => friendships(variation), liveStreams, + splatoonRotations, ]; export async function seed(variation?: SeedVariation | null) { @@ -307,6 +308,7 @@ function wipeDB() { "TournamentOrganization", "SeedingSkill", "LiveStream", + "SplatoonRotation", ]; for (const table of tablesToDelete) { @@ -2917,3 +2919,75 @@ function liveStreams() { }); } } + +function splatoonRotations() { + const nowUnix = Math.floor(Date.now() / 1000); + const TWO_HOURS = 2 * 60 * 60; + + const currentStart = nowUnix - (nowUnix % TWO_HOURS); + const currentEnd = currentStart + TWO_HOURS; + const nextStart = currentEnd; + const nextEnd = nextStart + TWO_HOURS; + + const rotationData = [ + { + type: "SERIES", + mode: "SZ", + stageId1: 0, + stageId2: 3, + startTime: currentStart, + endTime: currentEnd, + }, + { + type: "SERIES", + mode: "TC", + stageId1: 5, + stageId2: 8, + startTime: nextStart, + endTime: nextEnd, + }, + { + type: "OPEN", + mode: "RM", + stageId1: 1, + stageId2: 4, + startTime: currentStart, + endTime: currentEnd, + }, + { + type: "OPEN", + mode: "CB", + stageId1: 6, + stageId2: 9, + startTime: nextStart, + endTime: nextEnd, + }, + { + type: "X", + mode: "CB", + stageId1: 2, + stageId2: 7, + startTime: currentStart, + endTime: currentEnd, + }, + { + type: "X", + mode: "SZ", + stageId1: 10, + stageId2: 11, + startTime: nextStart, + endTime: nextEnd, + }, + ]; + + for (const rotation of rotationData) { + sql + .prepare( + ` + insert into "SplatoonRotation" ("type", "mode", "stageId1", "stageId2", "startTime", "endTime") + values ($type, $mode, $stageId1, $stageId2, $startTime, $endTime) + `, + ) + .run(rotation); + } +} diff --git a/app/db/tables.ts b/app/db/tables.ts index 1e6d89eeb..a7f026abb 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1215,6 +1215,19 @@ export interface NotificationUserSubscription { subscription: JSONColumnType; } +export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const; +export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number]; + +export interface SplatoonRotation { + id: GeneratedAlways; + type: SplatoonRotationType; + mode: string; + stageId1: number; + stageId2: number; + startTime: number; + endTime: number; +} + export type Tables = { [P in keyof DB]: Selectable }; export type TablesInsertable = { [P in keyof DB]: Insertable }; export type TablesUpdatable = { [P in keyof DB]: Updateable }; @@ -1311,4 +1324,5 @@ export interface DB { NotificationUser: NotificationUser; NotificationUserSubscription: NotificationUserSubscription; SavedTournament: SavedTournament; + SplatoonRotation: SplatoonRotation; } diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 33ebd1aee..e394b5bf4 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -13,6 +13,7 @@ import { daily, everyHourAt00, everyHourAt30, + everyTwoHours, everyTwoMinutes, } from "./routines/list.server"; import { logger } from "./utils/logger"; @@ -110,6 +111,12 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") { } }); + cron.schedule("5 */2 * * *", async () => { + for (const routine of everyTwoHours) { + await routine.run(); + } + }); + cron.schedule("*/2 * * * *", async () => { for (const routine of everyTwoMinutes) { await routine.run(); diff --git a/app/features/front-page/components/SplatoonRotations.module.css b/app/features/front-page/components/SplatoonRotations.module.css new file mode 100644 index 000000000..db792e828 --- /dev/null +++ b/app/features/front-page/components/SplatoonRotations.module.css @@ -0,0 +1,128 @@ +.rotationsContainer { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.rotationsFooter { + display: flex; + align-items: center; + justify-content: space-between; +} + +.rotationsCredit { + font-size: var(--font-2xs); +} + +.rotationsCredit a { + color: var(--color-text-high); + text-decoration: underline; +} + +.rotationsModeFilter { + display: flex; + gap: var(--s-1); +} + +.rotationsModeFilterButton { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + padding: var(--s-0-5) var(--s-2); + border-radius: var(--radius-field); + background-color: var(--color-bg-higher); + color: var(--color-text); + cursor: pointer; + border: none; + transition: background-color 0.15s; +} + +.rotationsModeFilterButtonActive { + background-color: var(--color-accent-high); + color: var(--color-text-inverse); +} + +.rotationsScroll { + display: flex; + gap: var(--s-3); + overflow-x: auto; +} + +.rotationCard { + flex: 1 1 0; + border-radius: var(--radius-box); + padding: var(--s-2); + display: flex; + flex-direction: column; + gap: var(--s-1-5); + min-width: 230px; +} + +.rotationCardType { + font-size: var(--font-xs); + font-weight: var(--weight-bold); + display: flex; + align-items: center; + gap: var(--s-1); +} + +.rotationCardProgress { + position: relative; + height: 18px; + background-color: var(--color-bg-high); + border-radius: var(--radius-field); + overflow: hidden; +} + +.rotationCardProgressBar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + background-color: var(--color-border); + border-radius: var(--radius-field); +} + +.rotationCardProgressStriped { + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 6px, + var(--color-bg) 6px, + var(--color-bg) 12px + ); +} + +.rotationCardProgressText { + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + font-size: var(--font-2xs); +} + +.rotationCardStages { + display: flex; + gap: var(--s-1); +} + +.rotationCardStageImage { + border-radius: var(--radius-field); + flex: 1; + min-width: 0; +} + +.rotationCardNext { + color: var(--color-text-high); + + img { + margin-inline: var(--s-0-5); + } +} + +.rotationCardNextInfo { + font-size: var(--font-xs); + display: flex; + align-items: center; + gap: var(--s-0-5); +} diff --git a/app/features/front-page/components/SplatoonRotations.tsx b/app/features/front-page/components/SplatoonRotations.tsx new file mode 100644 index 000000000..abe7dbed1 --- /dev/null +++ b/app/features/front-page/components/SplatoonRotations.tsx @@ -0,0 +1,290 @@ +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { ModeImage, StageImage } from "~/components/Image"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import type { RankedModeShort, StageId } from "~/modules/in-game-lists/types"; +import { databaseTimestampNow } from "~/utils/dates"; +import { SPLATOON_3_INK } from "~/utils/urls"; +import type { FrontPageLoaderData, loader } from "../loaders/index.server"; +import styles from "./SplatoonRotations.module.css"; + +const ROTATION_MODE_FILTERS = ["ALL", "SZ", "TC", "RM", "CB"] as const; +type RotationModeFilter = (typeof ROTATION_MODE_FILTERS)[number]; + +// xxx: maybe we can avoid some added translations to front.json by using native web i18n apis instead + +const ROTATION_TYPE_LABELS: Record = { + SERIES: "rotations.series", + OPEN: "rotations.open", + X: "rotations.x", +}; + +type RotationFromLoader = FrontPageLoaderData["rotations"][number]; + +const TYPE_ORDER = ["X", "SERIES", "OPEN"]; + +export function SplatoonRotations() { + const { t } = useTranslation(["front"]); + const data = useLoaderData(); + const [activeFilter, setActiveFilter] = + React.useState("ALL"); + + if (data.rotations.length === 0) return null; + + const nowUnix = databaseTimestampNow(); + + const rotationsByType = new Map< + string, + { + current: RotationFromLoader | undefined; + next: RotationFromLoader | undefined; + nextAfter: RotationFromLoader | undefined; + } + >(); + + for (const rotation of data.rotations) { + if (activeFilter !== "ALL" && rotation.mode !== activeFilter) continue; + + const isCurrent = + rotation.startTime <= nowUnix && rotation.endTime > nowUnix; + const isNext = rotation.startTime > nowUnix; + + if (!isCurrent && !isNext) continue; + + const existing = rotationsByType.get(rotation.type) ?? { + current: undefined, + next: undefined, + nextAfter: undefined, + }; + + if (isCurrent && !existing.current) { + existing.current = rotation; + } + if (isNext && !existing.next) { + existing.next = rotation; + } else if (isNext && existing.next && !existing.nextAfter) { + existing.nextAfter = rotation; + } + + rotationsByType.set(rotation.type, existing); + } + + const handleFilterChange = (filter: RotationModeFilter) => { + setActiveFilter(filter); + }; + + const sortedEntries = Array.from(rotationsByType.entries()).sort( + (a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]), + ); + + return ( +
+
+ {sortedEntries.map(([type, { current, next, nextAfter }]) => + current || next ? ( + + ) : null, + )} +
+
+ {/** xxx: can we use some shared component? */} +
+ {ROTATION_MODE_FILTERS.map((filter) => ( + + ))} +
+ + + {t("front:rotations.credit")} + + +
+
+ ); +} + +function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) { + const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); + + React.useEffect(() => { + const interval = setInterval(() => { + setNow(Math.floor(Date.now() / 1000)); + }, 60_000); + return () => clearInterval(interval); + }, []); + + const remaining = endTimeUnix - now; + if (remaining <= 0) return null; + + const total = endTimeUnix - startTimeUnix; + const elapsed = now - startTimeUnix; + const progress = total > 0 ? Math.min(1, Math.max(0, elapsed / total)) : 0; + + const hours = Math.floor(remaining / 3600); + const minutes = Math.floor((remaining % 3600) / 60); + return { hours, minutes, progress }; +} + +function useTimeUntil(startTimeUnix: number) { + const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); + + React.useEffect(() => { + const interval = setInterval(() => { + setNow(Math.floor(Date.now() / 1000)); + }, 60_000); + return () => clearInterval(interval); + }, []); + + const diff = startTimeUnix - now; + if (diff <= 0) return null; + + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + return { hours, minutes }; +} + +function RotationCard({ + type, + current, + next, + nextAfter, +}: { + type: string; + current: RotationFromLoader | undefined; + next: RotationFromLoader | undefined; + nextAfter: RotationFromLoader | undefined; +}) { + const { t } = useTranslation(["front", "game-misc"]); + const remaining = useTimeRemaining( + current?.startTime ?? 0, + current?.endTime ?? 0, + ); + const displayRotation = current ?? next; + const nextStartsIn = useTimeUntil(next?.startTime ?? 0); + const nextAfterStartsIn = useTimeUntil(nextAfter?.startTime ?? 0); + const shownNext = current ? next : nextAfter; + const shownNextStartsIn = current ? nextStartsIn : nextAfterStartsIn; + + if (!displayRotation) return null; + + return ( +
+
+ + {t(`front:${ROTATION_TYPE_LABELS[type]}` as any)} +
+ {current && remaining ? ( +
+
+ + {t("front:rotations.remaining", { + hours: remaining.hours, + minutes: remaining.minutes, + })} + +
+ ) : null} + {!current && next && nextStartsIn ? ( +
+ + + +
+ ) : null} +
+ + +
+ {shownNext ? ( +
+
+ {current && shownNext.startTime === current.endTime ? ( + t("front:rotations.nextLabel") + ) : shownNextStartsIn ? ( + + ) : null} + {" "} + {t(`game-misc:STAGE_${shownNext.stageId1}` as any).split(" ")[0]},{" "} + {t(`game-misc:STAGE_${shownNext.stageId2}` as any).split(" ")[0]} +
+
+ ) : null} +
+ ); +} + +function NextLabel({ + startTimeUnix, + startsIn, + compact, +}: { + startTimeUnix: number; + startsIn: { hours: number; minutes: number }; + compact?: boolean; +}) { + const { t } = useTranslation(["front"]); + const { formatTime } = useTimeFormat(); + + const withinTwoHours = startsIn.hours * 60 + startsIn.minutes <= 120; + + if (compact) { + if (withinTwoHours) { + return t("front:rotations.in", { + hours: startsIn.hours, + minutes: startsIn.minutes, + }); + } + return t("front:rotations.at", { + time: formatTime(new Date(startTimeUnix * 1000)), + }); + } + + if (withinTwoHours) { + return t("front:rotations.next", { + hours: startsIn.hours, + minutes: startsIn.minutes, + }); + } + + return t("front:rotations.nextAt", { + time: formatTime(new Date(startTimeUnix * 1000)), + }); +} diff --git a/app/features/front-page/loaders/index.server.ts b/app/features/front-page/loaders/index.server.ts index 37f6e0fbc..ccd05188e 100644 --- a/app/features/front-page/loaders/index.server.ts +++ b/app/features/front-page/loaders/index.server.ts @@ -4,12 +4,16 @@ import * as Changelog from "~/features/front-page/core/Changelog.server"; import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import * as Seasons from "~/features/mmr/core/Seasons"; +import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; +import type { SerializeFrom } from "~/utils/remix"; import { discordAvatarUrl, teamPage, userPage } from "~/utils/urls"; import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server"; +export type FrontPageLoaderData = SerializeFrom; + export const loader = async () => { - const [tournaments, changelog, leaderboards] = await Promise.all([ + const [tournaments, changelog, leaderboards, rotations] = await Promise.all([ ShowcaseTournaments.categorizedTournamentsByUserId(null), cachified({ key: "front-changelog", @@ -21,12 +25,14 @@ export const loader = async () => { }, }), cachedLeaderboards(), + SplatoonRotationRepository.findAll(), ]); return { tournaments, changelog, leaderboards, + rotations, }; }; diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index d2619f768..8da82989c 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -12,6 +12,7 @@ import { BSKYRepostIcon } from "~/components/icons/BSKYRepost"; import { ExternalIcon } from "~/components/icons/External"; import { Main } from "~/components/Main"; import { TournamentCard } from "~/features/calendar/components/TournamentCard"; +import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations"; import type * as Changelog from "~/features/front-page/core/Changelog.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { useIsMounted } from "~/hooks/useIsMounted"; @@ -26,12 +27,11 @@ import { SENDOUQ_PAGE, sqHeaderGuyImageUrl, } from "~/utils/urls"; - import { type LeaderboardEntry, loader } from "../loaders/index.server"; export { loader }; export const handle: SendouRouteHandle = { - i18n: ["front"], + i18n: ["front", "game-misc"], }; export default function FrontPage() { @@ -39,6 +39,7 @@ export default function FrontPage() {
+
diff --git a/app/features/splatoon-rotations/SplatoonRotationRepository.server.ts b/app/features/splatoon-rotations/SplatoonRotationRepository.server.ts new file mode 100644 index 000000000..16bb6137a --- /dev/null +++ b/app/features/splatoon-rotations/SplatoonRotationRepository.server.ts @@ -0,0 +1,44 @@ +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; + +type FindAllResult = Awaited>; +let cachedRotations: FindAllResult | null = null; + +export async function replaceAll( + rotations: Omit[], +) { + await db.transaction().execute(async (trx) => { + await trx.deleteFrom("SplatoonRotation").execute(); + + if (rotations.length > 0) { + await trx.insertInto("SplatoonRotation").values(rotations).execute(); + } + }); + + cachedRotations = await queryAll(); +} + +export async function findAll() { + if (cachedRotations) return cachedRotations; + + const result = await queryAll(); + cachedRotations = result; + + return result; +} + +function queryAll() { + return db + .selectFrom("SplatoonRotation") + .select([ + "SplatoonRotation.id", + "SplatoonRotation.type", + "SplatoonRotation.mode", + "SplatoonRotation.stageId1", + "SplatoonRotation.stageId2", + "SplatoonRotation.startTime", + "SplatoonRotation.endTime", + ]) + .orderBy("SplatoonRotation.startTime", "asc") + .execute(); +} diff --git a/app/features/splatoon-rotations/splatoon-rotations.server.ts b/app/features/splatoon-rotations/splatoon-rotations.server.ts new file mode 100644 index 000000000..de6b5e8f5 --- /dev/null +++ b/app/features/splatoon-rotations/splatoon-rotations.server.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; +import type { TablesInsertable } from "~/db/tables"; +import type { RankedModeShort } from "~/modules/in-game-lists/types"; +import { SPLATOON3_INK_SCHEDULES_URL } from "~/utils/urls"; + +// xxx: use stagesObj, ensure that has all names on type level +const STAGE_NAME_TO_ID: Record = { + "Scorch Gorge": 0, + "Eeltail Alley": 1, + "Hagglefish Market": 2, + "Undertow Spillway": 3, + "Mincemeat Metalworks": 4, + "Hammerhead Bridge": 5, + "Museum d'Alfonsino": 6, + "Mahi-Mahi Resort": 7, + "Inkblot Art Academy": 8, + "Sturgeon Shipyard": 9, + MakoMart: 10, + "Wahoo World": 11, + "Flounder Heights": 12, + "Brinewater Springs": 13, + "Manta Maria": 14, + "Um'ami Ruins": 15, + "Humpback Pump Track": 16, + "Barnacle & Dime": 17, + "Crableg Capital": 18, + "Shipshape Cargo Co.": 19, + "Bluefin Depot": 20, + "Robo ROM-en": 21, + "Marlin Airport": 22, + "Lemuria Hub": 23, + "Urchin Underpass": 24, +}; + +const RULE_TO_MODE: Record = { + AREA: "SZ", + LOFT: "TC", + GOAL: "RM", + CLAM: "CB", +}; + +const vsStageSchema = z.object({ + name: z.string(), + image: z.object({ url: z.string() }), +}); + +const vsRuleSchema = z.object({ + name: z.string(), + rule: z.string(), +}); + +const bankaraMatchSettingSchema = z.object({ + vsStages: z.array(vsStageSchema), + vsRule: vsRuleSchema, + bankaraMode: z.enum(["CHALLENGE", "OPEN"]), +}); + +const bankaraNodeSchema = z.object({ + startTime: z.string(), + endTime: z.string(), + bankaraMatchSettings: z.array(bankaraMatchSettingSchema).nullable(), +}); + +const xMatchSettingSchema = z + .object({ + vsStages: z.array(vsStageSchema), + vsRule: vsRuleSchema, + }) + .nullable(); + +const xNodeSchema = z.object({ + startTime: z.string(), + endTime: z.string(), + xMatchSetting: xMatchSettingSchema, +}); + +const schedulesSchema = z.object({ + data: z.object({ + bankaraSchedules: z.object({ + nodes: z.array(bankaraNodeSchema), + }), + xSchedules: z.object({ + nodes: z.array(xNodeSchema), + }), + }), +}); + +function resolveStageId(stageName: string): number | null { + return STAGE_NAME_TO_ID[stageName] ?? null; +} + +function resolveMode(rule: string): RankedModeShort | null { + return RULE_TO_MODE[rule] ?? null; +} + +export async function fetchRotations(): Promise< + Omit[] +> { + const response = await fetch(SPLATOON3_INK_SCHEDULES_URL, { + headers: { "User-Agent": "sendou.ink" }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch schedules: ${response.status} ${response.statusText}`, + ); + } + + const json = await response.json(); + const parsed = schedulesSchema.parse(json); + + const rotations: Omit[] = []; + + for (const node of parsed.data.bankaraSchedules.nodes) { + if (!node.bankaraMatchSettings) continue; + + for (const setting of node.bankaraMatchSettings) { + const mode = resolveMode(setting.vsRule.rule); + if (!mode) continue; + + const stageId1 = resolveStageId(setting.vsStages[0]?.name ?? ""); + const stageId2 = resolveStageId(setting.vsStages[1]?.name ?? ""); + if (stageId1 === null || stageId2 === null) continue; + + const type = setting.bankaraMode === "CHALLENGE" ? "SERIES" : "OPEN"; + + rotations.push({ + type, + mode, + stageId1, + stageId2, + startTime: Math.floor(new Date(node.startTime).getTime() / 1000), + endTime: Math.floor(new Date(node.endTime).getTime() / 1000), + }); + } + } + + for (const node of parsed.data.xSchedules.nodes) { + if (!node.xMatchSetting) continue; + + const mode = resolveMode(node.xMatchSetting.vsRule.rule); + if (!mode) continue; + + const stageId1 = resolveStageId(node.xMatchSetting.vsStages[0]?.name ?? ""); + const stageId2 = resolveStageId(node.xMatchSetting.vsStages[1]?.name ?? ""); + if (stageId1 === null || stageId2 === null) continue; + + rotations.push({ + type: "X", + mode, + stageId1, + stageId2, + startTime: Math.floor(new Date(node.startTime).getTime() / 1000), + endTime: Math.floor(new Date(node.endTime).getTime() / 1000), + }); + } + + return rotations; +} diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index c66a0601d..594d21d4d 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -6,6 +6,7 @@ import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon"; import { NotifySeasonStartRoutine } from "./notifySeasonStart"; import { SetOldGroupsAsInactiveRoutine } from "./setOldGroupsAsInactive"; import { SyncLiveStreamsRoutine } from "./syncLiveStreams"; +import { SyncSplatoonRotationsRoutine } from "./syncSplatoonRotations"; import { UpdatePatreonDataRoutine } from "./updatePatreonData"; /** List of Routines that should occur hourly at XX:00 */ @@ -28,5 +29,8 @@ export const daily = [ CloseExpiredCommissionsRoutine, ]; +/** List of Routines that should occur every 2 hours */ +export const everyTwoHours = [SyncSplatoonRotationsRoutine]; + /** List of Routines that should occur every 2 minutes */ export const everyTwoMinutes = [SyncLiveStreamsRoutine]; diff --git a/app/routines/syncSplatoonRotations.ts b/app/routines/syncSplatoonRotations.ts new file mode 100644 index 000000000..a81fc0a77 --- /dev/null +++ b/app/routines/syncSplatoonRotations.ts @@ -0,0 +1,15 @@ +import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server"; +import { fetchRotations } from "~/features/splatoon-rotations/splatoon-rotations.server"; +import { Routine } from "./routine.server"; + +export const SyncSplatoonRotationsRoutine = new Routine({ + name: "SyncSplatoonRotations", + func: syncSplatoonRotations, +}); + +async function syncSplatoonRotations() { + if (import.meta.env.VITE_PROD_MODE !== "true") return; + + const rotations = await fetchRotations(); + await SplatoonRotationRepository.replaceAll(rotations); +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index f0d1fb480..d88a84152 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -72,6 +72,8 @@ export const RHODESMAS_FREESOUND_PROFILE_URL = "https://freesound.org/people/rhodesmas/"; export const SPR_INFO_URL = "https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf"; +export const SPLATOON3_INK_SCHEDULES_URL = + "https://splatoon3.ink/data/schedules.json"; export const bskyUrl = (accountName: string) => `https://bsky.app/profile/${accountName}`; diff --git a/locales/da/front.json b/locales/da/front.json index a90957736..ccdff4a44 100644 --- a/locales/da/front.json +++ b/locales/da/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/de/front.json b/locales/de/front.json index a90957736..ccdff4a44 100644 --- a/locales/de/front.json +++ b/locales/de/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/en/front.json b/locales/en/front.json index 43f045745..825019137 100644 --- a/locales/en/front.json +++ b/locales/en/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "Friends", "mobileNav.you": "You", "mobileNav.login": "Login", - "mobileNav.queue": "Queue" + "mobileNav.queue": "Queue", + "rotations.header": "Rotations", + "rotations.series": "Anarchy Series", + "rotations.open": "Anarchy Open", + "rotations.x": "X Battle", + "rotations.current": "Now", + "rotations.next": "Next (in {{hours}}h {{minutes}}m)", + "rotations.nextAt": "Next (at {{time}})", + "rotations.in": "in {{hours}}h {{minutes}}m", + "rotations.at": "at {{time}}", + "rotations.nextLabel": "Next", + "rotations.credit": "Data from splatoon3.ink", + "rotations.filter.all": "All", + "rotations.remaining": "{{hours}}h {{minutes}}m" } diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index a90957736..ccdff4a44 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/es-US/front.json b/locales/es-US/front.json index a90957736..ccdff4a44 100644 --- a/locales/es-US/front.json +++ b/locales/es-US/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json index a90957736..ccdff4a44 100644 --- a/locales/fr-CA/front.json +++ b/locales/fr-CA/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json index 098c0a105..c70610d68 100644 --- a/locales/fr-EU/front.json +++ b/locales/fr-EU/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/he/front.json b/locales/he/front.json index a90957736..ccdff4a44 100644 --- a/locales/he/front.json +++ b/locales/he/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/it/front.json b/locales/it/front.json index 298214f4e..dcab8cbbc 100644 --- a/locales/it/front.json +++ b/locales/it/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/ja/front.json b/locales/ja/front.json index a90957736..ccdff4a44 100644 --- a/locales/ja/front.json +++ b/locales/ja/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/ko/front.json b/locales/ko/front.json index a90957736..ccdff4a44 100644 --- a/locales/ko/front.json +++ b/locales/ko/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/nl/front.json b/locales/nl/front.json index a90957736..ccdff4a44 100644 --- a/locales/nl/front.json +++ b/locales/nl/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/pl/front.json b/locales/pl/front.json index a90957736..ccdff4a44 100644 --- a/locales/pl/front.json +++ b/locales/pl/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json index a90957736..ccdff4a44 100644 --- a/locales/pt-BR/front.json +++ b/locales/pt-BR/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/ru/front.json b/locales/ru/front.json index 363feb8e5..08bcdb3fc 100644 --- a/locales/ru/front.json +++ b/locales/ru/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/locales/zh/front.json b/locales/zh/front.json index a90957736..ccdff4a44 100644 --- a/locales/zh/front.json +++ b/locales/zh/front.json @@ -29,5 +29,18 @@ "mobileNav.friends": "", "mobileNav.you": "", "mobileNav.login": "", - "mobileNav.queue": "" + "mobileNav.queue": "", + "rotations.header": "", + "rotations.series": "", + "rotations.open": "", + "rotations.x": "", + "rotations.current": "", + "rotations.next": "", + "rotations.nextAt": "", + "rotations.in": "", + "rotations.at": "", + "rotations.nextLabel": "", + "rotations.credit": "", + "rotations.filter.all": "", + "rotations.remaining": "" } diff --git a/migrations/122-splatoon-rotation.js b/migrations/122-splatoon-rotation.js new file mode 100644 index 000000000..91784fc35 --- /dev/null +++ b/migrations/122-splatoon-rotation.js @@ -0,0 +1,16 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "SplatoonRotation" ( + "id" integer primary key, + "type" text not null, + "mode" text not null, + "stageId1" integer not null, + "stageId2" integer not null, + "startTime" integer not null, + "endTime" integer not null + ) strict`, + ).run(); + })(); +} diff --git a/scripts/sync-splatoon-rotations.ts b/scripts/sync-splatoon-rotations.ts new file mode 100644 index 000000000..d85128a8f --- /dev/null +++ b/scripts/sync-splatoon-rotations.ts @@ -0,0 +1,19 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script */ +import "dotenv/config"; +import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server"; +import { fetchRotations } from "~/features/splatoon-rotations/splatoon-rotations.server"; + +async function main() { + console.log("Fetching splatoon rotations from splatoon3.ink..."); + + const rotations = await fetchRotations(); + console.log(`Fetched ${rotations.length} rotations`); + + await SplatoonRotationRepository.replaceAll(rotations); + console.log("Rotations synced to database"); +} + +main().catch((err) => { + console.error("Failed to sync rotations:", err); + process.exit(1); +});