Rotations feature

This commit is contained in:
Kalle
2026-03-09 20:29:06 +02:00
parent 2e96ff71ef
commit 231e1d9799
30 changed files with 1006 additions and 19 deletions

View File

@@ -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);
}
}

View File

@@ -1215,6 +1215,19 @@ export interface NotificationUserSubscription {
subscription: JSONColumnType<NotificationSubscription>;
}
export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
export interface SplatoonRotation {
id: GeneratedAlways<number>;
type: SplatoonRotationType;
mode: string;
stageId1: number;
stageId2: number;
startTime: number;
endTime: number;
}
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
export type TablesUpdatable = { [P in keyof DB]: Updateable<DB[P]> };
@@ -1311,4 +1324,5 @@ export interface DB {
NotificationUser: NotificationUser;
NotificationUserSubscription: NotificationUserSubscription;
SavedTournament: SavedTournament;
SplatoonRotation: SplatoonRotation;
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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<string, string> = {
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<typeof loader>();
const [activeFilter, setActiveFilter] =
React.useState<RotationModeFilter>("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 (
<div className={styles.rotationsContainer}>
<div className={styles.rotationsScroll}>
{sortedEntries.map(([type, { current, next, nextAfter }]) =>
current || next ? (
<RotationCard
key={type}
type={type}
current={current}
next={next}
nextAfter={nextAfter}
/>
) : null,
)}
</div>
<div className={styles.rotationsFooter}>
{/** xxx: can we use some shared component? */}
<div className={styles.rotationsModeFilter}>
{ROTATION_MODE_FILTERS.map((filter) => (
<button
key={filter}
type="button"
className={clsx(
styles.rotationsModeFilterButton,
activeFilter === filter
? styles.rotationsModeFilterButtonActive
: null,
)}
onClick={() => handleFilterChange(filter)}
>
{filter === "ALL" ? t("front:rotations.filter.all") : filter}
</button>
))}
</div>
<span className={styles.rotationsCredit}>
<a href={SPLATOON_3_INK} target="_blank" rel="noopener noreferrer">
{t("front:rotations.credit")}
</a>
</span>
</div>
</div>
);
}
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 (
<div className={styles.rotationCard}>
<div className={styles.rotationCardType}>
<ModeImage mode={displayRotation.mode as RankedModeShort} width={20} />
{t(`front:${ROTATION_TYPE_LABELS[type]}` as any)}
</div>
{current && remaining ? (
<div className={styles.rotationCardProgress}>
<div
className={styles.rotationCardProgressBar}
style={{ width: `${remaining.progress * 100}%` }}
/>
<span className={styles.rotationCardProgressText}>
{t("front:rotations.remaining", {
hours: remaining.hours,
minutes: remaining.minutes,
})}
</span>
</div>
) : null}
{!current && next && nextStartsIn ? (
<div
className={clsx(
styles.rotationCardProgress,
styles.rotationCardProgressStriped,
)}
>
<span className={styles.rotationCardProgressText}>
<NextLabel startTimeUnix={next.startTime} startsIn={nextStartsIn} />
</span>
</div>
) : null}
<div className={styles.rotationCardStages}>
<StageImage
stageId={displayRotation.stageId1 as StageId}
className={styles.rotationCardStageImage}
/>
<StageImage
stageId={displayRotation.stageId2 as StageId}
className={styles.rotationCardStageImage}
/>
</div>
{shownNext ? (
<div className={styles.rotationCardNext}>
<div className={styles.rotationCardNextInfo}>
{current && shownNext.startTime === current.endTime ? (
t("front:rotations.nextLabel")
) : shownNextStartsIn ? (
<NextLabel
startTimeUnix={shownNext.startTime}
startsIn={shownNextStartsIn}
compact
/>
) : null}
<ModeImage mode={shownNext.mode as RankedModeShort} width={16} />{" "}
{t(`game-misc:STAGE_${shownNext.stageId1}` as any).split(" ")[0]},{" "}
{t(`game-misc:STAGE_${shownNext.stageId2}` as any).split(" ")[0]}
</div>
</div>
) : null}
</div>
);
}
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)),
});
}

View File

@@ -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<typeof loader>;
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,
};
};

View File

@@ -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() {
<Main className={styles.frontPageContainer}>
<LeagueBanner />
<SeasonBanner />
<SplatoonRotations />
<ResultHighlights />
<ChangelogList />
</Main>

View File

@@ -0,0 +1,44 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
type FindAllResult = Awaited<ReturnType<typeof queryAll>>;
let cachedRotations: FindAllResult | null = null;
export async function replaceAll(
rotations: Omit<TablesInsertable["SplatoonRotation"], "id">[],
) {
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();
}

View File

@@ -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<string, number> = {
"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<string, RankedModeShort> = {
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<TablesInsertable["SplatoonRotation"], "id">[]
> {
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<TablesInsertable["SplatoonRotation"], "id">[] = [];
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;
}

View File

@@ -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];

View File

@@ -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);
}

View File

@@ -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}`;

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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"
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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": ""
}

View File

@@ -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();
})();
}

View File

@@ -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);
});