mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
Availability nudge
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
63
app/features/availability/components/ScheduleNudge.tsx
Normal file
63
app/features/availability/components/ScheduleNudge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]>>,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -114,7 +114,8 @@ export type Notification =
|
||||
teamName: string;
|
||||
teamCustomUrl: string;
|
||||
}
|
||||
>;
|
||||
>
|
||||
| NotificationItem<"SCHEDULE_TEAM_REMINDER">;
|
||||
|
||||
type NotificationItem<
|
||||
T extends string,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
58
app/routines/deleteOldAvailability.test.ts
Normal file
58
app/routines/deleteOldAvailability.test.ts
Normal 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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
app/routines/deleteOldAvailability.ts
Normal file
27
app/routines/deleteOldAvailability.ts
Normal 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`);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
69
app/routines/notifyScheduleTeamReminder.test.ts
Normal file
69
app/routines/notifyScheduleTeamReminder.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
app/routines/notifyScheduleTeamReminder.ts
Normal file
39
app/routines/notifyScheduleTeamReminder.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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).",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ú",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ú",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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 שלך.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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の連携が必要です。",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "",
|
||||
"sideNav.lookingForScrim": "",
|
||||
"sideNav.scrimRequestPending": "",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "",
|
||||
"mobileNav.friends": "",
|
||||
"mobileNav.you": "",
|
||||
|
||||
@@ -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 名字、头像和社交链接。",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"sideNav.scrimVs": "对战 {{opponent}}",
|
||||
"sideNav.lookingForScrim": "寻找对抗战",
|
||||
"sideNav.scrimRequestPending": "请求待处理",
|
||||
"sideNav.scheduleNudge": "",
|
||||
"sideNav.scheduleNudge.dismiss": "",
|
||||
"mobileNav.menu": "菜单",
|
||||
"mobileNav.friends": "好友",
|
||||
"mobileNav.you": "你",
|
||||
|
||||
Reference in New Issue
Block a user