mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 03:35:40 -05:00
Refactor
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
54
app/features/availability/components/WeekToggle.tsx
Normal file
54
app/features/availability/components/WeekToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user