Friend availabilities

This commit is contained in:
Kalle
2026-08-29 10:55:04 +03:00
parent 038c2b7f16
commit a75b115a40
34 changed files with 865 additions and 223 deletions

View File

@@ -188,7 +188,9 @@ export async function seedAvailability({
],
fillsNextWeek: true,
},
...misc.adminFriendIds.map((userId, index) => ({
// the last of the admin's friends reports nothing, so the friends page has
// a row with no schedule to sort below the ones that have one
...misc.adminFriendIds.slice(0, -1).map((userId, index) => ({
userId,
timezone: "Europe/Helsinki",
weekly: EVENINGS,

View File

@@ -102,3 +102,17 @@ export interface WindowSchedule {
/** Their commitments overlapping the window. */
busy: Array<BusyBlock>;
}
/**
* One person's week as the read-only week views render it: the seven days in
* the viewer's timezone with the time they are effectively free to play. What
* a commitment takes back is already cut out — the view answers "when can they
* play", not "what are they doing".
*/
export interface ScheduleWeekView {
week: "current" | "next";
weekNumber: number;
/** Whether they filled the week in at all. */
reported: boolean;
days: Array<{ noonAt: number; ranges: Array<TimeRange> }>;
}

View File

@@ -0,0 +1,44 @@
.content {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.range {
white-space: nowrap;
}
.unknown,
.unavailable {
color: var(--color-text-high);
}
.busy {
display: flex;
align-items: center;
max-width: 10rem;
padding: var(--s-0-5) var(--s-1-5);
background: repeating-linear-gradient(
-45deg,
var(--color-bg-higher) 0 5px,
transparent 5px 10px
);
border-radius: var(--radius-full);
align-self: flex-start;
& .busyName {
max-width: 100%;
padding-inline: var(--s-1);
font-size: var(--font-3xs);
color: var(--color-text-high);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--color-bg);
border-radius: var(--radius-full);
}
}
.noteFlag {
color: var(--color-text-accent);
}

View File

@@ -0,0 +1,94 @@
import { isSameDay } from "date-fns";
import { Flag } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { BusyBlock, TimeRange } from "../availability-types";
import styles from "./ScheduleDayCell.module.css";
/**
* One day of one person's week: the ranges they are effectively free for and,
* where the surface shows them, the commitments taking time back and the note
* they left on the day. Shared by the team schedule grid and the single person
* week view so the two cannot drift apart.
*/
export function ScheduleDayCell({
reported,
ranges,
busy = [],
note,
}: {
/** False when they have not filled the week in at all, which reads differently from being unavailable. */
reported: boolean;
ranges: Array<TimeRange>;
busy?: Array<BusyBlock>;
note?: string;
}) {
const { t } = useTranslation(["schedule"]);
const rangeText = useRangeText();
const busyName = (block: BusyBlock) =>
block.name ?? t("schedule:commitment.scrim");
return (
<div className={styles.content}>
{!reported ? (
<span className={styles.unknown} title={t("schedule:team.noSchedule")}>
?
</span>
) : ranges.length === 0 && busy.length === 0 ? (
<span
className={styles.unavailable}
title={t("schedule:team.notAvailable")}
>
</span>
) : (
ranges.map((range) => (
<div
key={range.startsAt}
className={styles.range}
data-testid="schedule-range"
>
{rangeText(range)}
</div>
))
)}
{busy.map((block) => (
<div
key={block.startsAt}
className={styles.busy}
title={`${rangeText(block)} · ${busyName(block)}`}
data-testid="schedule-busy"
>
<span className={styles.busyName}>{busyName(block)}</span>
</div>
))}
{note ? (
<span title={note}>
<Flag className={styles.noteFlag} size={12} aria-hidden />
</span>
) : null}
</div>
);
}
/**
* Formats a range as times only. `formatRange` expands to full dates when the
* ends fall on different calendar days, so a range crossing (or ending exactly
* at) midnight formats its ends separately.
*/
function useRangeText() {
const { formatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
return (range: TimeRange) =>
isSameDay(
databaseTimestampToDate(range.startsAt),
databaseTimestampToDate(range.endsAt),
)
? formatter.formatRange(range.startsAt, range.endsAt)
: `${formatter.format(range.startsAt)} ${formatter.format(range.endsAt)}`;
}

View File

@@ -0,0 +1,40 @@
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
}
.weekLabel {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.days {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
list-style: none;
font-size: var(--font-xs);
}
.day {
display: flex;
align-items: baseline;
gap: var(--s-3);
padding-block: var(--s-1-5);
&:not(:first-child) {
border-top: var(--border-style);
}
}
.dayLabel {
flex-shrink: 0;
width: 4.5rem;
font-weight: var(--weight-semi);
color: var(--color-text-high);
}

View File

@@ -0,0 +1,101 @@
import { useTranslation } from "react-i18next";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { scheduleWeekSearchParams } from "../availability-search-params";
import type { ScheduleWeekView } from "../availability-types";
import { ScheduleDayCell } from "./ScheduleDayCell";
import styles from "./ScheduleWeekDialog.module.css";
/**
* One person's reportable weeks as a read-only day-by-day list of the time
* they are free to play.
*/
export function ScheduleWeekDialog({
username,
weeks,
onClose,
}: {
username: string;
weeks: Array<ScheduleWeekView>;
onClose: () => void;
}) {
const { t } = useTranslation(["schedule"]);
const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams);
const { formatter: headingFormatter } = useDateTimeFormat({
month: "short",
day: "numeric",
});
const shownWeek =
weeks.find((candidate) => candidate.week === week) ?? weeks[0];
return (
<SendouDialog
heading={t("schedule:friends.availabilityOf", { name: username })}
onClose={onClose}
isDismissable
>
<div className="stack md">
<div className={styles.header}>
<span className={styles.weekLabel}>
{t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "}
{headingFormatter.formatRange(
shownWeek.days[0].noonAt,
shownWeek.days[6].noonAt,
)}
</span>
<SendouChipRadioGroup>
<SendouChipRadio
name="friend-schedule-week"
value="current"
checked={week === "current"}
onChange={() => setParams({ week: "current" })}
>
{t("schedule:team.currentWeek")}
</SendouChipRadio>
<SendouChipRadio
name="friend-schedule-week"
value="next"
checked={week === "next"}
onChange={() => setParams({ week: "next" })}
>
{t("schedule:team.nextWeek")}
</SendouChipRadio>
</SendouChipRadioGroup>
</div>
{shownWeek.reported ? (
<WeekDays week={shownWeek} />
) : (
<div className="text-lighter text-sm" data-testid="schedule-no-week">
{t("schedule:team.noSchedule")}
</div>
)}
</div>
</SendouDialog>
);
}
function WeekDays({ week }: { week: ScheduleWeekView }) {
const { formatter: dayFormatter } = useDateTimeFormat({
weekday: "short",
day: "numeric",
});
return (
<ul className={styles.days} data-testid="schedule-week-days">
{week.days.map((day) => (
<li key={day.noonAt} className={styles.day}>
<span className={styles.dayLabel}>
{dayFormatter.format(day.noonAt)}
</span>
<ScheduleDayCell reported ranges={day.ranges} />
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,107 @@
import { addWeeks } from "date-fns";
import { beforeEach, describe, expect, test } from "vitest";
import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory";
import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as Availability from "./Availability";
import * as FriendSchedule from "./FriendSchedule.server";
const users = UserFactory.pool();
const friendId = () => users.id(1);
const otherId = () => users.id(2);
const TIMEZONE = "Europe/Helsinki";
const HOUR = 60 * 60;
const currentWeekStartsAt = () =>
Availability.weekStartsAt(new Date(), TIMEZONE);
const nextWeekStartsAt = () =>
Availability.weekStartsAt(addWeeks(new Date(), 1), TIMEZONE);
const weeksOf = async (userId: number) => {
const schedules = await FriendSchedule.findByUserIds({
userIds: [friendId(), otherId()],
timezone: TIMEZONE,
});
return schedules.get(userId);
};
describe("FriendSchedule.findByUserIds", () => {
beforeEach(async () => {
await users.create(2);
});
test("leaves out a user who reported neither week", async () => {
await AvailabilityWeekFactory.create({
userId: friendId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
});
expect(await weeksOf(otherId())).toBeUndefined();
});
test("marks the week they filled in as reported and the other one not", async () => {
await AvailabilityWeekFactory.create({
userId: friendId(),
weekStartsAt: nextWeekStartsAt(),
timezone: TIMEZONE,
});
expect(
(await weeksOf(friendId()))?.map((week) => [week.week, week.reported]),
).toEqual([
["current", false],
["next", true],
]);
});
test("buckets the reported ranges into the days they start on", async () => {
const wednesdayEvening = {
startsAt: currentWeekStartsAt() + 2 * 24 * HOUR + 18 * HOUR,
endsAt: currentWeekStartsAt() + 2 * 24 * HOUR + 22 * HOUR,
};
await AvailabilityWeekFactory.create({
userId: friendId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [wednesdayEvening],
});
const days = (await weeksOf(friendId()))?.[0].days;
expect(days?.flatMap((day) => day.ranges)).toEqual([wednesdayEvening]);
expect(days?.[2].ranges).toEqual([wednesdayEvening]);
});
test("cuts a commitment out of the reported ranges", async () => {
const slot = {
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
};
await AvailabilityWeekFactory.create({
userId: friendId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [slot],
});
const team = await TeamFactory.create({
memberUserIds: [friendId(), otherId()],
});
await TeamEventFactory.create({
teamId: team.id,
authorId: friendId(),
name: "VoD review",
startsAt: slot.startsAt + HOUR,
endsAt: slot.endsAt,
});
const day = (await weeksOf(friendId()))?.[0].days[0];
expect(day?.ranges).toEqual([
{ startsAt: slot.startsAt, endsAt: slot.startsAt + HOUR },
]);
});
});

View File

@@ -0,0 +1,77 @@
import { addWeeks } from "date-fns";
import * as R from "remeda";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import type { ScheduleWeekView } from "../availability-types";
import * as Availability from "./Availability";
import * as Commitments from "./Commitments.server";
import * as ScheduleWeek from "./ScheduleWeek";
/**
* The reportable weeks of the given users as the friends page's week modal
* shows them, keyed by user id: nothing but the time they are free to play,
* commitments already subtracted. Users who reported neither week are left out,
* so a missing key is what "no schedule to show" means — and the friends page
* both sorts and shows its calendar icon by that.
*
* Everyone asked about is a friend or a teammate of the viewer, which is what
* makes their schedule theirs to see; the caller owns that guarantee.
*/
export async function findByUserIds({
userIds,
timezone,
}: {
userIds: Array<number>;
timezone: string;
}): Promise<Map<number, Array<ScheduleWeekView>>> {
const now = new Date();
const ranges = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
Availability.weekRange(addWeeks(now, weekOffset), timezone),
);
const horizon = {
startsAt: ranges[0].startsAt,
endsAt: ranges[ranges.length - 1].endsAt,
};
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...horizon }),
Commitments.busyBlocksByUserIds({ userIds, ...horizon }),
]);
const weeks = ranges.map((range, index) => ({
range,
week: index === 0 ? ("current" as const) : ("next" as const),
weekNumber: ScheduleWeek.weekNumber(range, timezone),
days: ScheduleWeek.days(range, timezone),
}));
return new Map(
userIds.flatMap((userId) => {
const busy = busyByUserId.get(userId) ?? [];
const views = weeks.map((week): ScheduleWeekView => {
const row = ScheduleWeek.memberRow({
userId,
days: week.days,
timezone,
reportedWeeks,
range: week.range,
busy,
});
return {
week: week.week,
weekNumber: week.weekNumber,
reported: row.reported,
days: week.days.map((day, dayIndex) => ({
noonAt: day.noonAt,
ranges: row.days[dayIndex].ranges,
})),
};
});
return views.some((view) => view.reported) ? [[userId, views]] : [];
}),
);
}

View File

@@ -0,0 +1,130 @@
import * as R from "remeda";
import { AVAILABILITY } from "../availability-constants";
import type { BusyBlock, TimeRange } from "../availability-types";
import * as Availability from "./Availability";
const DAY_SECONDS = 24 * 60 * 60;
/** One day of a schedule week, as the viewer's timezone places it. */
export interface ScheduleWeekDay {
/** `YYYY-MM-DD` in the viewer's timezone */
date: string;
noonAt: number;
}
/** A week of reported availability, in the shape the repository returns it. */
export interface ReportedWeek {
userId: number;
weekStartsAt: number;
timezone: string;
slots: Array<TimeRange>;
dayNotes: Array<{ date: string; text: string }>;
}
/** One member's week as the read-only schedule surfaces render it. */
export interface MemberWeek {
userId: number;
/** Whether they filled the week in at all. */
reported: boolean;
days: Array<{ ranges: Array<TimeRange>; busy: Array<BusyBlock> }>;
notes: Array<{ dayIndex: number; text: string }>;
}
/** The seven days a week is laid out on in the viewer's timezone, Monday first. */
export function days(
range: TimeRange,
timezone: string,
): Array<ScheduleWeekDay> {
return R.range(0, 7).map((dayIndex) => {
const noonAt = range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2;
return { date: Availability.dateInTimezone(noonAt, timezone), noonAt };
});
}
/** The week's ISO number, as its heading names it. */
export function weekNumber(range: TimeRange, timezone: string) {
return Availability.isoWeekNumber(range.startsAt + DAY_SECONDS / 2, timezone);
}
/**
* One member's week bucketed into the viewer's days: what they are effectively
* free for, the commitments taking time back and the notes they left.
*
* Slots are placed on the viewer-local day they start on, wherever their
* author's week put them — the adjacent weeks' spillover included. What a
* commitment takes back is cut out first: the days show when the member is
* actually free.
*/
export function memberRow({
userId,
days,
timezone,
reportedWeeks,
range,
busy,
}: {
userId: number;
days: Array<ScheduleWeekDay>;
timezone: string;
reportedWeeks: Array<ReportedWeek>;
range: TimeRange;
busy: Array<BusyBlock>;
}): MemberWeek {
const busyOfDay = (day: ScheduleWeekDay) =>
busy.filter(
(block) =>
Availability.dateInTimezone(block.startsAt, timezone) === day.date,
);
const memberWeeks = reportedWeeks.filter((week) => week.userId === userId);
const matchingWeek = memberWeeks.find(
(week) =>
Math.abs(week.weekStartsAt - range.startsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
);
if (!matchingWeek) {
return {
userId,
reported: false,
days: days.map((day) => ({
ranges: [] as Array<TimeRange>,
busy: busyOfDay(day),
})),
notes: [],
};
}
const slots = Availability.subtract(
memberWeeks.flatMap((week) => week.slots),
busy,
);
return {
userId,
reported: true,
days: days.map((day) => ({
ranges: slots.filter(
(slot) =>
Availability.dateInTimezone(slot.startsAt, timezone) === day.date,
),
busy: busyOfDay(day),
})),
notes: memberWeeks.flatMap((week) =>
week.dayNotes.flatMap((note) => {
const noteDate = Availability.dateInTimezone(
Availability.localToTimestamp({
date: note.date,
time: "12:00",
timezone: week.timezone,
}),
timezone,
);
const dayIndex = days.findIndex((day) => day.date === noteDate);
return dayIndex === -1 ? [] : [{ dayIndex, text: note.text }];
}),
),
};
}

View File

@@ -19,8 +19,7 @@ import type {
} from "../availability-types";
import * as Availability from "../core/Availability";
import * as Commitments from "../core/Commitments.server";
const DAY_SECONDS = 24 * 60 * 60;
import * as ScheduleWeek from "../core/ScheduleWeek";
export type TeamScheduleLoaderData = SerializeFrom<typeof loader>;
@@ -93,10 +92,6 @@ type TeamEventRow = Awaited<
ReturnType<typeof AvailabilityRepository.findTeamEventsByTeamId>
>[number];
type ReportedWeek = Awaited<
ReturnType<typeof AvailabilityRepository.findAllWeeksByUserIds>
>[number];
function weekView({
range,
timezone,
@@ -110,7 +105,7 @@ function weekView({
timezone: string;
memberIds: Array<number>;
playerIds: Array<number>;
reportedWeeks: Array<ReportedWeek>;
reportedWeeks: Array<ScheduleWeek.ReportedWeek>;
busyByUserId: Map<number, Array<BusyBlock>>;
teamEvents: Array<TeamEventRow>;
}) {
@@ -135,19 +130,13 @@ function weekView({
minPlayers,
}).map((window) => R.omit(window, ["userIds"]));
const days = R.range(0, 7).map((dayIndex) => {
const noonAt = range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2;
const date = Availability.dateInTimezone(noonAt, timezone);
return {
date,
noonAt,
windowTier: bestWindowTierOfDay({ date, windows, timezone }),
};
});
const days = ScheduleWeek.days(range, timezone).map((day) => ({
...day,
windowTier: bestWindowTierOfDay({ date: day.date, windows, timezone }),
}));
const members = memberIds.map((userId) =>
memberWeekRow({
ScheduleWeek.memberRow({
userId,
days,
timezone,
@@ -159,10 +148,7 @@ function weekView({
return {
startsAt: range.startsAt,
weekNumber: Availability.isoWeekNumber(
range.startsAt + DAY_SECONDS / 2,
timezone,
),
weekNumber: ScheduleWeek.weekNumber(range, timezone),
days,
members,
windows,
@@ -198,80 +184,3 @@ function bestWindowTierOfDay({
if (tiers.includes("ONE_SHORT")) return "ONE_SHORT";
return null;
}
function memberWeekRow({
userId,
days,
timezone,
reportedWeeks,
range,
busy,
}: {
userId: number;
days: Array<{ date: string; noonAt: number }>;
timezone: string;
reportedWeeks: Array<ReportedWeek>;
range: TimeRange;
busy: Array<BusyBlock>;
}) {
const busyOfDay = (day: { date: string }) =>
busy.filter(
(block) =>
Availability.dateInTimezone(block.startsAt, timezone) === day.date,
);
const memberWeeks = reportedWeeks.filter((week) => week.userId === userId);
const matchingWeek = memberWeeks.find(
(week) =>
Math.abs(week.weekStartsAt - range.startsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
);
if (!matchingWeek) {
return {
userId,
reported: false,
days: days.map((day) => ({
ranges: [] as Array<TimeRange>,
busy: busyOfDay(day),
})),
notes: [] as Array<{ dayIndex: number; text: string }>,
};
}
// slots are placed on the viewer-local day they start on, wherever their
// author's week put them — the adjacent weeks' spillover included. What a
// commitment takes back is cut out first: the grid shows when the member
// is actually free.
const slots = Availability.subtract(
memberWeeks.flatMap((week) => week.slots),
busy,
);
return {
userId,
reported: true,
days: days.map((day) => ({
ranges: slots.filter(
(slot) =>
Availability.dateInTimezone(slot.startsAt, timezone) === day.date,
),
busy: busyOfDay(day),
})),
notes: memberWeeks.flatMap((week) =>
week.dayNotes.flatMap((note) => {
const noteDate = Availability.dateInTimezone(
Availability.localToTimestamp({
date: note.date,
time: "12:00",
timezone: week.timezone,
}),
timezone,
);
const dayIndex = days.findIndex((day) => day.date === noteDate);
return dayIndex === -1 ? [] : [{ dayIndex, text: note.text }];
}),
),
};
}

View File

@@ -69,47 +69,6 @@
}
}
.cellContent {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.range {
white-space: nowrap;
}
.unknown,
.unavailable {
color: var(--color-text-high);
}
.busy {
display: flex;
align-items: center;
max-width: 10rem;
padding: var(--s-0-5) var(--s-1-5);
background: repeating-linear-gradient(
-45deg,
var(--color-bg-higher) 0 5px,
transparent 5px 10px
);
border-radius: var(--radius-full);
align-self: flex-start;
& .busyName {
max-width: 100%;
padding-inline: var(--s-1);
font-size: var(--font-3xs);
color: var(--color-text-high);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--color-bg);
border-radius: var(--radius-full);
}
}
.noteFlag {
color: var(--color-text-accent);
}

View File

@@ -32,6 +32,7 @@ import {
teamScheduleActionSchema,
} from "../availability-schemas";
import { scheduleWeekSearchParams } from "../availability-search-params";
import { ScheduleDayCell } from "../components/ScheduleDayCell";
import type { TeamScheduleLoaderData } from "../loaders/t.$customUrl.schedule.server";
import { loader } from "../loaders/t.$customUrl.schedule.server";
@@ -176,11 +177,7 @@ function ScheduleGrid({ week }: { week: WeekData }) {
{playerRows.map(renderRow)}
{otherRows.length > 0 ? (
<tr>
<th
scope="colgroup"
colSpan={8}
className={styles.sectionDivider}
>
<th scope="colgroup" colSpan={8}>
{t("team:roster.sections.other")}
</th>
</tr>
@@ -201,75 +198,16 @@ function ScheduleCell({
day: MemberWeekRow["days"][number];
dayIndex: number;
}) {
const { t } = useTranslation(["schedule"]);
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
const note = row.notes.find((note) => note.dayIndex === dayIndex);
// formatRange expands to full dates when the ends fall on different
// calendar days, so a range crossing (or ending exactly at) midnight
// formats its ends separately to stay times-only
const rangeText = (range: { startsAt: number; endsAt: number }) =>
isSameDay(
databaseTimestampToDate(range.startsAt),
databaseTimestampToDate(range.endsAt),
)
? timeFormatter.formatRange(range.startsAt, range.endsAt)
: `${timeFormatter.format(range.startsAt)} ${timeFormatter.format(range.endsAt)}`;
const busyName = (block: MemberWeekRow["days"][number]["busy"][number]) =>
block.name ?? t("schedule:commitment.scrim");
return (
<td
className={styles.cell}
data-testid={`schedule-cell-${row.userId}-${dayIndex}`}
>
<div className={styles.cellContent}>
{!row.reported ? (
<span
className={styles.unknown}
title={t("schedule:team.noSchedule")}
>
?
</span>
) : day.ranges.length === 0 && day.busy.length === 0 ? (
<span
className={styles.unavailable}
title={t("schedule:team.notAvailable")}
>
</span>
) : (
day.ranges.map((range) => (
<div
key={range.startsAt}
className={styles.range}
data-testid="schedule-range"
>
{rangeText(range)}
</div>
))
)}
{day.busy.map((block) => (
<div
key={block.startsAt}
className={styles.busy}
title={`${rangeText(block)} · ${busyName(block)}`}
data-testid="schedule-busy"
>
<span className={styles.busyName}>{busyName(block)}</span>
</div>
))}
{note ? (
<span title={note.text}>
<Flag className={styles.noteFlag} size={12} aria-hidden />
</span>
) : null}
</div>
<td data-testid={`schedule-cell-${row.userId}-${dayIndex}`}>
<ScheduleDayCell
reported={row.reported}
ranges={day.ranges}
busy={day.busy}
note={note?.text}
/>
</td>
);
}

View File

@@ -1,5 +1,7 @@
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import * as FriendSchedule from "~/features/availability/core/FriendSchedule.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import { userPage } from "~/utils/urls";
import * as FriendRepository from "../FriendRepository.server";
import { friendActivitySortValue } from "../friends-constants";
@@ -27,6 +29,13 @@ export const loader = async () => {
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
// everyone listed is a friend or a teammate, which is what makes their
// schedule theirs to see
const schedules = await FriendSchedule.findByUserIds({
userIds: unique.map((f) => f.id),
timezone: getViewerTimezone() ?? "UTC",
});
const friends = R.sortBy(
unique
.filter((f) => f.friendshipId !== null)
@@ -58,9 +67,11 @@ export const loader = async () => {
tournamentId: activity.tournamentId ?? friend.tournamentId,
streamUrl: activity.streamUrl,
friendshipCreatedAt: friend.friendshipCreatedAt,
schedule: schedules.get(friend.id) ?? null,
};
}),
[(friend) => friendActivitySortValue(friend.activityType), "desc"],
[(friend) => (friend.schedule ? 1 : 0), "desc"],
[(friend) => friend.friendshipCreatedAt ?? 0, "desc"],
);
@@ -93,9 +104,11 @@ export const loader = async () => {
matchId: activity.matchId,
tournamentId: activity.tournamentId ?? tm.tournamentId,
streamUrl: activity.streamUrl,
schedule: schedules.get(tm.id) ?? null,
};
}),
[(tm) => friendActivitySortValue(tm.activityType), "desc"],
[(tm) => (tm.schedule ? 1 : 0), "desc"],
);
return {

View File

@@ -29,3 +29,19 @@
gap: var(--s-2);
margin-block-end: var(--s-2);
}
.friendRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: var(--s-1-5);
}
.scheduleSlot {
display: flex;
width: 18px;
margin-block-end: var(--s-1);
& > button {
height: auto;
}
}

View File

@@ -1,11 +1,14 @@
import { CalendarDays } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, type MetaFunction, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { ScheduleWeekDialog } from "~/features/availability/components/ScheduleWeekDialog";
import { SendouForm } from "~/form/SendouForm";
import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -39,7 +42,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["friends"],
i18n: ["friends", "schedule"],
};
export default function FriendsPage() {
@@ -221,7 +224,7 @@ function FriendsListSection() {
) : (
<div className="stack xs">
{shownItems.map((item) => (
<FriendMenu key={item.id} name={item.username} {...item} />
<FriendRow key={item.id} item={item} />
))}
</div>
)}
@@ -230,6 +233,58 @@ function FriendsListSection() {
);
}
function FriendRow({ item }: { item: ShownItem }) {
return (
<div className={styles.friendRow} data-testid={`friend-row-${item.id}`}>
<FriendMenu name={item.username} {...item} />
<div className={styles.scheduleSlot}>
{item.schedule ? (
<ScheduleButton
userId={item.id}
username={item.username}
weeks={item.schedule}
/>
) : null}
</div>
</div>
);
}
function ScheduleButton({
userId,
username,
weeks,
}: {
userId: number;
username: string;
weeks: NonNullable<ShownItem["schedule"]>;
}) {
const { t } = useTranslation(["schedule"]);
const [dialogOpen, setDialogOpen] = React.useState(false);
return (
<>
<SendouButton
variant="minimal"
size="small"
icon={<CalendarDays size={18} />}
aria-label={t("schedule:friends.availabilityOf", { name: username })}
testId={`friend-schedule-button-${userId}`}
onPress={() => setDialogOpen(true)}
/>
{dialogOpen ? (
<ScheduleWeekDialog
username={username}
weeks={weeks}
onClose={() => setDialogOpen(false)}
/>
) : null}
</>
);
}
type ShownItem = ReturnType<typeof resolveShownItems>[number];
function resolveShownItems(
filter: ViewFilter,
data: Awaited<ReturnType<FriendsLoaderData>>,
@@ -243,9 +298,10 @@ function resolveShownItems(
...data.teamMembers.filter((tm) => !friendIds.has(tm.id)),
];
return combined.sort((a, b) => {
const aActive = a.subtitle ? 1 : 0;
const bActive = b.subtitle ? 1 : 0;
return bActive - aActive;
});
// same order the loader sorted each group in: active first, then the ones
// who shared a schedule
const sortValue = (item: (typeof combined)[number]) =>
(item.subtitle ? 2 : 0) + (item.schedule ? 1 : 0);
return combined.sort((a, b) => sortValue(b) - sortValue(a));
}

View File

@@ -97,7 +97,7 @@ test.describe("My schedule", () => {
await expect(page).toHaveURL(/\/events/);
await events.locators.saveWeekButton.click();
await expect(page.getByText("Schedule saved")).toBeAttached();
await expect(page.getByText("Availability saved")).toBeAttached();
await events.goto();
await expect(events.locators.availabilityBars).toHaveCount(1);
@@ -112,7 +112,7 @@ test.describe("My schedule", () => {
await isNotVisible(events.locators.dayEditorPopover);
await isNotVisible(events.locators.availabilityBars);
await events.locators.saveWeekButton.click();
await expect(page.getByText("Schedule saved")).toBeAttached();
await expect(page.getByText("Availability saved")).toBeAttached();
// an empty submitted week is "unavailable all week", not missing
await events.goto();
@@ -201,6 +201,6 @@ test.describe("My schedule", () => {
await expect(events.locators.availabilityBars).toHaveCount(1);
await events.locators.saveWeekButton.click();
await expect(page.getByText("Schedule saved")).toBeAttached();
await expect(page.getByText("Availability saved")).toBeAttached();
});
});

View File

@@ -1,8 +1,25 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as Availability from "~/features/availability/core/Availability";
import {
expect,
impersonate,
isNotVisible,
MACHINE_TIMEZONE,
setTimezoneCookie,
test,
} from "./helpers/playwright";
import {
befriend,
createNamedUsers,
expectTopToBottom,
} from "./helpers/sidebar";
import { FriendsPage } from "./pages/friends/friends-page";
import { NotificationPopover } from "./pages/layout/notification-popover";
const WEDNESDAY = 2;
const DAY_SECONDS = 24 * 60 * 60;
test.describe("Friends", () => {
test("send friend request, accept it, then delete friend", async ({
page,
@@ -40,4 +57,93 @@ test.describe("Friends", () => {
await expect(friends.locators.noFriendsText).toBeVisible();
});
test("sorts friends who shared a schedule up and shows their week", async ({
page,
factories,
}) => {
const [scheduled, unscheduled, queueing] = await createNamedUsers(
factories,
["ScheduleFriend", "NoScheduleFriend", "QueueFriend"],
);
await befriend(
factories,
[unscheduled.id, scheduled.id, queueing.id],
ADMIN_ID,
);
await factories.SQGroupFactory.create({ memberUserIds: [queueing.id] });
await factories.AvailabilityWeekFactory.create({
userId: scheduled.id,
weekStartsAt: currentWeek().startsAt,
timezone: MACHINE_TIMEZONE,
slots: [daySlot(WEDNESDAY, "18:00", "22:00")],
});
// a commitment of their own team, which the modal shows only as the free
// time it takes away
const { id: teamId } = await factories.TeamFactory.create({
name: "Schedule Team",
memberUserIds: [scheduled.id],
});
await factories.TeamEventFactory.create({
teamId,
authorId: scheduled.id,
name: "VoD review",
...daySlot(WEDNESDAY, "20:00", "22:00"),
});
await impersonate(page, ADMIN_ID);
await setTimezoneCookie(page);
const friends = new FriendsPage(page);
await friends.goto();
await expectTopToBottom([
friends.row(queueing.id),
friends.row(scheduled.id),
friends.row(unscheduled.id),
]);
await isNotVisible(friends.scheduleButton(unscheduled.id));
await friends.scheduleButton(scheduled.id).click();
await expect(friends.locators.scheduleRanges).toHaveCount(1);
await expect(friends.day(WEDNESDAY)).toContainText("6:00");
await expect(friends.day(WEDNESDAY)).not.toContainText("VoD review");
// they only filled in the current week
await friends.locators.nextWeekToggle.click();
await expect(friends.locators.noScheduleText).toBeVisible();
});
});
function currentWeek() {
return Availability.weekRange(new Date(), MACHINE_TIMEZONE);
}
function currentWeekDates() {
const { startsAt } = currentWeek();
return Array.from({ length: 7 }, (_, dayIndex) =>
Availability.dateInTimezone(
startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
MACHINE_TIMEZONE,
),
);
}
function daySlot(dayIndex: number, start: string, end: string) {
const date = currentWeekDates()[dayIndex];
return {
startsAt: Availability.localToTimestamp({
date,
time: start,
timezone: MACHINE_TIMEZONE,
}),
endsAt: Availability.localToTimestamp({
date,
time: end,
timezone: MACHINE_TIMEZONE,
}),
};
}

View File

@@ -22,6 +22,13 @@ export class FriendsPage {
acceptButton: this.page.getByRole("button", { name: "Accept" }),
cancelRequestButton: this.page.getByRole("button", { name: "Cancel" }),
noFriendsText: this.page.getByText("No friends yet"),
scheduleDays: this.page.getByTestId("schedule-week-days"),
scheduleRanges: this.page.getByTestId("schedule-range"),
noScheduleText: this.page.getByTestId("schedule-no-week"),
// the chip radio input is visually hidden, so the label is what clicks
nextWeekToggle: this.page.locator(
'label[for="chip-radio-friend-schedule-week-next"]',
),
};
}
@@ -52,6 +59,19 @@ export class FriendsPage {
friend(name: string) {
return new FriendMenu(this.page, name);
}
row(userId: number) {
return this.page.getByTestId(`friend-row-${userId}`);
}
scheduleButton(userId: number) {
return this.page.getByTestId(`friend-schedule-button-${userId}`);
}
/** One day row of the open week modal, Monday being 0. */
day(dayIndex: number) {
return this.locators.scheduleDays.getByRole("listitem").nth(dayIndex);
}
}
class FriendMenu {

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "No events this week",
"events.delete": "Delete event",
"events.deleteConfirm": "Delete the event {{name}}?",
"friends.availabilityOf": "{{name}}'s availability",
"registration.title": "Availability",
"registration.estimated": "estimated",
"registration.notVisible": "Schedule not shared with you",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",

View File

@@ -19,6 +19,7 @@
"events.none": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",
"registration.title": "",
"registration.estimated": "",
"registration.notVisible": "",