Availability nudge

This commit is contained in:
Kalle
2026-08-27 21:48:29 +03:00
parent 14b113147b
commit f0b41b87c9
56 changed files with 747 additions and 56 deletions

View File

@@ -17,6 +17,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge";
import { useChatContext } from "~/features/chat/ChatProvider";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
@@ -127,6 +128,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
{activePanel === "tourneys" ? (
<TourneysPanel
events={sidebarData?.events ?? []}
showScheduleNudge={sidebarData?.scheduleNudge ?? false}
onClose={closePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
@@ -503,12 +505,14 @@ function FriendsPanel({
function TourneysPanel({
events,
showScheduleNudge,
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
events: NonNullable<SidebarData>["events"];
showScheduleNudge: boolean;
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
@@ -525,6 +529,7 @@ function TourneysPanel({
isLoggedIn={isLoggedIn}
skipAnimation={skipAnimation}
>
{showScheduleNudge ? <ScheduleNudge panel onNavigate={onClose} /> : null}
<EventsList events={events} onClick={onClose} />
<Link
to={EVENTS_PAGE}

View File

@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge";
import { useChatContext } from "~/features/chat/ChatProvider";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useLayoutData } from "~/features/layout/LayoutDataProvider";
@@ -277,6 +278,7 @@ export function Layout({
sidebarData?.incomingFriendRequestIds ?? [],
);
const streams = sidebarData?.streams ?? [];
const showScheduleNudge = sidebarData?.scheduleNudge ?? false;
const isFrontPage = location.pathname === "/";
@@ -306,6 +308,7 @@ export function Layout({
>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{showScheduleNudge ? <ScheduleNudge /> : null}
{events.length > 0 ? (
events.map((event) => (
<ListLink

View File

@@ -67,6 +67,8 @@ export interface UserPreferences {
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
/** Start of the week the schedule sidebar nudge was last dismissed for, so it stays gone until the horizon rolls over. */
scheduleNudgeDismissedWeekStartsAt?: number;
}
export type Pronouns = {

View File

@@ -195,6 +195,102 @@ describe("AvailabilityRepository.deleteWeeksStartedBefore", () => {
});
});
describe("AvailabilityRepository.hasReportedWeek", () => {
beforeEach(async () => {
await users.create(1);
});
test("finds the week even when it was reported in another timezone", async () => {
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: Availability.weekStartsAt(
new Date(WEEK_STARTS_AT * 1000 + 3 * 24 * 60 * 60 * 1000),
"Asia/Tokyo",
),
timezone: "Asia/Tokyo",
});
expect(
await AvailabilityRepository.hasReportedWeek({
userId: users.id(1),
weekStartsAt: WEEK_STARTS_AT,
}),
).toBe(true);
});
test("does not confuse a neighbouring week for the asked one", async () => {
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: NEXT_WEEK_STARTS_AT,
});
expect(
await AvailabilityRepository.hasReportedWeek({
userId: users.id(1),
weekStartsAt: WEEK_STARTS_AT,
}),
).toBe(false);
});
});
describe("AvailabilityRepository.findWeekReminderUserIds", () => {
beforeEach(async () => {
await users.create(4);
});
const reminderUserIds = () =>
AvailabilityRepository.findWeekReminderUserIds(WEEK_STARTS_AT);
test("reminds the members whose teammate reported the week", async () => {
await TeamFactory.create({
memberUserIds: [users.id(1), users.id(2), users.id(3)],
});
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: WEEK_STARTS_AT,
});
expect(await reminderUserIds()).toEqual([users.id(2), users.id(3)]);
});
test("reminds nobody on a team where nobody reported the week", async () => {
await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: NEXT_WEEK_STARTS_AT,
});
expect(await reminderUserIds()).toEqual([]);
});
test("reminds a user once even when several of their teams qualify", async () => {
await TeamFactory.create({ memberUserIds: [users.id(1), users.id(3)] });
await TeamFactory.create({
memberUserIds: [users.id(2), users.id(3)],
isMainTeam: false,
});
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: WEEK_STARTS_AT,
});
await AvailabilityWeekFactory.create({
userId: users.id(2),
weekStartsAt: WEEK_STARTS_AT,
});
expect(await reminderUserIds()).toEqual([users.id(3)]);
});
test("leaves users without a team out", async () => {
await AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: WEEK_STARTS_AT,
});
expect(await reminderUserIds()).toEqual([]);
});
});
describe("AvailabilityRepository.findTeamEventsByTeamId", () => {
beforeEach(async () => {
await users.create(2);

View File

@@ -1,3 +1,4 @@
import * as R from "remeda";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
@@ -6,6 +7,7 @@ import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
} from "~/utils/kysely.server";
import { AVAILABILITY } from "./availability-constants";
import type { TimeRange } from "./availability-types";
/** Longest a week can be, a DST week included. Weeks are indexed by their start, so finding the ones overlapping a window means looking this far back. */
@@ -65,6 +67,80 @@ export function findAllWeeksByUserIds({
.execute();
}
/**
* Whether the user has reported the week starting at `weekStartsAt`. The week
* is theirs to place, so a start within {@link AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS}
* of the asked one is the same week seen from another timezone.
*/
export async function hasReportedWeek({
userId,
weekStartsAt,
}: {
userId: number;
weekStartsAt: number;
}) {
const week = await db
.selectFrom("AvailabilityWeek")
.select("AvailabilityWeek.id")
.where("AvailabilityWeek.userId", "=", userId)
.where(
"AvailabilityWeek.weekStartsAt",
">",
weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
)
.where(
"AvailabilityWeek.weekStartsAt",
"<",
weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
)
.executeTakeFirst();
return Boolean(week);
}
/**
* Ids of the users who have not reported the week starting at `weekStartsAt`
* while at least one of their teammates has — the reminder is only worth
* sending when somebody else on the team already moved.
*/
export async function findWeekReminderUserIds(weekStartsAt: number) {
const memberships = await db
.selectFrom("TeamMemberWithSecondary")
.leftJoin("AvailabilityWeek", (join) =>
join
.onRef("AvailabilityWeek.userId", "=", "TeamMemberWithSecondary.userId")
.on(
"AvailabilityWeek.weekStartsAt",
">",
weekStartsAt - AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
)
.on(
"AvailabilityWeek.weekStartsAt",
"<",
weekStartsAt + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
),
)
.select([
"TeamMemberWithSecondary.userId",
"TeamMemberWithSecondary.teamId",
"AvailabilityWeek.id as reportedWeekId",
])
.execute();
const userIds = new Set<number>();
for (const team of Object.values(
R.groupBy(memberships, (membership) => membership.teamId),
)) {
if (!team.some((member) => member.reportedWeekId !== null)) continue;
for (const member of team) {
if (member.reportedWeekId === null) userIds.add(member.userId);
}
}
return Array.from(userIds);
}
/**
* Team events of every team the given users are members of (secondary teams
* included) that overlap the given window, one row per member.
@@ -256,7 +332,7 @@ export function deleteWeeksStartedBefore(weekStartsAt: number) {
return db
.deleteFrom("AvailabilityWeek")
.where("AvailabilityWeek.weekStartsAt", "<", weekStartsAt)
.execute();
.executeTakeFirstOrThrow();
}
/**

View File

@@ -2,69 +2,99 @@ import { addWeeks } from "date-fns";
import type { ActionFunction } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import { saveWeekSchema } from "../availability-schemas";
import { eventsActionSchema } from "../availability-schemas";
import * as Availability from "../core/Availability";
const DAY_SECONDS = 24 * 60 * 60;
export const action: ActionFunction = async ({ request }) => {
requireUser();
const user = requireUser();
const data = await parseRequestPayload({ request, schema: saveWeekSchema });
const timezone = getViewerTimezone() ?? "UTC";
const weekStartsAt = Availability.localToTimestamp({
date: data.days[0].date,
time: "00:00",
timezone,
const data = await parseRequestPayload({
request,
schema: eventsActionSchema,
});
const timezone = getViewerTimezone() ?? "UTC";
const now = new Date();
errorToastIfFalsy(
R.range(0, AVAILABILITY.WEEK_HORIZON).some(
(weekOffset) =>
Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) ===
switch (data._action) {
case "SAVE_WEEK": {
const weekStartsAt = Availability.localToTimestamp({
date: data.days[0].date,
time: "00:00",
timezone,
});
errorToastIfFalsy(
R.range(0, AVAILABILITY.WEEK_HORIZON).some(
(weekOffset) =>
Availability.weekStartsAt(addWeeks(now, weekOffset), timezone) ===
weekStartsAt,
),
"Only the current and the next week can be saved",
);
errorToastIfFalsy(
data.days.every(
(day, dayIndex) =>
day.date ===
Availability.dateInTimezone(
weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
timezone,
),
),
"Days do not form one week",
);
await AvailabilityRepository.upsertOwnWeek({
weekStartsAt,
),
"Only the current and the next week can be saved",
);
errorToastIfFalsy(
data.days.every(
(day, dayIndex) =>
day.date ===
Availability.dateInTimezone(
weekStartsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
timezone,
slots: data.days.flatMap((day) =>
day.ranges.map((range) => ({
startsAt: Availability.dayMinutesToTimestamp({
date: day.date,
minutes: range.start,
timezone,
}),
endsAt: Availability.dayMinutesToTimestamp({
date: day.date,
minutes: range.end,
timezone,
}),
})),
),
dayNotes: data.days.flatMap((day) =>
day.note ? [{ date: day.date, text: day.note }] : [],
),
});
await resolveNotifications({
userIds: [user.id],
type: "SCHEDULE_TEAM_REMINDER",
});
break;
}
case "DISMISS_SCHEDULE_NUDGE": {
await UserRepository.updateOwnPreferences({
scheduleNudgeDismissedWeekStartsAt: Availability.weekStartsAt(
addWeeks(now, 1),
timezone,
),
),
"Days do not form one week",
);
});
await AvailabilityRepository.upsertOwnWeek({
weekStartsAt,
timezone,
slots: data.days.flatMap((day) =>
day.ranges.map((range) => ({
startsAt: Availability.dayMinutesToTimestamp({
date: day.date,
minutes: range.start,
timezone,
}),
endsAt: Availability.dayMinutesToTimestamp({
date: day.date,
minutes: range.end,
timezone,
}),
})),
),
dayNotes: data.days.flatMap((day) =>
day.note ? [{ date: day.date, text: day.note }] : [],
),
});
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -44,6 +44,16 @@ export const saveWeekSchema = v.object({
days: v.pipe(v.array(editorDaySchema), v.length(7)),
});
export const dismissScheduleNudgeSchema = v.object({
_action: _action("DISMISS_SCHEDULE_NUDGE"),
revalidateRoot: v.optional(v.nullable(v.literal(true))),
});
export const eventsActionSchema = v.union([
saveWeekSchema,
dismissScheduleNudgeSchema,
]);
const teamEventDurationItems = [
{ label: "options.duration.30m" as const, value: "30" },
{ label: "options.duration.1h" as const, value: "60" },

View File

@@ -0,0 +1,37 @@
.container {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5) var(--s-2);
border-bottom: 1.5px solid var(--color-border);
}
/** Flush against the events header, bleeding past the sidebar's own padding. */
.sidebar {
margin-block-start: calc(-1 * var(--s-2));
margin-inline: calc(-1 * var(--s-1-5));
}
/** Same, for the mobile events panel. */
.panel {
margin-block-start: calc(-1 * var(--s-2));
margin-inline: calc(-1 * var(--s-2));
}
.link {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-accent);
&:hover {
text-decoration: underline;
}
}
.dismissButton {
margin-inline-start: auto;
color: var(--color-text-high);
}

View File

@@ -0,0 +1,63 @@
import clsx from "clsx";
import { CalendarPlus, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { EVENTS_PAGE } from "~/utils/urls";
import { dismissScheduleNudgeSchema } from "../availability-schemas";
import { scheduleWeekSearchParams } from "../availability-search-params";
import styles from "./ScheduleNudge.module.css";
/**
* Prompt to report next week's availability, shown on the last day of the week
* while next week is still empty. Sits as a band right under the events header.
* Dismissing it is remembered for the week, so it can be waved away without
* filling anything in.
*/
export function ScheduleNudge({
panel,
onNavigate,
}: {
/** Bleeds past the mobile events panel's padding rather than the sidebar's. */
panel?: boolean;
onNavigate?: () => void;
}) {
const { t } = useTranslation(["front"]);
const [dismissed, setDismissed] = React.useState(false);
const { submit } = useActionSubmit(dismissScheduleNudgeSchema, {
action: EVENTS_PAGE,
encType: "application/json",
});
if (dismissed) return null;
const dismiss = () => {
setDismissed(true);
submit("DISMISS_SCHEDULE_NUDGE", { revalidateRoot: true });
};
return (
<div
className={clsx(styles.container, panel ? styles.panel : styles.sidebar)}
>
<Link
to={scheduleWeekSearchParams.href(EVENTS_PAGE, { week: "next" })}
className={styles.link}
onClick={onNavigate}
>
<CalendarPlus size={14} />
{t("front:sideNav.scheduleNudge")}
</Link>
<SendouButton
icon={<X size={14} />}
variant="minimal"
size="miniscule"
className={styles.dismissButton}
aria-label={t("front:sideNav.scheduleNudge.dismiss")}
onPress={dismiss}
/>
</div>
);
}

View File

@@ -420,6 +420,47 @@ describe("Availability.isoWeekNumber", () => {
});
});
describe("Availability.isFirstDayOfWeek", () => {
test.each([
{ why: "a Monday", date: "2026-08-24", is: true },
{ why: "a Sunday", date: "2026-08-30", is: false },
{ why: "a Wednesday", date: "2026-08-26", is: false },
])("resolves $why to $is", ({ date, is }) => {
expect(
Availability.isFirstDayOfWeek(
new Date(at(date, "12:00") * 1000),
HELSINKI,
),
).toBe(is);
});
});
describe("Availability.isLastDayOfWeek", () => {
test.each([
{ why: "a Sunday", date: "2026-08-30", is: true },
{ why: "a Monday", date: "2026-08-24", is: false },
{ why: "a Saturday", date: "2026-08-29", is: false },
])("resolves $why to $is", ({ date, is }) => {
expect(
Availability.isLastDayOfWeek(
new Date(at(date, "12:00") * 1000),
HELSINKI,
),
).toBe(is);
});
test("resolves an instant by the timezone's local day", () => {
const mondayEarlyHelsinki = new Date(at("2026-08-31", "01:00") * 1000);
expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, HELSINKI)).toBe(
false,
);
expect(Availability.isLastDayOfWeek(mondayEarlyHelsinki, LOS_ANGELES)).toBe(
true,
);
});
});
describe("Availability.playableWindows", () => {
const members = (
ranges: Array<Array<[start: string, end: string, endDate?: string]>>,

View File

@@ -1,5 +1,12 @@
import { TZDate } from "@date-fns/tz";
import { addWeeks, format, getISOWeek, startOfWeek } from "date-fns";
import {
addWeeks,
format,
getISOWeek,
isMonday,
isSunday,
startOfWeek,
} from "date-fns";
import * as R from "remeda";
import {
databaseTimestampToJavascriptTimestamp,
@@ -43,6 +50,16 @@ export function weekRange(date: Date, timezone: string): TimeRange {
};
}
/** Whether `date` falls on the first day of its week (Monday), as the week is seen in `timezone`. */
export function isFirstDayOfWeek(date: Date, timezone: string) {
return isMonday(new TZDate(date.getTime(), timezone));
}
/** Whether `date` falls on the last day of its week (Sunday), as the week is seen in `timezone`. */
export function isLastDayOfWeek(date: Date, timezone: string) {
return isSunday(new TZDate(date.getTime(), timezone));
}
/** ISO week number of the week the timestamp falls in, as seen in `timezone`. */
export function isoWeekNumber(timestamp: number, timezone: string) {
return getISOWeek(inTimezone(timestamp, timezone));

View File

@@ -12,7 +12,7 @@ import { GIT_COMMIT } from "~/utils/git-commit";
export async function resolveLayoutData(user: AuthenticatedUser | undefined) {
return {
loggedInUserId: user?.id ?? null,
sidebar: await resolveSidebarData(user?.id ?? null),
sidebar: await resolveSidebarData(user),
buildCommit: GIT_COMMIT,
};
}

View File

@@ -40,6 +40,7 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
COMMISSIONS_CLOSED: "normal",
FRIEND_REQUEST_RECEIVED: "normal",
TEAM_EVENT_ADDED: "normal",
SCHEDULE_TEAM_REMINDER: "normal",
};
/** How long a push notification is held back before sending. Anything marking the notification as seen during this window (the user addressing what it is about, opening the notification list, `defaultSeenUserIds`) cancels the push for that user. */

View File

@@ -51,6 +51,7 @@ const RESOLUTION_TRIGGERS = {
FRIEND_REQUEST_RECEIVED:
"accepts or declines the request, or the sender cancels it",
TEAM_EVENT_ADDED: "visits the team's schedule page",
SCHEDULE_TEAM_REMINDER: "saves any week of their own schedule",
} as const satisfies Record<Notification["type"], string | null>;
type ResolvableNotificationType = {

View File

@@ -114,7 +114,8 @@ export type Notification =
teamName: string;
teamCustomUrl: string;
}
>;
>
| NotificationItem<"SCHEDULE_TEAM_REMINDER">;
type NotificationItem<
T extends string,

View File

@@ -5,6 +5,7 @@ import { userSeasonsPage } from "~/features/user-page/user-page-urls";
import { assertUnreachable } from "~/utils/types";
import {
badgePage,
EVENTS_PAGE,
FRIENDS_PAGE,
NEW_TROPHY_PAGE,
PLUS_VOTING_PAGE,
@@ -65,6 +66,8 @@ export const notificationNavIcon = (type: Notification["type"]) => {
return "sendou_love";
case "TEAM_EVENT_ADDED":
return "t";
case "SCHEDULE_TEAM_REMINDER":
return "calendar";
default:
assertUnreachable(type);
}
@@ -143,6 +146,9 @@ export const notificationLink = (
case "TEAM_EVENT_ADDED": {
return teamSchedulePage(notification.meta.teamCustomUrl);
}
case "SCHEDULE_TEAM_REMINDER": {
return EVENTS_PAGE;
}
default:
assertUnreachable(notification);
}

View File

@@ -1,9 +1,12 @@
import { cachified } from "@epic-web/cachified";
import { addDays } from "date-fns";
import { addDays, addWeeks } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server";
import { AVAILABILITY } from "~/features/availability/availability-constants";
import * as Availability from "~/features/availability/core/Availability";
import { userIsBanned } from "~/features/ban/core/banned.server";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import {
@@ -28,6 +31,7 @@ import type { SidebarScrim } from "~/features/scrims/ScrimPostRepository.server"
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { scrimsSearchParams } from "~/features/scrims/scrims-search-params";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import { cache, ttl } from "~/utils/cache.server";
@@ -77,7 +81,9 @@ const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3;
const SENDOUQ_QUOTA = 2;
const TOURNAMENT_SUB_QUOTA = 2;
export async function resolveSidebarData(userId: number | null) {
export async function resolveSidebarData(user: AuthenticatedUser | undefined) {
const userId = user?.id ?? null;
if (!userId) {
return {
events: [] as SidebarEvent[],
@@ -85,6 +91,7 @@ export async function resolveSidebarData(userId: number | null) {
streams: await combinedStreamsCached(),
savedTournamentIds: [] as number[],
incomingFriendRequestIds: [] as number[],
scheduleNudge: false,
};
}
@@ -97,6 +104,7 @@ export async function resolveSidebarData(userId: number | null) {
incomingFriendRequestIds,
streamedSendouQMatches,
teamEvents,
scheduleNudge,
] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(userId),
ScrimPostRepository.findUserScrims(userId),
@@ -105,6 +113,7 @@ export async function resolveSidebarData(userId: number | null) {
FriendRepository.findPendingReceivedRequestIds(userId),
resolveSendouQMatchStreams(),
findUpcomingTeamEvents(userId),
showScheduleNudge(user),
]);
const seenTournamentIds = new Set<number>();
@@ -151,9 +160,39 @@ export async function resolveSidebarData(userId: number | null) {
streams: await combinedStreamsCached(),
savedTournamentIds,
incomingFriendRequestIds,
scheduleNudge,
};
}
/**
* Whether to prompt the user to report next week: they are on its last day, it
* is still empty, and they have not waved the prompt away for this week.
*/
async function showScheduleNudge(user: AuthenticatedUser | undefined) {
if (!user) return false;
const timezone = getViewerTimezone() ?? "UTC";
const now = new Date();
if (!Availability.isLastDayOfWeek(now, timezone)) return false;
const weekStartsAt = Availability.weekStartsAt(addWeeks(now, 1), timezone);
const dismissedAt = user.preferences?.scheduleNudgeDismissedWeekStartsAt;
if (
dismissedAt !== undefined &&
Math.abs(dismissedAt - weekStartsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS
) {
return false;
}
return !(await AvailabilityRepository.hasReportedWeek({
userId: user.id,
weekStartsAt,
}));
}
function combinedStreamsCached(): Promise<SidebarStream[]> {
return cachified({
key: COMBINED_STREAMS_KEY,

View File

@@ -56,6 +56,7 @@ import {
useTheme,
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
import { timezoneMiddleware } from "./features/timezone/timezone-middleware.server";
import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard";
import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference";
import { useHydrated } from "./hooks/useHydrated";
@@ -87,6 +88,7 @@ export const middleware: Route.MiddlewareFunction[] = [
sessionIdMiddleware,
userMiddleware,
i18nMiddleware,
timezoneMiddleware,
];
import "~/styles/fonts.css";

View File

@@ -0,0 +1,58 @@
import { subMonths, subWeeks } from "date-fns";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server";
import { AVAILABILITY } from "~/features/availability/availability-constants";
import * as Availability from "~/features/availability/core/Availability";
import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability";
const users = UserFactory.pool();
const NOW = new Date("2026-08-24T09:00:00Z");
const seedWeekOf = (date: Date) =>
AvailabilityWeekFactory.create({
userId: users.id(1),
weekStartsAt: Availability.weekStartsAt(date, "UTC"),
timezone: "UTC",
});
const remainingWeekStarts = async () =>
(
await AvailabilityRepository.findAllWeeksByUserIds({
userIds: [users.id(1)],
startsAt: 0,
endsAt: Availability.weekStartsAt(NOW, "UTC") + 1,
})
).map((week) => week.weekStartsAt);
describe("DeleteOldAvailabilityRoutine", () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
await users.create(1);
});
afterEach(() => {
vi.useRealTimers();
});
test("deletes weeks that ended over the retention period ago, keeping the rest", async () => {
const retentionAgo = subMonths(NOW, AVAILABILITY.RETENTION_MONTHS);
const longGone = subWeeks(retentionAgo, 4);
// the week the retention period reaches into still ends inside it
const justInside = retentionAgo;
await seedWeekOf(longGone);
await seedWeekOf(justInside);
await seedWeekOf(NOW);
await DeleteOldAvailabilityRoutine.run();
expect(await remainingWeekStarts()).toEqual([
Availability.weekStartsAt(justInside, "UTC"),
Availability.weekStartsAt(NOW, "UTC"),
]);
});
});

View File

@@ -0,0 +1,27 @@
import { subDays, subMonths } from "date-fns";
import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server";
import { AVAILABILITY } from "../features/availability/availability-constants";
import { dateToDatabaseTimestamp } from "../utils/dates";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
const WEEK_DAYS = 7;
export const DeleteOldAvailabilityRoutine = new Routine({
name: "DeleteOldAvailability",
func: async () => {
// weeks are indexed by their start, so a week that ended long enough ago
// is one that started a week further back than that
const cutOff = subDays(
subMonths(new Date(), AVAILABILITY.RETENTION_MONTHS),
WEEK_DAYS,
);
const { numDeletedRows } =
await AvailabilityRepository.deleteWeeksStartedBefore(
dateToDatabaseTimestamp(cutOff),
);
logger.info(`Deleted ${numDeletedRows} old availability weeks`);
},
});

View File

@@ -3,6 +3,7 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { ComputeLutiDivsRoutine } from "./computeLutiDivs";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldAvailabilityRoutine } from "./deleteOldAvailability";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendRequests";
@@ -13,6 +14,7 @@ import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTourname
import { ExpireReadyChecksRoutine } from "./expireReadyChecks";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
import { NotifySeasonEndRoutine } from "./notifySeasonEnd";
import { NotifySeasonStartRoutine } from "./notifySeasonStart";
@@ -53,6 +55,8 @@ export const daily = [
DeleteOldPendingFriendRequestsRoutine,
DeleteOldTournamentAuditLogsRoutine,
DeleteOldScrimPickupRostersRoutine,
DeleteOldAvailabilityRoutine,
NotifyScheduleTeamReminderRoutine,
CloseExpiredCommissionsRoutine,
CloseExpiredChatRoomsRoutine,
DeleteOrphanArtTagsRoutine,

View File

@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as Availability from "~/features/availability/core/Availability";
import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder";
const users = UserFactory.pool();
const { mockNotify } = vi.hoisted(() => ({
mockNotify: vi.fn(),
}));
vi.mock("~/features/notifications/core/notify.server", () => ({
notify: mockNotify,
}));
const MONDAY = new Date("2026-08-24T09:00:00Z");
const WEDNESDAY = new Date("2026-08-26T09:00:00Z");
const reportCurrentWeek = (userId: number) =>
AvailabilityWeekFactory.create({
userId,
weekStartsAt: Availability.weekStartsAt(MONDAY, "UTC"),
timezone: "UTC",
});
describe("NotifyScheduleTeamReminderRoutine", () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(MONDAY);
await users.create(2);
mockNotify.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
test("notifies the teammate who has not reported the week", async () => {
await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
await reportCurrentWeek(users.id(1));
await NotifyScheduleTeamReminderRoutine.run();
expect(mockNotify).toHaveBeenCalledWith({
notification: { type: "SCHEDULE_TEAM_REMINDER" },
userIds: [users.id(2)],
});
});
test("notifies nobody when no teammate reported the week", async () => {
await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
await NotifyScheduleTeamReminderRoutine.run();
expect(mockNotify).not.toHaveBeenCalled();
});
test("does nothing on a day that is not the first of the week", async () => {
vi.setSystemTime(WEDNESDAY);
await TeamFactory.create({ memberUserIds: [users.id(1), users.id(2)] });
await reportCurrentWeek(users.id(1));
await NotifyScheduleTeamReminderRoutine.run();
expect(mockNotify).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,39 @@
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../features/admin/core/dev-controls";
import * as AvailabilityRepository from "../features/availability/AvailabilityRepository.server";
import * as Availability from "../features/availability/core/Availability";
import { notify } from "../features/notifications/core/notify.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
/**
* Reminds users whose teammates have reported the week that just started while
* they have not. Runs on Mondays only, which is also what keeps it to at most
* one reminder per user per week.
*/
export const NotifyScheduleTeamReminderRoutine = new Routine({
name: "NotifyScheduleTeamReminder",
func: async () => {
const now = new Date();
// runs whatever the day is when triggered by hand in development
if (
!Availability.isFirstDayOfWeek(now, "UTC") &&
!DANGEROUS_CAN_ACCESS_DEV_CONTROLS
) {
return;
}
const userIds = await AvailabilityRepository.findWeekReminderUserIds(
Availability.weekStartsAt(now, "UTC"),
);
if (userIds.length === 0) return;
logger.info(`Reminding ${userIds.length} users about their schedule`);
await notify({
notification: { type: "SCHEDULE_TEAM_REMINDER" },
userIds,
});
},
});

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "logindforsøg afbrudt",
"auth.errors.failed": "Loginforsøg fejlet",
"auth.errors.discordPermissions": "Før at du kan oprette en profil på sendou.ink, skal sendou.ink have adgang til din Discordprofils navn, brugerbillede og sociale forbindelser (de sociale medier, som du har tilknyttet din discordprofil).",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Einloggen abgebrochen",
"auth.errors.failed": "Einloggen fehlgeschlagen",
"auth.errors.discordPermissions": "Für dein sendou.ink-Profil benötigt die Seite Zugriff auf den Namen, Avatar und verbundene Social-Media-Accounts in deinem Discord-Profil.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} accepted your group invitation in {{tournamentName}}",
"notifications.title.TEAM_EVENT_ADDED": "New Team Event",
"notifications.text.TEAM_EVENT_ADDED": "{{teamName}} has a new event: {{eventName}}",
"notifications.title.SCHEDULE_TEAM_REMINDER": "Availability Missing",
"notifications.text.SCHEDULE_TEAM_REMINDER": "Your teammates are waiting for you to fill your availability for the week",
"auth.errors.aborted": "Login Aborted",
"auth.errors.failed": "Login Failed",
"auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.",
@@ -477,4 +479,4 @@
"tier.confirmed": "{{tierName}}-tier tournament",
"spoilerFree.showResults": "Show results",
"spoilerFree.hideResults": "Hide results"
}
}

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Looking for scrim",
"sideNav.scrimRequestPending": "Request pending",
"sideNav.scheduleNudge": "Add next week's availability",
"sideNav.scheduleNudge.dismiss": "Dismiss",
"mobileNav.menu": "Menu",
"mobileNav.friends": "Friends",
"mobileNav.you": "You",

View File

@@ -7,9 +7,9 @@
"editor.later": "Later",
"editor.notFilled": "not filled",
"editor.note": "Note",
"editor.saved": "Schedule saved",
"editor.saved": "Availability saved",
"editor.saveWeek": "Save week",
"editor.title": "My schedule",
"editor.title": "My availability",
"editor.timesInYourTimezone": "Times in your time zone",
"editor.visibility": "Visible to your teammates and friends",
"events.title": "Team events",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Inicio de Sesión Cancelado",
"auth.errors.failed": "Error al Iniciar Sesión",
"auth.errors.discordPermissions": "Para tu perfil de sendou.ink, el sitio necesita acceso al nombre, avatar y conexiones sociales de tu perfil de Discord.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Buscando scrim",
"sideNav.scrimRequestPending": "Solicitud pendiente",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "Menú",
"mobileNav.friends": "Amigos",
"mobileNav.you": "Tú",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Ingreso cancelado",
"auth.errors.failed": "Ingreso fallido",
"auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere acceso a tu nombre en Discord, avatar, y redes sociales.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "vs. {{opponent}}",
"sideNav.lookingForScrim": "Buscando scrim",
"sideNav.scrimRequestPending": "Solicitud pendiente",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "Menú",
"mobileNav.friends": "Amigos",
"mobileNav.you": "Tú",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "הכניסה בוטלה",
"auth.errors.failed": "הכניסה נכשלה",
"auth.errors.discordPermissions": "עבור פרופיל sendou.ink שלך, האתר זקוק לגישה לשם, הפרופיל והקשרים החברתיים של פרופיל ה-Discord שלך.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Accesso cancellato",
"auth.errors.failed": "Accesso fallito",
"auth.errors.discordPermissions": "Per il tuo profilo di sendou.ink, il sito ha bisogno di accesso al nome utente, avatar e connessioni social del tuo profilo Discord.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}}が{{tournamentName}}への招待が承諾されました",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "ログインを中断しました",
"auth.errors.failed": "ログインに失敗しました",
"auth.errors.discordPermissions": "sendou.ink プロファイルを作成するには、Discord 名、アバター、そしてSNSの連携が必要です。",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "로그인 중단됨",
"auth.errors.failed": "로그인 실패",
"auth.errors.discordPermissions": "sendou.ink 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "",
"auth.errors.failed": "",
"auth.errors.discordPermissions": "",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Logowanie przerwane",
"auth.errors.failed": "Logowanie nieudane",
"auth.errors.discordPermissions": "Do twojego profilu sendou.ink, ta strona potrzebuje dostęp do twojej nazwy, avataru i połączeń konta Discord.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Login Abortado",
"auth.errors.failed": "Login Falhou",
"auth.errors.discordPermissions": "Para o seu perfil do sendou.ink, o site precisa de acesso ao nome do perfil do seu Discord, incluindo também o avatar e conexões sociais.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "Вход отменён",
"auth.errors.failed": "Ошибка входа",
"auth.errors.discordPermissions": "Для вашего профиля на sendou.ink странице нужен доступ к вашему имени, аватару и привязанным аккаунтам соц. сетей в Discord.",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "",
"sideNav.lookingForScrim": "",
"sideNav.scrimRequestPending": "",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "",
"mobileNav.friends": "",
"mobileNav.you": "",

View File

@@ -110,6 +110,8 @@
"notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} 在赛事【{{tournamentName}}】中接受了您的小组邀请",
"notifications.title.TEAM_EVENT_ADDED": "",
"notifications.text.TEAM_EVENT_ADDED": "",
"notifications.title.SCHEDULE_TEAM_REMINDER": "",
"notifications.text.SCHEDULE_TEAM_REMINDER": "",
"auth.errors.aborted": "登录中止",
"auth.errors.failed": "登录失败",
"auth.errors.discordPermissions": "为了完善您的sendou.ink个人资料网站需要获取您的 Discord 名字、头像和社交链接。",

View File

@@ -28,6 +28,8 @@
"sideNav.scrimVs": "对战 {{opponent}}",
"sideNav.lookingForScrim": "寻找对抗战",
"sideNav.scrimRequestPending": "请求待处理",
"sideNav.scheduleNudge": "",
"sideNav.scheduleNudge.dismiss": "",
"mobileNav.menu": "菜单",
"mobileNav.friends": "好友",
"mobileNav.you": "你",