Files
sendou.ink/app/features/availability/loaders/t.$customUrl.schedule.server.ts
2026-09-09 21:46:09 +03:00

186 lines
4.9 KiB
TypeScript

import { addWeeks } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import * as v from "valibot";
import { getUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { teamParamsSchema } from "~/features/team/team-schemas.server";
import { getMemberRoleType, isTeamMember } from "~/features/team/team-utils";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish } from "~/utils/remix.server";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import type {
BusyBlock,
PlayableWindowTier,
TimeRange,
} from "../availability-types";
import * as Availability from "../core/Availability";
import * as ScheduleWeek from "../core/ScheduleWeek";
import * as VisibleSchedules from "../core/VisibleSchedules.server";
export type TeamScheduleLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { customUrl } = v.parse(teamParamsSchema, params);
const team = notFoundIfNullish(
await TeamRepository.findByCustomUrl(customUrl),
);
const user = getUser();
if (!user || !isTeamMember({ team, user })) {
return { weeks: null };
}
await resolveNotifications({
userIds: [user.id],
type: "TEAM_EVENT_ADDED",
meta: { teamCustomUrl: team.customUrl },
});
const members = team.members;
const timezone = getViewerTimezone() ?? "UTC";
const now = new Date();
const horizon = {
startsAt: Availability.weekRange(now, timezone).startsAt,
endsAt: Availability.weekRange(
addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1),
timezone,
).endsAt,
};
const [{ reportedWeeks, busyByUserId }, teamEvents] = await Promise.all([
VisibleSchedules.findByUserIds({
userIds: members.map((member) => member.id),
viewerId: user.id,
...horizon,
}),
// team events are the team's own data, visible to every member no matter what they share
AvailabilityRepository.findTeamEventsByTeamId({
teamId: team.id,
...horizon,
}),
]);
const playerIds = members
.filter((member) => getMemberRoleType(member) !== "OTHER")
.map((member) => member.id);
return {
weeks: R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
weekView({
range: Availability.weekRange(addWeeks(now, weekOffset), timezone),
timezone,
memberIds: members.map((member) => member.id),
playerIds,
reportedWeeks,
busyByUserId,
teamEvents,
}),
),
};
};
type TeamEventRow = Awaited<
ReturnType<typeof AvailabilityRepository.findTeamEventsByTeamId>
>[number];
function weekView({
range,
timezone,
memberIds,
playerIds,
reportedWeeks,
busyByUserId,
teamEvents,
}: {
range: TimeRange;
timezone: string;
memberIds: Array<number>;
playerIds: Array<number>;
reportedWeeks: Array<ScheduleWeek.ReportedWeek>;
busyByUserId: Map<number, Array<BusyBlock>>;
teamEvents: Array<TeamEventRow>;
}) {
const minPlayers = Math.min(
AVAILABILITY.DEFAULT_MIN_PLAYERS,
playerIds.length,
);
const windows = Availability.playableWindows({
members: playerIds.map((userId) => ({
userId,
ranges: Availability.subtract(
Availability.clip(
reportedWeeks
.filter((week) => week.userId === userId)
.flatMap((week) => week.slots),
range,
),
busyByUserId.get(userId) ?? [],
),
})),
minPlayers,
}).map((window) => R.omit(window, ["userIds"]));
const days = ScheduleWeek.days(range, timezone).map((day) => ({
...day,
/** Local midnight starting the day, the zero the heatmap reads track minutes from. */
startsAt: Availability.localToTimestamp({
date: day.date,
time: "00:00",
timezone,
}),
windowTier: bestWindowTierOfDay({ date: day.date, windows, timezone }),
}));
const members = memberIds.map((userId) =>
ScheduleWeek.memberRow({
userId,
days,
timezone,
reportedWeeks,
range,
busy: busyByUserId.get(userId) ?? [],
}),
);
return {
startsAt: range.startsAt,
weekNumber: ScheduleWeek.weekNumber(range, timezone),
days,
members,
windows,
minPlayers,
teamEvents: teamEvents.filter(
(event) =>
event.startsAt >= range.startsAt && event.startsAt < range.endsAt,
),
};
}
/** Tier of the best playable window starting on the viewer-local day (the day it renders its grid ranges on). */
function bestWindowTierOfDay({
date,
windows,
timezone,
}: {
date: string;
windows: Array<TimeRange & { tier: PlayableWindowTier }>;
timezone: string;
}): PlayableWindowTier | null {
const tiers = windows
.filter(
(window) =>
Availability.dateInTimezone(window.startsAt, timezone) === date,
)
.map((window) => window.tier);
if (tiers.includes("FULL")) return "FULL";
if (tiers.includes("ONE_SHORT")) return "ONE_SHORT";
return null;
}