mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 03:35:40 -05:00
Friend availabilities
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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> }>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
94
app/features/availability/components/ScheduleDayCell.tsx
Normal file
94
app/features/availability/components/ScheduleDayCell.tsx
Normal 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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
101
app/features/availability/components/ScheduleWeekDialog.tsx
Normal file
101
app/features/availability/components/ScheduleWeekDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
app/features/availability/core/FriendSchedule.server.test.ts
Normal file
107
app/features/availability/core/FriendSchedule.server.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
77
app/features/availability/core/FriendSchedule.server.ts
Normal file
77
app/features/availability/core/FriendSchedule.server.ts
Normal 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]] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
130
app/features/availability/core/ScheduleWeek.ts
Normal file
130
app/features/availability/core/ScheduleWeek.ts
Normal 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 }];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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 }];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"events.none": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
"registration.title": "",
|
||||
"registration.estimated": "",
|
||||
"registration.notVisible": "",
|
||||
|
||||
Reference in New Issue
Block a user