mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 04:54:25 -05:00
Rotations feature
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
128
app/features/front-page/components/SplatoonRotations.module.css
Normal file
128
app/features/front-page/components/SplatoonRotations.module.css
Normal 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);
|
||||
}
|
||||
290
app/features/front-page/components/SplatoonRotations.tsx
Normal file
290
app/features/front-page/components/SplatoonRotations.tsx
Normal 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)),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
159
app/features/splatoon-rotations/splatoon-rotations.server.ts
Normal file
159
app/features/splatoon-rotations/splatoon-rotations.server.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
15
app/routines/syncSplatoonRotations.ts
Normal file
15
app/routines/syncSplatoonRotations.ts
Normal 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);
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
16
migrations/122-splatoon-rotation.js
Normal file
16
migrations/122-splatoon-rotation.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
19
scripts/sync-splatoon-rotations.ts
Normal file
19
scripts/sync-splatoon-rotations.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user