Files
sendou.ink/app/features/availability/core/RosterSchedule.server.ts
2026-09-09 21:46:09 +03:00

185 lines
5.0 KiB
TypeScript

import { addWeeks } from "date-fns";
import * as R from "remeda";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { AVAILABILITY } from "../availability-constants";
import type { TimeRange, WindowSchedule } from "../availability-types";
import * as Availability from "./Availability";
import * as ScheduleWeek from "./ScheduleWeek";
import * as VisibleSchedules from "./VisibleSchedules.server";
const DAY_SECONDS = 24 * 60 * 60;
export type RosterScheduleData = SerializeFrom<
Awaited<ReturnType<typeof rosterScheduleData>>
>;
/**
* Effective availability of the users over the reportable horizon as viewer-local weeks and
* days. Which users form a roster is only known in the browser (scrim post form's team select,
* pick-up search), so members come out one by one and {@link Availability.playableWindows}
* merges the picked ones client side.
*/
export async function rosterScheduleData({
userIds,
timezone,
viewerId,
}: {
userIds: Array<number>;
timezone: string;
viewerId: number;
}) {
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 } = await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...horizon,
});
const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
weekView({
range: Availability.weekRange(addWeeks(now, weekOffset), timezone),
timezone,
}),
);
return {
/** Server clock, so that the picker's cutoff of past windows renders the same before and after hydration. */
now: dateToDatabaseTimestamp(now),
weeks,
members: userIds.map((userId) => {
const memberWeeks = reportedWeeks.filter(
(week) => week.userId === userId,
);
const busy = busyByUserId.get(userId) ?? [];
return {
userId,
reportedWeekStarts: weeks
.filter((week) =>
memberWeeks.some((memberWeek) =>
Availability.isSameWeek(memberWeek.weekStartsAt, week.startsAt),
),
)
.map((week) => week.startsAt),
ranges: Availability.subtract(
Availability.clip(
memberWeeks.flatMap((week) => week.slots),
horizon,
),
busy,
),
};
}),
};
}
function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
const dates = ScheduleWeek.days(range, timezone);
const dayStartsAt = (dayIndex: number) =>
dayIndex === 7
? range.endsAt
: Availability.localToTimestamp({
date: dates[dayIndex].date,
time: "00:00",
timezone,
});
return {
startsAt: range.startsAt,
endsAt: range.endsAt,
weekNumber: ScheduleWeek.weekNumber(range, timezone),
days: R.range(0, 7).map((dayIndex) => {
const startsAt = dayStartsAt(dayIndex);
return {
startsAt,
endsAt: dayStartsAt(dayIndex + 1),
noonAt: startsAt + DAY_SECONDS / 2,
};
}),
};
}
/**
* What the users' schedules say about each window: reported ranges inside it, overriding
* commitments and whether the week was filled in at all. Resolved in one go so a page with many
* windows (scrim fit indicators) reads the schedules once. Windows past the horizon are left out.
*/
export async function windowSchedules({
windows,
userIds,
viewerId,
}: {
windows: Array<TimeRange & { id: number }>;
userIds: Array<number>;
/** Null when logged out, which the scrims page is viewable as. */
viewerId: number | null;
}) {
// the horizon's last week starts at the current week's start at the latest, so nothing inside it reaches this far
const horizonEndsAt = dateToDatabaseTimestamp(
addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON),
);
const withinHorizon = windows.filter(
(window) => window.startsAt < horizonEndsAt,
);
if (withinHorizon.length === 0 || userIds.length === 0 || viewerId === null) {
return [];
}
const range = {
startsAt: Math.min(...withinHorizon.map((window) => window.startsAt)),
endsAt: Math.max(...withinHorizon.map((window) => window.endsAt)),
};
const { reportedWeeks, busyByUserId } = await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...range,
});
return withinHorizon.map((window) => ({
id: window.id,
members: userIds.map((userId): WindowSchedule => {
const memberWeeks = reportedWeeks.filter(
(week) => week.userId === userId,
);
const busy = (busyByUserId.get(userId) ?? []).filter((block) =>
Availability.overlaps(block, window),
);
return {
userId,
// which week a window falls in goes by the member's own clock, the one they filled the week in on
reported: memberWeeks.some(
(week) =>
Availability.weekStartsAt(
databaseTimestampToDate(window.startsAt),
week.timezone,
) === week.weekStartsAt,
),
ranges: Availability.clip(
Availability.subtract(
memberWeeks.flatMap((week) => week.slots),
busy,
),
window,
),
busy,
};
}),
}));
}