From a75b115a409fa80c2a672e9cfd6675294a717a35 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:55:04 +0300 Subject: [PATCH] Friend availabilities --- app/db/seed/dev/availability.ts | 4 +- .../availability/availability-types.ts | 14 ++ .../components/ScheduleDayCell.module.css | 44 ++++++ .../components/ScheduleDayCell.tsx | 94 +++++++++++++ .../components/ScheduleWeekDialog.module.css | 40 ++++++ .../components/ScheduleWeekDialog.tsx | 101 ++++++++++++++ .../core/FriendSchedule.server.test.ts | 107 ++++++++++++++ .../core/FriendSchedule.server.ts | 77 +++++++++++ .../availability/core/ScheduleWeek.ts | 130 ++++++++++++++++++ .../loaders/t.$customUrl.schedule.server.ts | 107 ++------------ .../routes/t.$customUrl.schedule.module.css | 41 ------ .../routes/t.$customUrl.schedule.tsx | 80 ++--------- .../friends/loaders/friends.server.ts | 13 ++ .../friends/routes/friends.module.css | 16 +++ app/features/friends/routes/friends.tsx | 70 +++++++++- e2e/events.spec.ts | 6 +- e2e/friends.spec.ts | 108 ++++++++++++++- e2e/pages/friends/friends-page.ts | 20 +++ locales/da/schedule.json | 1 + locales/de/schedule.json | 1 + locales/en/schedule.json | 1 + locales/es-ES/schedule.json | 1 + locales/es-US/schedule.json | 1 + locales/fr-CA/schedule.json | 1 + locales/fr-EU/schedule.json | 1 + locales/he/schedule.json | 1 + locales/it/schedule.json | 1 + locales/ja/schedule.json | 1 + locales/ko/schedule.json | 1 + locales/nl/schedule.json | 1 + locales/pl/schedule.json | 1 + locales/pt-BR/schedule.json | 1 + locales/ru/schedule.json | 1 + locales/zh/schedule.json | 1 + 34 files changed, 865 insertions(+), 223 deletions(-) create mode 100644 app/features/availability/components/ScheduleDayCell.module.css create mode 100644 app/features/availability/components/ScheduleDayCell.tsx create mode 100644 app/features/availability/components/ScheduleWeekDialog.module.css create mode 100644 app/features/availability/components/ScheduleWeekDialog.tsx create mode 100644 app/features/availability/core/FriendSchedule.server.test.ts create mode 100644 app/features/availability/core/FriendSchedule.server.ts create mode 100644 app/features/availability/core/ScheduleWeek.ts diff --git a/app/db/seed/dev/availability.ts b/app/db/seed/dev/availability.ts index 9f00dd49f..39f84342b 100644 --- a/app/db/seed/dev/availability.ts +++ b/app/db/seed/dev/availability.ts @@ -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, diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index 05ca21921..a4b2b22bd 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -102,3 +102,17 @@ export interface WindowSchedule { /** Their commitments overlapping the window. */ busy: Array; } + +/** + * 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 }>; +} diff --git a/app/features/availability/components/ScheduleDayCell.module.css b/app/features/availability/components/ScheduleDayCell.module.css new file mode 100644 index 000000000..b9b536f99 --- /dev/null +++ b/app/features/availability/components/ScheduleDayCell.module.css @@ -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); +} diff --git a/app/features/availability/components/ScheduleDayCell.tsx b/app/features/availability/components/ScheduleDayCell.tsx new file mode 100644 index 000000000..8f5167f7f --- /dev/null +++ b/app/features/availability/components/ScheduleDayCell.tsx @@ -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; + busy?: Array; + note?: string; +}) { + const { t } = useTranslation(["schedule"]); + const rangeText = useRangeText(); + + const busyName = (block: BusyBlock) => + block.name ?? t("schedule:commitment.scrim"); + + return ( +
+ {!reported ? ( + + ? + + ) : ranges.length === 0 && busy.length === 0 ? ( + + — + + ) : ( + ranges.map((range) => ( +
+ {rangeText(range)} +
+ )) + )} + {busy.map((block) => ( +
+ {busyName(block)} +
+ ))} + {note ? ( + + + + ) : null} +
+ ); +} + +/** + * 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)}`; +} diff --git a/app/features/availability/components/ScheduleWeekDialog.module.css b/app/features/availability/components/ScheduleWeekDialog.module.css new file mode 100644 index 000000000..d98922a0d --- /dev/null +++ b/app/features/availability/components/ScheduleWeekDialog.module.css @@ -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); +} diff --git a/app/features/availability/components/ScheduleWeekDialog.tsx b/app/features/availability/components/ScheduleWeekDialog.tsx new file mode 100644 index 000000000..d49256741 --- /dev/null +++ b/app/features/availability/components/ScheduleWeekDialog.tsx @@ -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; + 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 ( + +
+
+ + {t("schedule:team.weekHeading", { week: shownWeek.weekNumber })} ·{" "} + {headingFormatter.formatRange( + shownWeek.days[0].noonAt, + shownWeek.days[6].noonAt, + )} + + + setParams({ week: "current" })} + > + {t("schedule:team.currentWeek")} + + setParams({ week: "next" })} + > + {t("schedule:team.nextWeek")} + + +
+ {shownWeek.reported ? ( + + ) : ( +
+ {t("schedule:team.noSchedule")} +
+ )} +
+
+ ); +} + +function WeekDays({ week }: { week: ScheduleWeekView }) { + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + + return ( +
    + {week.days.map((day) => ( +
  • + + {dayFormatter.format(day.noonAt)} + + +
  • + ))} +
+ ); +} diff --git a/app/features/availability/core/FriendSchedule.server.test.ts b/app/features/availability/core/FriendSchedule.server.test.ts new file mode 100644 index 000000000..361dcc41d --- /dev/null +++ b/app/features/availability/core/FriendSchedule.server.test.ts @@ -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 }, + ]); + }); +}); diff --git a/app/features/availability/core/FriendSchedule.server.ts b/app/features/availability/core/FriendSchedule.server.ts new file mode 100644 index 000000000..24b6615db --- /dev/null +++ b/app/features/availability/core/FriendSchedule.server.ts @@ -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; + timezone: string; +}): Promise>> { + 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]] : []; + }), + ); +} diff --git a/app/features/availability/core/ScheduleWeek.ts b/app/features/availability/core/ScheduleWeek.ts new file mode 100644 index 000000000..bca0cb86b --- /dev/null +++ b/app/features/availability/core/ScheduleWeek.ts @@ -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; + 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; busy: Array }>; + 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 { + 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; + timezone: string; + reportedWeeks: Array; + range: TimeRange; + busy: Array; +}): 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, + 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 }]; + }), + ), + }; +} diff --git a/app/features/availability/loaders/t.$customUrl.schedule.server.ts b/app/features/availability/loaders/t.$customUrl.schedule.server.ts index 16ac68fc2..30a7af45c 100644 --- a/app/features/availability/loaders/t.$customUrl.schedule.server.ts +++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts @@ -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; @@ -93,10 +92,6 @@ type TeamEventRow = Awaited< ReturnType >[number]; -type ReportedWeek = Awaited< - ReturnType ->[number]; - function weekView({ range, timezone, @@ -110,7 +105,7 @@ function weekView({ timezone: string; memberIds: Array; playerIds: Array; - reportedWeeks: Array; + reportedWeeks: Array; busyByUserId: Map>; teamEvents: Array; }) { @@ -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; - range: TimeRange; - busy: Array; -}) { - 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, - 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 }]; - }), - ), - }; -} diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index 47518ce21..5a8850a20 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -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); } diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx index f4d0917b1..2b510fdb0 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.tsx +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -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 ? ( - + {t("team:roster.sections.other")} @@ -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 ( - -
- {!row.reported ? ( - - ? - - ) : day.ranges.length === 0 && day.busy.length === 0 ? ( - - — - - ) : ( - day.ranges.map((range) => ( -
- {rangeText(range)} -
- )) - )} - {day.busy.map((block) => ( -
- {busyName(block)} -
- ))} - {note ? ( - - - - ) : null} -
+ + ); } diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 0d0ffbe5a..f548e5087 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -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 { diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css index 734ecb8b4..c2f34dfe4 100644 --- a/app/features/friends/routes/friends.module.css +++ b/app/features/friends/routes/friends.module.css @@ -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; + } +} diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx index 9b6499205..448ab350c 100644 --- a/app/features/friends/routes/friends.tsx +++ b/app/features/friends/routes/friends.tsx @@ -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() { ) : (
{shownItems.map((item) => ( - + ))}
)} @@ -230,6 +233,58 @@ function FriendsListSection() { ); } +function FriendRow({ item }: { item: ShownItem }) { + return ( +
+ +
+ {item.schedule ? ( + + ) : null} +
+
+ ); +} + +function ScheduleButton({ + userId, + username, + weeks, +}: { + userId: number; + username: string; + weeks: NonNullable; +}) { + const { t } = useTranslation(["schedule"]); + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + <> + } + aria-label={t("schedule:friends.availabilityOf", { name: username })} + testId={`friend-schedule-button-${userId}`} + onPress={() => setDialogOpen(true)} + /> + {dialogOpen ? ( + setDialogOpen(false)} + /> + ) : null} + + ); +} + +type ShownItem = ReturnType[number]; + function resolveShownItems( filter: ViewFilter, data: Awaited>, @@ -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)); } diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index 3e52a17c6..5a9d5af8d 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -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(); }); }); diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index 64f06bd1c..b8259ff79 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -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, + }), + }; +} diff --git a/e2e/pages/friends/friends-page.ts b/e2e/pages/friends/friends-page.ts index 08fcdaa7b..101729eda 100644 --- a/e2e/pages/friends/friends-page.ts +++ b/e2e/pages/friends/friends-page.ts @@ -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 { diff --git a/locales/da/schedule.json b/locales/da/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index e24e7253c..7db96627d 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index 6756b6f10..784eb9ea7 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -19,6 +19,7 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "friends.availabilityOf": "", "registration.title": "", "registration.estimated": "", "registration.notVisible": "",