This commit is contained in:
Kalle
2026-08-29 11:17:20 +03:00
parent a75b115a40
commit a39213ecc6
20 changed files with 288 additions and 370 deletions

View File

@@ -3,10 +3,6 @@ import { useTranslation } from "react-i18next";
import type { FetcherWithComponents } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { toastQueue } from "~/components/elements/Toast";
import { useUnsavedChangesChecker } from "~/form/UnsavedChangesGuard";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -18,8 +14,7 @@ import type { AvailabilityEditorWeek } from "../availability-types";
import type { MyScheduleData } from "../core/MySchedule.server";
import styles from "./MySchedule.module.css";
import { WeekAvailabilityEditor } from "./WeekAvailabilityEditor";
const WEEK_VALUES = ["current", "next"] as const;
import { WeekToggle } from "./WeekToggle";
/**
* The "My schedule" section of the events page: the schedule editor with a
@@ -95,31 +90,21 @@ export function MySchedule({ data }: { data: MyScheduleData }) {
<section className="stack sm" data-testid="my-schedule">
<div className={styles.header}>
<h2 className="text-lg mx-2">{t("schedule:editor.title")}</h2>
<SendouChipRadioGroup>
{WEEK_VALUES.map((value, index) => (
<SendouChipRadio
key={value}
name="my-schedule-week"
value={value}
checked={weekIndex === index}
onChange={() => setParams({ week: value })}
>
<span>
{index === 0
? t("schedule:team.currentWeek")
: t("schedule:team.nextWeek")}
{!data.weeks[index].submitted ? (
<span
className={styles.notFilled}
data-testid={`week-not-filled-${value}`}
>
{t("schedule:editor.notFilled")}
</span>
) : null}
<WeekToggle
name="my-schedule-week"
value={week}
onChange={(value) => setParams({ week: value })}
renderExtra={(value) =>
!data.weeks[value === "next" ? 1 : 0].submitted ? (
<span
className={styles.notFilled}
data-testid={`week-not-filled-${value}`}
>
{t("schedule:editor.notFilled")}
</span>
</SendouChipRadio>
))}
</SendouChipRadioGroup>
) : null
}
/>
</div>
<h3 className={styles.weekHeading}>
{t("schedule:team.weekHeading", {

View File

@@ -12,11 +12,11 @@ import type * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import type { TimeRange, WindowAvailabilityEntry } from "../availability-types";
import type { RegistrationAvailability } from "../core/RegistrationAvailability.server";
import styles from "./RegistrationAvailabilityPanel.module.css";
import { useRangeText } from "./ScheduleDayCell";
export interface AvailabilityPanelUser {
id: number;
@@ -34,15 +34,6 @@ export type AvailabilityRowStatus =
/** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */
| "hidden";
const STATUS_ORDER: Array<AvailabilityRowStatus> = [
"available",
"partial",
"unavailable",
"busy",
"unknown",
"hidden",
];
/**
* The tournament registration page's availability panel: how each member of
* the roster relates to the event's estimated window, plus the friends who
@@ -88,6 +79,18 @@ export function RegistrationAvailabilityPanel({
if (roster.length === 0 && freeSubs.length === 0) return null;
const freeSubRows = (
<ul className={styles.rows}>
{freeSubs.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
);
return (
<section className={styles.panel}>
<h4 className={styles.heading}>
@@ -118,26 +121,10 @@ export function RegistrationAvailabilityPanel({
<h5 className={styles.subsHeading}>
{t("schedule:registration.friends")}
</h5>
<ul className={styles.rows}>
{freeSubs.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
{freeSubRows}
</div>
) : (
<ul className={styles.rows}>
{freeSubs.map((user) => (
<AvailabilityMemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
freeSubRows
)
) : null}
</section>
@@ -311,9 +298,10 @@ export function AvailabilityStatusDots({
}: {
statuses: Array<AvailabilityRowStatus>;
}) {
const shown = statuses
.filter((status) => status === "available" || status === "partial")
.sort((a, b) => STATUS_ORDER.indexOf(a) - STATUS_ORDER.indexOf(b));
const shown = [
...statuses.filter((status) => status === "available"),
...statuses.filter((status) => status === "partial"),
];
if (shown.length === 0) return null;
return (
@@ -326,19 +314,7 @@ export function AvailabilityStatusDots({
}
function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
// formatRange expands to full dates when the ends fall on different
// calendar days, so a range crossing midnight formats its ends separately
// to stay times-only
const rangeText = (range: TimeRange) =>
databaseTimestampToDate(range.startsAt).getDate() ===
databaseTimestampToDate(range.endsAt).getDate()
? timeFormatter.formatRange(range.startsAt, range.endsAt)
: `${timeFormatter.format(range.startsAt)} ${timeFormatter.format(range.endsAt)}`;
const rangeText = useRangeText();
return (
<span className={styles.ranges}>{ranges.map(rangeText).join(" · ")}</span>

View File

@@ -78,7 +78,7 @@ export function ScheduleDayCell({
* ends fall on different calendar days, so a range crossing (or ending exactly
* at) midnight formats its ends separately.
*/
function useRangeText() {
export function useRangeText() {
const { formatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",

View File

@@ -159,3 +159,44 @@
font-size: var(--font-xs);
color: var(--color-text-high);
}
.listDay {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
padding-block: var(--s-2);
border-bottom: 1px solid var(--color-bg-higher);
&:last-child {
border-bottom: none;
}
}
.listDayHeader {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.listDayBody {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}
.timeChip {
padding: var(--s-0-5) var(--s-2);
background-color: var(--color-success-low);
border: 1px solid var(--color-success);
border-radius: var(--radius-full);
font-size: var(--font-xs);
color: var(--color-text);
cursor: pointer;
&:focus-visible {
outline: var(--focus-ring);
}
}

View File

@@ -1,8 +1,4 @@
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";
@@ -10,6 +6,7 @@ import { scheduleWeekSearchParams } from "../availability-search-params";
import type { ScheduleWeekView } from "../availability-types";
import { ScheduleDayCell } from "./ScheduleDayCell";
import styles from "./ScheduleWeekDialog.module.css";
import { WeekToggle } from "./WeekToggle";
/**
* One person's reportable weeks as a read-only day-by-day list of the time
@@ -49,24 +46,11 @@ export function ScheduleWeekDialog({
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>
<WeekToggle
name="friend-schedule-week"
value={week}
onChange={(value) => setParams({ week: value })}
/>
</div>
{shownWeek.reported ? (
<WeekDays week={shownWeek} />

View File

@@ -133,47 +133,6 @@
color: var(--color-text-high);
}
.listDay {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
padding-block: var(--s-2);
border-bottom: 1px solid var(--color-bg-higher);
&:last-child {
border-bottom: none;
}
}
.listDayHeader {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.listDayBody {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}
.timeChip {
padding: var(--s-0-5) var(--s-2);
background-color: var(--color-success-low);
border: 1px solid var(--color-success);
border-radius: var(--radius-full);
font-size: var(--font-xs);
color: var(--color-text);
cursor: pointer;
&:focus-visible {
outline: var(--focus-ring);
}
}
.addChip {
display: inline-flex;
align-items: center;

View File

@@ -561,14 +561,16 @@ export function WeekAvailabilityEditor({
);
return (
<div key={day.date} className={styles.listDay}>
<div className={styles.listDayHeader}>{dayLabelText(day)}</div>
<div className={styles.listDayBody}>
<div key={day.date} className={trackStyles.listDay}>
<div className={trackStyles.listDayHeader}>
{dayLabelText(day)}
</div>
<div className={trackStyles.listDayBody}>
{day.ranges.map((range) => (
<button
type="button"
key={`${range.start}-${range.end}`}
className={styles.timeChip}
className={trackStyles.timeChip}
onClick={(event) =>
openDayEditor(day.date, event.currentTarget)
}

View File

@@ -0,0 +1,54 @@
import type * as React from "react";
import { useTranslation } from "react-i18next";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
const WEEK_VALUES = ["current", "next"] as const;
export type WeekToggleValue = (typeof WEEK_VALUES)[number];
/** The current/next week chip toggle shared by the schedule surfaces. */
export function WeekToggle({
name,
value,
onChange,
renderExtra,
}: {
name: string;
value: WeekToggleValue;
onChange: (value: WeekToggleValue) => void;
/** Rendered after a chip's label, e.g. the editor's "not filled" marker. */
renderExtra?: (week: WeekToggleValue) => React.ReactNode;
}) {
const { t } = useTranslation(["schedule"]);
const label = (week: WeekToggleValue) =>
week === "current"
? t("schedule:team.currentWeek")
: t("schedule:team.nextWeek");
return (
<SendouChipRadioGroup>
{WEEK_VALUES.map((week) => (
<SendouChipRadio
key={week}
name={name}
value={week}
checked={value === week}
onChange={() => onChange(week)}
>
{renderExtra ? (
<span>
{label(week)}
{renderExtra(week)}
</span>
) : (
label(week)
)}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
);
}

View File

@@ -65,6 +65,18 @@ export function isoWeekNumber(timestamp: number, timezone: string) {
return getISOWeek(inTimezone(timestamp, timezone));
}
/**
* Whether a week reported to start at `weekStartsAt` is the week starting at
* `rangeStartsAt`: the two starts are closer than timezones can set them
* apart (hours, never days).
*/
export function isSameWeek(weekStartsAt: number, rangeStartsAt: number) {
return (
Math.abs(weekStartsAt - rangeStartsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS
);
}
/**
* Database timestamp of the given wall clock time in `timezone`. `date` is
* `YYYY-MM-DD` and `time` is `HH:mm`, the shapes the availability tables and
@@ -134,6 +146,26 @@ export function timeInTimezone(timestamp: number, timezone: string) {
return format(inTimezone(timestamp, timezone), "HH:mm");
}
/**
* `YYYY-MM-DD` in the `to` timezone of a day saved as a date in the `from`
* timezone, mapped through that day's noon in case the viewer has since
* moved. How day notes find their viewer-local day.
*/
export function dateAcrossTimezones({
date,
from,
to,
}: {
date: string;
from: string;
to: string;
}) {
return dateInTimezone(
localToTimestamp({ date, time: "12:00", timezone: from }),
to,
);
}
/** Whether the two ranges share any time at all. Ranges that merely touch do not overlap. */
export function overlaps(one: TimeRange, other: TimeRange) {
return one.startsAt < other.endsAt && other.startsAt < one.endsAt;

View File

@@ -7,6 +7,7 @@ import { AVAILABILITY } from "../availability-constants";
import type { BusyBlock } from "../availability-types";
import * as Availability from "./Availability";
import * as TournamentDuration from "./TournamentDuration";
import { estimatedEndsAtWith } from "./TournamentDuration.server";
/**
* The busy blocks of the given users within the given window, keyed by user
@@ -63,15 +64,16 @@ export async function busyBlocksByUserIds({
type: "tournament" as const,
name: registration.name,
startsAt: registration.startsAt,
endsAt:
registration.startsAt +
TournamentDuration.estimateSeconds({
endsAt: estimatedEndsAtWith(
{
...registration,
minMembersPerTeam: registration.settings.minMembersPerTeam ?? 4,
bracketTypes: registration.settings.bracketProgression.map(
(bracket) => bracket.type,
),
teamCount: expectedTeamCount(registration),
}),
},
expectedTeamCount,
),
})),
...scrims.map((scrim) => ({
userId: scrim.userId,

View File

@@ -7,8 +7,7 @@ import { AVAILABILITY } from "../availability-constants";
import type { DayTimeRange, TimeRange } from "../availability-types";
import * as Availability from "./Availability";
import * as Commitments from "./Commitments.server";
const DAY_SECONDS = 24 * 60 * 60;
import * as ScheduleWeek from "./ScheduleWeek";
export type MyScheduleData = SerializeFrom<
Awaited<ReturnType<typeof myScheduleData>>
@@ -83,38 +82,26 @@ function editorWeek({
timezone: string;
reportedWeeks: Array<ReportedWeek>;
}) {
const matchingWeek = reportedWeeks.find(
(week) =>
Math.abs(week.weekStartsAt - range.startsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
const matchingWeek = reportedWeeks.find((week) =>
Availability.isSameWeek(week.weekStartsAt, range.startsAt),
);
const days = R.range(0, 7).map((dayIndex) => {
const date = Availability.dateInTimezone(
range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
timezone,
);
return {
date,
ranges: Availability.mergedDayRanges(
(matchingWeek?.slots ?? [])
.filter(
(slot) =>
Availability.dateInTimezone(slot.startsAt, timezone) === date,
)
.map((slot) => slotToDayRange(slot, timezone)),
),
note: matchingWeek ? noteOfDay(matchingWeek, date, timezone) : "",
};
});
const days = ScheduleWeek.days(range, timezone).map(({ date }) => ({
date,
ranges: Availability.mergedDayRanges(
(matchingWeek?.slots ?? [])
.filter(
(slot) =>
Availability.dateInTimezone(slot.startsAt, timezone) === date,
)
.map((slot) => slotToDayRange(slot, timezone)),
),
note: matchingWeek ? noteOfDay(matchingWeek, date, timezone) : "",
}));
return {
weekStartsAt: range.startsAt,
weekNumber: Availability.isoWeekNumber(
range.startsAt + DAY_SECONDS / 2,
timezone,
),
weekNumber: ScheduleWeek.weekNumber(range, timezone),
submitted: Boolean(matchingWeek),
days,
};
@@ -128,19 +115,15 @@ function slotToDayRange(slot: TimeRange, timezone: string): DayTimeRange {
return { start, end: start + Math.round((slot.endsAt - slot.startsAt) / 60) };
}
/** Notes were saved with dates of the week's stored timezone, so they map through that day's noon in case the viewer has since moved. */
function noteOfDay(week: ReportedWeek, date: string, timezone: string) {
return (
week.dayNotes.find(
(note) =>
Availability.dateInTimezone(
Availability.localToTimestamp({
date: note.date,
time: "12:00",
timezone: week.timezone,
}),
timezone,
) === date,
Availability.dateAcrossTimezones({
date: note.date,
from: week.timezone,
to: timezone,
}) === date,
)?.text ?? ""
);
}

View File

@@ -94,14 +94,11 @@ export async function registrationAvailability({
week.dayNotes
.filter((note) =>
windowDates.includes(
Availability.dateInTimezone(
Availability.localToTimestamp({
date: note.date,
time: "12:00",
timezone: week.timezone,
}),
timezone,
),
Availability.dateAcrossTimezones({
date: note.date,
from: week.timezone,
to: timezone,
}),
),
)
.map((note) => note.text),

View File

@@ -10,6 +10,7 @@ import { AVAILABILITY } from "../availability-constants";
import type { TimeRange, WindowSchedule } from "../availability-types";
import * as Availability from "./Availability";
import * as Commitments from "./Commitments.server";
import * as ScheduleWeek from "./ScheduleWeek";
const DAY_SECONDS = 24 * 60 * 60;
@@ -68,10 +69,8 @@ export async function rosterScheduleData({
userId,
reportedWeekStarts: weeks
.filter((week) =>
memberWeeks.some(
(memberWeek) =>
Math.abs(memberWeek.weekStartsAt - week.startsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS,
memberWeeks.some((memberWeek) =>
Availability.isSameWeek(memberWeek.weekStartsAt, week.startsAt),
),
)
.map((week) => week.startsAt),
@@ -88,14 +87,12 @@ export async function rosterScheduleData({
}
function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
const dates = ScheduleWeek.days(range, timezone);
const dayStartsAt = (dayIndex: number) =>
dayIndex === 7
? range.endsAt
: Availability.localToTimestamp({
date: Availability.dateInTimezone(
range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2,
timezone,
),
date: dates[dayIndex].date,
time: "00:00",
timezone,
});
@@ -103,10 +100,7 @@ function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
return {
startsAt: range.startsAt,
endsAt: range.endsAt,
weekNumber: Availability.isoWeekNumber(
range.startsAt + DAY_SECONDS / 2,
timezone,
),
weekNumber: ScheduleWeek.weekNumber(range, timezone),
days: R.range(0, 7).map((dayIndex) => {
const startsAt = dayStartsAt(dayIndex);

View File

@@ -1,5 +1,4 @@
import * as R from "remeda";
import { AVAILABILITY } from "../availability-constants";
import type { BusyBlock, TimeRange } from "../availability-types";
import * as Availability from "./Availability";
@@ -78,10 +77,8 @@ export function memberRow({
);
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,
const matchingWeek = memberWeeks.find((week) =>
Availability.isSameWeek(week.weekStartsAt, range.startsAt),
);
if (!matchingWeek) {
@@ -113,14 +110,11 @@ export function memberRow({
})),
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 noteDate = Availability.dateAcrossTimezones({
date: note.date,
from: week.timezone,
to: timezone,
});
const dayIndex = days.findIndex((day) => day.date === noteDate);
return dayIndex === -1 ? [] : [{ dayIndex, text: note.text }];

View File

@@ -19,8 +19,14 @@ interface EstimatedTournament {
* or blocking out a tournament's window goes through this so the two agree.
*/
export async function estimatedEndsAt(tournament: EstimatedTournament) {
const expectedTeamCount = await SeriesTeamCount.lookup();
return estimatedEndsAtWith(tournament, await SeriesTeamCount.lookup());
}
/** {@link estimatedEndsAt} for callers estimating many tournaments off one resolved lookup. */
export function estimatedEndsAtWith(
tournament: EstimatedTournament,
expectedTeamCount: (tournament: EstimatedTournament) => number,
) {
return (
tournament.startsAt +
TournamentDuration.estimateSeconds({

View File

@@ -124,7 +124,11 @@ export function ScrimAvailabilityRows({ fit }: { fit: ScrimRosterFit }) {
/>
))}
</ul>
<AvailabilitySummary statuses={rosterStatuses(fit)} />
<AvailabilitySummary
statuses={fit.roster.map((member) =>
availabilityRowStatus(entryByUserId.get(member.id)),
)}
/>
</div>
);
}

View File

@@ -114,43 +114,7 @@
color: var(--color-text-high);
}
.listDay {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
padding-block: var(--s-2);
border-bottom: 1px solid var(--color-bg-higher);
&:last-child {
border-bottom: none;
}
}
.listDayHeader {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.listDayBody {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}
.slotChip {
padding: var(--s-0-5) var(--s-2);
background-color: var(--color-success-low);
border: 1px solid var(--color-success);
border-radius: var(--radius-full);
font-size: var(--font-xs);
color: var(--color-text);
cursor: pointer;
&:focus-visible {
outline: var(--focus-ring);
}
&.oneShort {
border-style: dashed;
opacity: 0.85;

View File

@@ -2,10 +2,6 @@ import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { useUser } from "~/features/auth/core/user";
import type {
DayTimeRange,
@@ -18,6 +14,7 @@ import {
useClockWindow,
} from "~/features/availability/components/ScheduleTracks";
import trackStyles from "~/features/availability/components/ScheduleTracks.module.css";
import { WeekToggle } from "~/features/availability/components/WeekToggle";
import * as Availability from "~/features/availability/core/Availability";
import type { RosterScheduleData } from "~/features/availability/core/RosterSchedule.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -147,23 +144,17 @@ function RosterTimeline({
});
const nameById = new Map(names.map((member) => [member.id, member.username]));
const freeNames = (userIds: Array<number>) =>
userIds
.flatMap((userId) => {
const username = nameById.get(userId);
const namesOf = (userIds: Array<number>) =>
userIds.flatMap((userId) => {
const username = nameById.get(userId);
return username ? [username] : [];
})
.join(", ");
return username ? [username] : [];
});
const unknownUserIds = roster.filter(
(userId) =>
!memberById.get(userId)?.reportedWeekStarts.includes(week.startsAt),
);
const unknownNamed = unknownUserIds.flatMap((userId) => {
const username = nameById.get(userId);
return username ? [username] : [];
});
const unknownNamed = namesOf(unknownUserIds);
const unknownUnnamed = unknownUserIds.length - unknownNamed.length;
const pickedAt = at ? dateToDatabaseTimestamp(at) : null;
@@ -193,7 +184,7 @@ function RosterTimeline({
label={`${rangeText(slot)} · ${t("schedule:picker.free", {
amount: slot.userIds.length,
})}`}
members={freeNames(slot.userIds)}
members={namesOf(slot.userIds).join(", ")}
isPicked={slot.pick.startsAt === pickedAt}
onPick={() => pick(slot)}
/>
@@ -209,24 +200,11 @@ function RosterTimeline({
<section className={styles.picker} data-testid="scrim-schedule-picker">
<div className={styles.header}>
<h3 className={styles.heading}>{t("schedule:picker.title")}</h3>
<SendouChipRadioGroup>
<SendouChipRadio
name="scrim-schedule-week"
value="current"
checked={weekIndex === 0}
onChange={() => setWeekIndex(0)}
>
{t("schedule:team.currentWeek")}
</SendouChipRadio>
<SendouChipRadio
name="scrim-schedule-week"
value="next"
checked={weekIndex === 1}
onChange={() => setWeekIndex(1)}
>
{t("schedule:team.nextWeek")}
</SendouChipRadio>
</SendouChipRadioGroup>
<WeekToggle
name="scrim-schedule-week"
value={weekIndex === 0 ? "current" : "next"}
onChange={(value) => setWeekIndex(value === "next" ? 1 : 0)}
/>
</div>
<div className={trackStyles.container}>
<div className={trackStyles.tracks}>
@@ -239,23 +217,23 @@ function RosterTimeline({
<div className={trackStyles.list}>
{dayRows.map(({ day, slots: daySlots }) => {
return (
<div key={day.startsAt} className={styles.listDay}>
<div className={styles.listDayHeader}>
<div key={day.startsAt} className={trackStyles.listDay}>
<div className={trackStyles.listDayHeader}>
{dayFormatter.format(day.noonAt)}
</div>
{daySlots.length === 0 ? (
<span className="text-lighter text-xs"></span>
) : (
<div className={styles.listDayBody}>
<div className={trackStyles.listDayBody}>
{daySlots.map((slot) => (
<button
key={slot.startsAt}
type="button"
className={clsx(styles.slotChip, {
className={clsx(trackStyles.timeChip, styles.slotChip, {
[styles.oneShort]: slot.tier === "ONE_SHORT",
[styles.picked]: slot.pick.startsAt === pickedAt,
})}
title={freeNames(slot.userIds)}
title={namesOf(slot.userIds).join(", ")}
onClick={() => pick(slot)}
>
{rangeText(slot)} ·{" "}
@@ -304,14 +282,12 @@ function SlotBar({
isPicked: boolean;
onPick: () => void;
}) {
const visibleStart = Math.max(slot.range.start, clockWindow.trackStart);
const visibleEnd = Math.min(slot.range.end, clockWindow.trackEnd);
if (visibleEnd <= visibleStart) return null;
const barStart = clockWindow.pct(slot.range.start);
const barEnd = clockWindow.pct(slot.range.end);
if (barEnd <= barStart) return null;
const withinBar = (minutes: number) =>
((Math.min(Math.max(minutes, visibleStart), visibleEnd) - visibleStart) /
(visibleEnd - visibleStart)) *
100;
((clockWindow.pct(minutes) - barStart) / (barEnd - barStart)) * 100;
return (
<button
type="button"
@@ -319,7 +295,7 @@ function SlotBar({
[styles.oneShort]: slot.tier === "ONE_SHORT",
[styles.picked]: isPicked,
})}
style={clockWindow.barStyle({ start: visibleStart, end: visibleEnd })}
style={clockWindow.barStyle(slot.range)}
title={members ? `${label} · ${members}` : label}
aria-label={label}
data-testid="scrim-schedule-slot"

View File

@@ -5,7 +5,6 @@ import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as AvailabilityRepository from "~/features/availability/AvailabilityRepository.server";
import { AVAILABILITY } from "~/features/availability/availability-constants";
import * as Availability from "~/features/availability/core/Availability";
import { userIsBanned } from "~/features/ban/core/banned.server";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
@@ -181,8 +180,7 @@ async function showScheduleNudge(user: AuthenticatedUser | undefined) {
if (
dismissedAt !== undefined &&
Math.abs(dismissedAt - weekStartsAt) <
AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS
Availability.isSameWeek(dismissedAt, weekStartsAt)
) {
return false;
}

View File

@@ -987,42 +987,24 @@ function QuickAddPlayers({
<div className="stack sm">
<fetcher.Form method="post">
<div className={styles.quickAddRow}>
{teamGroups.length > 0 ? (
<SendouSelect
label={t("tournament:pre.roster.quickAdd")}
items={sections}
selectedKey={selectedUserId}
onSelectionChange={(key) =>
setSelectedUserId(key as number | null)
}
estimatedRowHeight={entryByUserId ? 52 : undefined}
className={styles.quickAddSelect}
data-testid="quick-add-select"
>
{(section) => (
<SendouSelectItemSection
key={section.key}
heading={section.heading}
>
{section.players.map(renderPlayerItem)}
</SendouSelectItemSection>
)}
</SendouSelect>
) : (
<SendouSelect
label={t("tournament:pre.roster.quickAdd")}
items={pickupPlayers}
selectedKey={selectedUserId}
onSelectionChange={(key) =>
setSelectedUserId(key as number | null)
}
estimatedRowHeight={entryByUserId ? 52 : undefined}
className={styles.quickAddSelect}
data-testid="quick-add-select"
>
{renderPlayerItem}
</SendouSelect>
)}
<SendouSelect
label={t("tournament:pre.roster.quickAdd")}
items={sections}
selectedKey={selectedUserId}
onSelectionChange={(key) => setSelectedUserId(key as number | null)}
estimatedRowHeight={entryByUserId ? 52 : undefined}
className={styles.quickAddSelect}
data-testid="quick-add-select"
>
{(section) => (
<SendouSelectItemSection
key={section.key}
heading={section.heading}
>
{section.players.map(renderPlayerItem)}
</SendouSelectItemSection>
)}
</SendouSelect>
{selectedUserId ? (
<input type="hidden" name="userId" value={selectedUserId} />
) : null}
@@ -1176,12 +1158,13 @@ function SelectedTeamAvailability() {
// could recruit (all their teams' members and friends) in one list, kept
// to those actually free during the event
const roster = teamId
? (data?.friendPlayers?.friends ?? [])
.filter((friend) => friend.teamId === teamId)
.map(panelUser)
: R.uniqueBy(data?.friendPlayers?.friends ?? [], (friend) => friend.id)
.filter((friend) => !inTournament(friend.id) && isFree(friend.id))
.map(panelUser);
? (data?.friendPlayers?.friends ?? []).filter(
(friend) => friend.teamId === teamId,
)
: R.uniqueBy(
data?.friendPlayers?.friends ?? [],
(friend) => friend.id,
).filter((friend) => !inTournament(friend.id) && isFree(friend.id));
if (roster.length === 0 && !availability.beyondHorizon) return null;
return (
@@ -1201,22 +1184,6 @@ function SelectedTeamAvailability() {
);
}
function panelUser(user: {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
customAvatarUrl?: string | null;
}) {
return {
id: user.id,
username: user.username,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
customAvatarUrl: user.customAvatarUrl,
};
}
function availabilityEntryByUserId(
data: ReturnType<typeof useLoaderData<TournamentRegisterPageLoader>>,
) {
@@ -1252,10 +1219,10 @@ function subCandidates({
const inTournament = (userId: number) =>
tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId));
return R.uniqueBy(data?.friendPlayers?.friends ?? [], (friend) => friend.id)
.filter(
(friend) =>
!rosterUserIds.includes(friend.id) && !inTournament(friend.id),
)
.map(panelUser);
return R.uniqueBy(
data?.friendPlayers?.friends ?? [],
(friend) => friend.id,
).filter(
(friend) => !rosterUserIds.includes(friend.id) && !inTournament(friend.id),
);
}