mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
import { Eye, EyeOff, Users } from "lucide-react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { FetcherWithComponents } from "react-router";
|
|
import * as R from "remeda";
|
|
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
|
import { toastQueue } from "~/components/elements/Toast";
|
|
import { useUser } from "~/features/auth/core/user";
|
|
import { useUnsavedChangesChecker } from "~/form/UnsavedChangesGuard";
|
|
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
|
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
|
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
|
import { teamSchedulePage } from "~/utils/urls";
|
|
import { saveWeekSchema } from "../availability-schemas";
|
|
import { scheduleWeekSearchParams } from "../availability-search-params";
|
|
import type {
|
|
AvailabilityEditorWeek,
|
|
ScheduleAudienceTeam,
|
|
} from "../availability-types";
|
|
import { scheduleAudiencesHiddenFrom } from "../availability-utils";
|
|
import type { MyScheduleData } from "../core/MySchedule.server";
|
|
import styles from "./MySchedule.module.css";
|
|
import { ScheduleVisibilityDialog } from "./ScheduleVisibilityDialog";
|
|
import { WeekAvailabilityEditor } from "./WeekAvailabilityEditor";
|
|
import { WeekToggle } from "./WeekToggle";
|
|
|
|
/** The events page's "My schedule": editor with current/next week toggle, "Copy last week" prefill and save. */
|
|
export function MySchedule({
|
|
data,
|
|
teams,
|
|
}: {
|
|
data: MyScheduleData;
|
|
teams: Array<ScheduleAudienceTeam>;
|
|
}) {
|
|
const { t } = useTranslation(["schedule"]);
|
|
const user = useUser();
|
|
const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams);
|
|
const [weeks, setWeeks] = React.useState<Array<AvailabilityEditorWeek>>(() =>
|
|
data.weeks.map((editorWeek) => editorWeek.days),
|
|
);
|
|
const [visibilityDialogOpen, setVisibilityDialogOpen] = React.useState(false);
|
|
const hiddenFrom = scheduleAudiencesHiddenFrom({
|
|
visibility: user?.preferences.scheduleVisibility,
|
|
teams,
|
|
});
|
|
const isRestricted = hiddenFrom.friends || hiddenFrom.teams.length > 0;
|
|
const { submit, fetcher, state } = useActionSubmit(saveWeekSchema, {
|
|
encType: "application/json",
|
|
});
|
|
useSavedToast(fetcher);
|
|
const { formatter: headingFormatter } = useDateTimeFormat({
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
|
|
// dirty = editor differs from the loader (a save revalidates it, reading clean again) or the day
|
|
// popover holds uncommitted edits. Edits survive same-route navigations (view tabs), so only a
|
|
// pathname change or a full unload warns
|
|
const hasPendingDraftRef = React.useRef(false);
|
|
const hasUnsavedChangesRef = React.useRef<
|
|
Parameters<typeof useUnsavedChangesChecker>[0]["current"]
|
|
>(() => false);
|
|
hasUnsavedChangesRef.current = (navigation) =>
|
|
fetcher.state === "idle" &&
|
|
(!navigation ||
|
|
navigation.currentLocation.pathname !==
|
|
navigation.nextLocation.pathname) &&
|
|
(hasPendingDraftRef.current ||
|
|
!R.isDeepEqual(
|
|
weeks,
|
|
data.weeks.map((editorWeek) => editorWeek.days),
|
|
));
|
|
useUnsavedChangesChecker(hasUnsavedChangesRef);
|
|
|
|
const weekIndex = week === "next" ? 1 : 0;
|
|
const shownDays = weeks[weekIndex];
|
|
|
|
const copySourceRanges =
|
|
weekIndex === 0 ? data.lastWeekRanges : weeks[0].map((day) => day.ranges);
|
|
const canCopy =
|
|
copySourceRanges?.some((ranges) => ranges.length > 0) ?? false;
|
|
|
|
const copyPreviousWeek = () => {
|
|
if (!copySourceRanges) return;
|
|
|
|
setWeeks(
|
|
weeks.map((days, index) =>
|
|
index === weekIndex
|
|
? days.map((day, dayIndex) => ({
|
|
...day,
|
|
ranges: copySourceRanges[dayIndex],
|
|
}))
|
|
: days,
|
|
),
|
|
);
|
|
};
|
|
|
|
const saveWeek = () => {
|
|
submit("SAVE_WEEK", {
|
|
days: shownDays.map((day) => ({
|
|
date: day.date,
|
|
ranges: day.ranges,
|
|
note: day.note,
|
|
})),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className="stack sm" data-testid="my-schedule">
|
|
<div className={styles.header}>
|
|
<div className={styles.title}>
|
|
<h2 className="text-lg mx-2">{t("schedule:editor.title")}</h2>
|
|
{user?.team ? (
|
|
<LinkButton
|
|
to={scheduleWeekSearchParams.href(
|
|
teamSchedulePage(user.team.customUrl),
|
|
{ week },
|
|
)}
|
|
variant="minimal"
|
|
size="miniscule"
|
|
icon={<Users />}
|
|
testId="team-schedule-link"
|
|
>
|
|
{t("schedule:editor.teamSchedule")}
|
|
</LinkButton>
|
|
) : null}
|
|
<SendouButton
|
|
variant="minimal"
|
|
size="miniscule"
|
|
icon={isRestricted ? <EyeOff /> : <Eye />}
|
|
onClick={() => setVisibilityDialogOpen(true)}
|
|
testId="schedule-visibility-button"
|
|
>
|
|
{isRestricted
|
|
? t("schedule:visibility.limited")
|
|
: t("schedule:visibility.button")}
|
|
</SendouButton>
|
|
</div>
|
|
<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>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
<h3 className={styles.weekHeading}>
|
|
{t("schedule:team.weekHeading", {
|
|
week: data.weeks[weekIndex].weekNumber,
|
|
})}{" "}
|
|
·{" "}
|
|
{headingFormatter.formatRange(
|
|
dateAtNoon(shownDays[0].date),
|
|
dateAtNoon(shownDays[6].date),
|
|
)}
|
|
</h3>
|
|
<WeekAvailabilityEditor
|
|
key={data.weeks[weekIndex].weekStartsAt}
|
|
value={shownDays}
|
|
commitments={data.commitments.map((commitment) => ({
|
|
date: commitment.date,
|
|
range: commitment.range,
|
|
name: commitment.name ?? t("schedule:commitment.scrim"),
|
|
}))}
|
|
notSharedWith={[
|
|
...(hiddenFrom.friends ? [t("schedule:visibility.friends")] : []),
|
|
...hiddenFrom.teams.map((team) => team.name),
|
|
]}
|
|
onChange={(value) =>
|
|
setWeeks(
|
|
weeks.map((days, index) => (index === weekIndex ? value : days)),
|
|
)
|
|
}
|
|
onPendingDraftChange={(hasPendingDraft) => {
|
|
hasPendingDraftRef.current = hasPendingDraft;
|
|
}}
|
|
/>
|
|
<div className={styles.actions}>
|
|
<SendouButton
|
|
variant="outlined"
|
|
size="small"
|
|
isDisabled={!canCopy}
|
|
onClick={copyPreviousWeek}
|
|
testId="copy-last-week-button"
|
|
>
|
|
{t("schedule:editor.copyLastWeek")}
|
|
</SendouButton>
|
|
<SendouButton
|
|
size="small"
|
|
isDisabled={state !== "idle"}
|
|
onClick={saveWeek}
|
|
testId="save-week-button"
|
|
>
|
|
{t("schedule:editor.saveWeek")}
|
|
</SendouButton>
|
|
</div>
|
|
{visibilityDialogOpen ? (
|
|
<ScheduleVisibilityDialog
|
|
teams={teams}
|
|
close={() => setVisibilityDialogOpen(false)}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function useSavedToast(fetcher: FetcherWithComponents<unknown>) {
|
|
const { t } = useTranslation(["schedule"]);
|
|
const previousStateRef = React.useRef(fetcher.state);
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
previousStateRef.current !== "idle" &&
|
|
fetcher.state === "idle" &&
|
|
fetcher.data === null
|
|
) {
|
|
toastQueue.add({
|
|
message: t("schedule:editor.saved"),
|
|
variant: "success",
|
|
});
|
|
}
|
|
previousStateRef.current = fetcher.state;
|
|
}, [fetcher.state, fetcher.data, t]);
|
|
}
|
|
|
|
function dateAtNoon(date: string) {
|
|
const [year, month, day] = date.split("-").map(Number);
|
|
|
|
return new Date(year, month - 1, day, 12);
|
|
}
|