My availability visibility

This commit is contained in:
Kalle
2026-09-08 19:07:15 +03:00
parent 18de4135b1
commit ac9eb3723b
66 changed files with 1102 additions and 100 deletions

View File

@@ -56,6 +56,8 @@ export interface UserPreferences {
weaponReportDefaultOpen?: boolean;
/** Start of the week the schedule sidebar nudge was last dismissed for, so it stays gone until the horizon rolls over. */
scheduleNudgeDismissedWeekStartsAt?: number;
/** Who may see the user's schedule. Missing = everyone. Once set it is an allow-list, so a team joined later stays hidden until added. */
scheduleVisibility?: { friends: boolean; teamIds: Array<number> };
}
export type Pronouns = {

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, test } from "vitest";
import { actAs } from "~/db/seed/core/actAs";
import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory";
import * as FriendshipFactory from "~/db/seed/factories/FriendshipFactory";
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 type { UserPreferences } from "~/db/tables-json";
import * as AvailabilityRepository from "./AvailabilityRepository.server";
import * as Availability from "./core/Availability";
@@ -665,3 +667,102 @@ describe("AvailabilityRepository.deleteTeamEvent", () => {
).toEqual([]);
});
});
describe("AvailabilityRepository.findScheduleVisibleUserIds", () => {
const targetId = () => users.id(1);
const viewerId = () => users.id(2);
const restrict = (
scheduleVisibility: NonNullable<UserPreferences["scheduleVisibility"]> = {
friends: false,
teamIds: [],
},
) => UserFactory.grant(targetId(), { preferences: { scheduleVisibility } });
const visibleToViewer = async () =>
AvailabilityRepository.findScheduleVisibleUserIds({
userIds: [targetId()],
viewerId: viewerId(),
});
beforeEach(async () => {
await users.create(2);
});
test("shows the schedule of a user who never restricted it", async () => {
expect(await visibleToViewer()).toEqual([targetId()]);
});
test("hides the schedule of a user sharing with nobody", async () => {
await restrict();
expect(await visibleToViewer()).toEqual([]);
});
test("shows the viewer their own schedule however they restricted it", async () => {
await restrict();
expect(
await AvailabilityRepository.findScheduleVisibleUserIds({
userIds: [targetId()],
viewerId: targetId(),
}),
).toEqual([targetId()]);
});
test("shows a friend the schedule when sharing with friends", async () => {
await FriendshipFactory.create({
userOneId: targetId(),
userTwoId: viewerId(),
});
await restrict({ friends: true, teamIds: [] });
expect(await visibleToViewer()).toEqual([targetId()]);
});
test("hides the schedule from a friend when not sharing with friends", async () => {
await FriendshipFactory.create({
userOneId: targetId(),
userTwoId: viewerId(),
});
await restrict({ friends: false, teamIds: [] });
expect(await visibleToViewer()).toEqual([]);
});
test("shows a teammate the schedule when their team is shared with", async () => {
const team = await TeamFactory.create({
memberUserIds: [targetId(), viewerId()],
});
await restrict({ friends: false, teamIds: [team.id] });
expect(await visibleToViewer()).toEqual([targetId()]);
});
test("hides the schedule from a teammate whose team is not shared with", async () => {
await TeamFactory.create({ memberUserIds: [targetId(), viewerId()] });
await restrict({ friends: false, teamIds: [] });
expect(await visibleToViewer()).toEqual([]);
});
test("counts a secondary team as a shared team", async () => {
await TeamFactory.create({ memberUserIds: [targetId()] });
const secondary = await TeamFactory.create({
memberUserIds: [viewerId(), targetId()],
isMainTeam: false,
});
await restrict({ friends: false, teamIds: [secondary.id] });
expect(await visibleToViewer()).toEqual([targetId()]);
});
test("returns nothing when asked about nobody", async () => {
expect(
await AvailabilityRepository.findScheduleVisibleUserIds({
userIds: [],
viewerId: viewerId(),
}),
).toEqual([]);
});
});

View File

@@ -9,13 +9,86 @@ import {
} from "~/utils/kysely.server";
import { AVAILABILITY } from "./availability-constants";
import type { TimeRange } from "./availability-types";
import { sharesScheduleWith } from "./availability-utils";
/** Longest week (DST included). Weeks are indexed by start, so overlapping a window means looking this far back. */
const WEEK_MAX_SECONDS = 169 * 60 * 60;
/**
* Of `userIds`, those whose schedule the viewer may see, in the order given. Without the
* `scheduleVisibility` preference a schedule is visible to everyone; once set it is an allow-list
* of friends and teams (secondary memberships count), and the viewer has to be an allowed friend
* or share one of the allowed teams. The viewer always sees their own.
*/
export async function findScheduleVisibleUserIds({
userIds,
viewerId,
}: {
userIds: Array<number>;
viewerId: number;
}): Promise<Array<number>> {
if (userIds.length === 0) return [];
const rows = await db
.selectFrom("User")
.select((eb) => [
"User.id",
"User.preferences",
eb
.exists(
eb
.selectFrom("Friendship")
.select("Friendship.id")
.where((innerEb) =>
innerEb.or([
innerEb.and([
innerEb("Friendship.userOneId", "=", viewerId),
innerEb("Friendship.userTwoId", "=", innerEb.ref("User.id")),
]),
innerEb.and([
innerEb("Friendship.userTwoId", "=", viewerId),
innerEb("Friendship.userOneId", "=", innerEb.ref("User.id")),
]),
]),
),
)
.as("isFriend"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary as theirs")
.innerJoin("TeamMemberWithSecondary as viewers", (join) =>
join
.onRef("viewers.teamId", "=", "theirs.teamId")
.on("viewers.userId", "=", viewerId),
)
.select("theirs.teamId")
.whereRef("theirs.userId", "=", "User.id"),
).as("sharedTeams"),
])
.where("User.id", "in", userIds)
.execute();
const visible = new Set(
rows
.filter(
(row) =>
row.id === viewerId ||
sharesScheduleWith({
visibility: row.preferences?.scheduleVisibility,
isFriend: Boolean(row.isFriend),
sharedTeamIds: row.sharedTeams.map((team) => team.teamId),
}),
)
.map((row) => row.id),
);
return userIds.filter((userId) => visible.has(userId));
}
/**
* Reported weeks of the given users overlapping the window, with slots and day notes. A week
* without slots means "unavailable all week"; no week at all means nothing was reported.
* without slots means "unavailable all week"; no week at all means nothing was reported. Callers
* pass only ids {@link findScheduleVisibleUserIds} handed back.
*/
export function findAllWeeksByUserIds({
userIds,

View File

@@ -1,10 +1,16 @@
import { addWeeks } from "date-fns";
import * as R from "remeda";
import { describe, expect, test } from "vitest";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import type { saveWeekSchema } from "../availability-schemas";
import { SCHEDULE_VISIBILITY_FRIENDS_VALUE } from "../availability-constants";
import type {
saveScheduleVisibilitySchema,
saveWeekSchema,
} from "../availability-schemas";
import * as Availability from "../core/Availability";
import { action as eventsAction } from "./events.server";
@@ -17,6 +23,10 @@ const saveWeek = wrappedAction<typeof saveWeekSchema>({
isJsonSubmission: true,
});
const saveScheduleVisibility = wrappedAction<
typeof saveScheduleVisibilitySchema
>({ action: eventsAction, isJsonSubmission: true });
const weekDays = (weeksFromNow: number) => {
const weekStartsAt = Availability.weekStartsAt(
addWeeks(new Date(), weeksFromNow),
@@ -83,3 +93,101 @@ describe("events action: SAVE_WEEK", () => {
assertResponseErrored(response, "Days do not form one week");
});
});
describe("events action: SAVE_SCHEDULE_VISIBILITY", () => {
const visibilityOf = async (userId: number) =>
(await UserRepository.findLeanById(userId))?.preferences
?.scheduleVisibility;
test("saves the picked friends and teams", async () => {
const user = await UserFactory.createRegular();
const shared = await TeamFactory.create({ memberUserIds: [user.id] });
await TeamFactory.create({
memberUserIds: [user.id],
isMainTeam: false,
});
await saveScheduleVisibility(
{
_action: "SAVE_SCHEDULE_VISIBILITY",
sharedWith: [SCHEDULE_VISIBILITY_FRIENDS_VALUE, String(shared.id)],
},
{ user: "regular" },
);
expect(await visibilityOf(user.id)).toEqual({
friends: true,
teamIds: [shared.id],
});
});
test("stays on the default when everything is shared, so later teams are too", async () => {
const user = await UserFactory.createRegular();
const team = await TeamFactory.create({ memberUserIds: [user.id] });
await saveScheduleVisibility(
{ _action: "SAVE_SCHEDULE_VISIBILITY", sharedWith: [] },
{ user: "regular" },
);
await saveScheduleVisibility(
{
_action: "SAVE_SCHEDULE_VISIBILITY",
sharedWith: [SCHEDULE_VISIBILITY_FRIENDS_VALUE, String(team.id)],
},
{ user: "regular" },
);
expect(await visibilityOf(user.id)).toBeUndefined();
});
test("saves sharing with friends only for a user with no teams", async () => {
const user = await UserFactory.createRegular();
await saveScheduleVisibility(
{
_action: "SAVE_SCHEDULE_VISIBILITY",
sharedWith: [SCHEDULE_VISIBILITY_FRIENDS_VALUE],
},
{ user: "regular" },
);
expect(await visibilityOf(user.id)).toEqual({
friends: true,
teamIds: [],
});
});
test("saves sharing with nobody", async () => {
const user = await UserFactory.createRegular();
await TeamFactory.create({ memberUserIds: [user.id] });
await saveScheduleVisibility(
{ _action: "SAVE_SCHEDULE_VISIBILITY", sharedWith: [] },
{ user: "regular" },
);
expect(await visibilityOf(user.id)).toEqual({
friends: false,
teamIds: [],
});
});
test("drops a team the user is not a member of", async () => {
const user = await UserFactory.createRegular();
const other = await UserFactory.create();
const otherTeam = await TeamFactory.create({ memberUserIds: [other.id] });
await saveScheduleVisibility(
{
_action: "SAVE_SCHEDULE_VISIBILITY",
sharedWith: [String(otherTeam.id)],
},
{ user: "regular" },
);
expect(await visibilityOf(user.id)).toEqual({
friends: false,
teamIds: [],
});
});
});

View File

@@ -3,12 +3,16 @@ import type { ActionFunction } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import {
AVAILABILITY,
SCHEDULE_VISIBILITY_FRIENDS_VALUE,
} from "../availability-constants";
import { eventsActionSchema } from "../availability-schemas";
import * as Availability from "../core/Availability";
@@ -94,6 +98,26 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "SAVE_SCHEDULE_VISIBILITY": {
// intersecting with the actual memberships is the validation, and prunes teams left since
const teams = await TeamRepository.findAllMemberOfByUserId(user.id);
const sharedWith = new Set(data.sharedWith);
const friends = sharedWith.has(SCHEDULE_VISIBILITY_FRIENDS_VALUE);
const teamIds = teams
.filter((team) => sharedWith.has(String(team.id)))
.map((team) => team.id);
await UserRepository.updateOwnPreferences({
// sharing with everyone stays the unset default, so that teams joined later are shared with too
scheduleVisibility:
friends && teams.length > 0 && teamIds.length === teams.length
? undefined
: { friends, teamIds },
});
break;
}
default: {
assertUnreachable(data);
}

View File

@@ -1,3 +1,6 @@
/** Value of the "all friends" option in the schedule visibility picker, where the other options are team ids. */
export const SCHEDULE_VISIBILITY_FRIENDS_VALUE = "FRIENDS";
export const AVAILABILITY = {
/** Granularity availability is entered and rendered at. */
SLOT_STEP_MINUTES: 30,

View File

@@ -57,9 +57,19 @@ export const dismissScheduleNudgeSchema = v.object({
revalidateRoot: v.optional(v.nullable(v.literal(true))),
});
export const saveScheduleVisibilitySchema = v.object({
_action: stringConstant("SAVE_SCHEDULE_VISIBILITY"),
sharedWith: checkboxGroupDynamic({
label: "labels.scheduleSharedWith",
minLength: 0,
}),
revalidateRoot: v.optional(v.nullable(v.literal(true))),
});
export const eventsActionSchema = v.union([
saveWeekSchema,
dismissScheduleNudgeSchema,
saveScheduleVisibilitySchema,
]);
const teamEventDurationItems = [

View File

@@ -19,6 +19,12 @@ export interface PlayableWindow extends TimeRange {
userIds: Array<number>;
}
/** One team the schedule can be shared with, as the visibility picker lists it. */
export interface ScheduleAudienceTeam {
id: number;
name: string;
}
/** A span within one editor day in minutes from midnight. `end` may pass 1440 for a range crossing midnight. */
export interface DayTimeRange {
start: number;

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import {
scheduleAudiencesHiddenFrom,
sharesScheduleWith,
} from "./availability-utils";
const TEAMS = [
{ id: 1, name: "Team Olive" },
{ id: 2, name: "Alliance Rogue" },
];
describe("sharesScheduleWith", () => {
test.each([
{
why: "no preference set",
visibility: undefined,
isFriend: false,
sharedTeamIds: [],
expected: true,
},
{
why: "a friend while sharing with friends",
visibility: { friends: true, teamIds: [] },
isFriend: true,
sharedTeamIds: [],
expected: true,
},
{
why: "a friend while not sharing with friends",
visibility: { friends: false, teamIds: [] },
isFriend: true,
sharedTeamIds: [],
expected: false,
},
{
why: "a stranger while sharing with friends",
visibility: { friends: true, teamIds: [] },
isFriend: false,
sharedTeamIds: [],
expected: false,
},
{
why: "one of the shared teams in common",
visibility: { friends: false, teamIds: [1, 2] },
isFriend: false,
sharedTeamIds: [2],
expected: true,
},
{
why: "only a team left out in common",
visibility: { friends: false, teamIds: [1] },
isFriend: false,
sharedTeamIds: [2],
expected: false,
},
])("$why", ({ expected, ...args }) => {
expect(sharesScheduleWith(args)).toBe(expected);
});
});
describe("scheduleAudiencesHiddenFrom", () => {
test("hides nothing while the schedule is shared with everyone", () => {
expect(
scheduleAudiencesHiddenFrom({ visibility: undefined, teams: TEAMS }),
).toEqual({ friends: false, teams: [] });
});
test.each([
{
why: "friends left out",
visibility: { friends: false, teamIds: [1, 2] },
expected: { friends: true, teams: [] },
},
{
why: "one team left out",
visibility: { friends: true, teamIds: [1] },
expected: { friends: false, teams: [TEAMS[1]] },
},
{
why: "nobody shared with",
visibility: { friends: false, teamIds: [] },
expected: { friends: true, teams: TEAMS },
},
])("names who is hidden with $why", ({ visibility, expected }) => {
expect(scheduleAudiencesHiddenFrom({ visibility, teams: TEAMS })).toEqual(
expected,
);
});
test("counts a team joined after the visibility was saved as hidden", () => {
expect(
scheduleAudiencesHiddenFrom({
visibility: { friends: true, teamIds: [1] },
teams: [...TEAMS, { id: 3, name: "Team Blue" }],
}).teams,
).toEqual([TEAMS[1], { id: 3, name: "Team Blue" }]);
});
});

View File

@@ -0,0 +1,42 @@
import type { UserPreferences } from "~/db/tables-json";
import type { ScheduleAudienceTeam } from "./availability-types";
/**
* Whether a schedule with this visibility is shared with a viewer who is or is not a friend of its
* owner and shares these teams with them. Without the preference it is shared with everyone.
*/
export function sharesScheduleWith({
visibility,
isFriend,
sharedTeamIds,
}: {
visibility: UserPreferences["scheduleVisibility"];
isFriend: boolean;
sharedTeamIds: Array<number>;
}) {
if (!visibility) return true;
return (
(visibility.friends && isFriend) ||
sharedTeamIds.some((teamId) => visibility.teamIds.includes(teamId))
);
}
/**
* Friends and teams the user keeps their schedule from, nothing while it is shared with everyone.
* A team joined after the visibility was last saved is outside the allow-list, so it shows up here.
*/
export function scheduleAudiencesHiddenFrom({
visibility,
teams,
}: {
visibility: UserPreferences["scheduleVisibility"];
teams: Array<ScheduleAudienceTeam>;
}): { friends: boolean; teams: Array<ScheduleAudienceTeam> } {
if (!visibility) return { friends: false, teams: [] };
return {
friends: !visibility.friends,
teams: teams.filter((team) => !visibility.teamIds.includes(team.id)),
};
}

View File

@@ -11,6 +11,11 @@
align-items: center;
gap: var(--s-1);
flex-wrap: wrap;
/* the two links read as one blur of blue at the gap the heading needs */
& > a + button {
margin-inline-start: var(--s-1);
}
}
.weekHeading {

View File

@@ -1,4 +1,4 @@
import { Users } from "lucide-react";
import { Eye, EyeOff, Users } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FetcherWithComponents } from "react-router";
@@ -13,20 +13,37 @@ 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 } from "../availability-types";
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 }: { data: MyScheduleData }) {
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",
});
@@ -107,6 +124,17 @@ export function MySchedule({ data }: { data: MyScheduleData }) {
{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"
@@ -142,6 +170,10 @@ export function MySchedule({ data }: { data: MyScheduleData }) {
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)),
@@ -170,6 +202,12 @@ export function MySchedule({ data }: { data: MyScheduleData }) {
{t("schedule:editor.saveWeek")}
</SendouButton>
</div>
{visibilityDialogOpen ? (
<ScheduleVisibilityDialog
teams={teams}
close={() => setVisibilityDialogOpen(false)}
/>
) : null}
</section>
);
}

View File

@@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next";
import { SendouDialog } from "~/components/elements/Dialog";
import { useUser } from "~/features/auth/core/user";
import { FormField } from "~/form/FormField";
import { SendouForm } from "~/form/SendouForm";
import { SCHEDULE_VISIBILITY_FRIENDS_VALUE } from "../availability-constants";
import { saveScheduleVisibilitySchema } from "../availability-schemas";
import type { ScheduleAudienceTeam } from "../availability-types";
/** Picks the friends and teams the user's schedule is shared with. Nothing saved yet means everyone. */
export function ScheduleVisibilityDialog({
teams,
close,
}: {
teams: Array<ScheduleAudienceTeam>;
close: () => void;
}) {
const { t } = useTranslation(["schedule"]);
const user = useUser();
const saved = user?.preferences.scheduleVisibility;
const teamValues = teams.map((team) => String(team.id));
return (
<SendouDialog
heading={t("schedule:visibility.dialogTitle")}
onClose={close}
>
<SendouForm
schema={saveScheduleVisibilitySchema}
onSuccess={close}
revalidateRoot
defaultValues={{
sharedWith: saved
? [
...(saved.friends ? [SCHEDULE_VISIBILITY_FRIENDS_VALUE] : []),
...teams
.filter((team) => saved.teamIds.includes(team.id))
.map((team) => String(team.id)),
]
: [SCHEDULE_VISIBILITY_FRIENDS_VALUE, ...teamValues],
}}
>
<FormField
name="sharedWith"
options={[
{
value: SCHEDULE_VISIBILITY_FRIENDS_VALUE,
label: () => t("schedule:visibility.allFriends"),
},
...teams.map((team) => ({
value: String(team.id),
label: () => team.name,
})),
]}
/>
</SendouForm>
</SendouDialog>
);
}

View File

@@ -133,6 +133,11 @@
color: var(--color-text-high);
}
.notSharedWith {
color: var(--color-warning-high);
font-weight: var(--weight-semi);
}
.addChip {
display: inline-flex;
align-items: center;

View File

@@ -78,15 +78,18 @@ export function WeekAvailabilityEditor({
value,
onChange,
commitments = [],
notSharedWith = [],
onPendingDraftChange,
}: {
value: AvailabilityEditorWeek;
onChange: (value: AvailabilityEditorWeek) => void;
commitments?: Array<EditorCommitment>;
/** Named friends and teams the week is kept from, called out under the tracks. */
notSharedWith?: Array<string>;
/** Reports edits typed in the day popover but not yet committed into `value`, which an unsaved changes guard would otherwise miss. */
onPendingDraftChange?: (hasPendingDraft: boolean) => void;
}) {
const { t } = useTranslation(["schedule", "common"]);
const { t, i18n } = useTranslation(["schedule", "common"]);
const { formatter: dayFormatter } = useDateTimeFormat({
weekday: "short",
day: "numeric",
@@ -624,8 +627,20 @@ export function WeekAvailabilityEditor({
})}
</div>
<p className={styles.footer}>
{t("schedule:editor.timesInYourTimezone")} ·{" "}
{t("schedule:editor.visibility")}
{t("schedule:editor.timesInYourTimezone")}
{notSharedWith.length > 0 ? (
<span
className={styles.notSharedWith}
data-testid="schedule-not-shared-with"
>
{" · "}
{t("schedule:editor.notSharedWith", {
audiences: new Intl.ListFormat(i18n.language).format(
notSharedWith,
),
})}
</span>
) : null}
</p>
</div>
{openDay ? (

View File

@@ -15,6 +15,8 @@ import { estimatedEndsAtWith } from "./TournamentDuration.server";
* {@link TournamentDuration.estimateSeconds}), accepted scrims (start + assumed length) and team
* events (actual span). Leagues are not blocks, their matches are scheduled separately.
* `excludeTournamentId` leaves one tournament out, for "busy elsewhere" views of that tournament.
* Busy blocks are part of the schedule, so callers pass only ids
* {@link AvailabilityRepository.findScheduleVisibleUserIds} handed back.
*/
export async function busyBlocksByUserIds({
userIds,

View File

@@ -10,6 +10,7 @@ import * as FriendSchedule from "./FriendSchedule.server";
const users = UserFactory.pool();
const friendId = () => users.id(1);
const otherId = () => users.id(2);
const viewerId = () => users.id(3);
const TIMEZONE = "Europe/Helsinki";
const HOUR = 60 * 60;
@@ -23,6 +24,7 @@ const weeksOf = async (userId: number) => {
const schedules = await FriendSchedule.findByUserIds({
userIds: [friendId(), otherId()],
timezone: TIMEZONE,
viewerId: viewerId(),
});
return schedules.get(userId);
@@ -30,7 +32,7 @@ const weeksOf = async (userId: number) => {
describe("FriendSchedule.findByUserIds", () => {
beforeEach(async () => {
await users.create(2);
await users.create(3);
});
test("leaves out a user who reported neither week", async () => {
@@ -43,6 +45,19 @@ describe("FriendSchedule.findByUserIds", () => {
expect(await weeksOf(otherId())).toBeUndefined();
});
test("leaves out a user not sharing their schedule with the viewer", async () => {
await AvailabilityWeekFactory.create({
userId: friendId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
});
await UserFactory.grant(friendId(), {
preferences: { scheduleVisibility: { friends: false, teamIds: [] } },
});
expect(await weeksOf(friendId())).toBeUndefined();
});
test("marks the week they filled in as reported and the other one not", async () => {
await AvailabilityWeekFactory.create({
userId: friendId(),

View File

@@ -1,23 +1,24 @@
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";
import * as VisibleSchedules from "./VisibleSchedules.server";
/**
* Reportable weeks keyed by user id, free time only (commitments subtracted). Users who reported
* neither week are left out; the friends page sorts and shows its calendar icon by the missing key.
* The caller guarantees everyone asked about is a friend or teammate of the viewer.
* neither week, and those not sharing their schedule with the viewer, are left out; the friends
* page sorts and shows its calendar icon by the missing key.
*/
export async function findByUserIds({
userIds,
timezone,
viewerId,
}: {
userIds: Array<number>;
timezone: string;
viewerId: number;
}): Promise<Map<number, Array<ScheduleWeekView>>> {
const now = new Date();
@@ -29,10 +30,11 @@ export async function findByUserIds({
endsAt: ranges[ranges.length - 1].endsAt,
};
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...horizon }),
Commitments.busyBlocksByUserIds({ userIds, ...horizon }),
]);
const { reportedWeeks, busyByUserId } = await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...horizon,
});
const weeks = ranges.map((range, index) => ({
range,

View File

@@ -9,6 +9,7 @@ import * as RegistrationAvailability from "./RegistrationAvailability.server";
const users = UserFactory.pool();
const playerId = () => users.id(1);
const viewerId = () => users.id(2);
const TIMEZONE = "UTC";
const HOUR = 60 * 60;
@@ -32,11 +33,12 @@ const availabilityFor = (startsAt: number) =>
tournament: tournamentStartingAt(startsAt),
userIds: [playerId()],
timezone: TIMEZONE,
viewerId: viewerId(),
});
describe("RegistrationAvailability.registrationAvailability", () => {
beforeEach(async () => {
await users.create(1);
await users.create(2);
});
test("computes nothing for a tournament past the reportable horizon", async () => {
@@ -64,6 +66,31 @@ describe("RegistrationAvailability.registrationAvailability", () => {
expect(result.entries).toHaveLength(1);
});
test("reads as unknown for a player not sharing their schedule", async () => {
const weekStartsAt = weekStartsAtIn(1);
const startsAt = weekStartsAt + 2 * DAY + 18 * HOUR;
await AvailabilityWeekFactory.create({
userId: playerId(),
weekStartsAt,
timezone: TIMEZONE,
slots: [{ startsAt, endsAt: startsAt + 6 * HOUR }],
dayNotes: [
{
date: Availability.dateInTimezone(startsAt, TIMEZONE),
text: "Have to leave by 21",
},
],
});
await UserFactory.grant(playerId(), {
preferences: { scheduleVisibility: { friends: false, teamIds: [] } },
});
const result = await availabilityFor(startsAt);
expect(result.entries?.[0].availability.status).toBe("unknown");
expect(result.entries?.[0].notes).toEqual([]);
});
test("returns only the day notes falling inside the tournament's window", async () => {
const weekStartsAt = weekStartsAtIn(1);
const startsAt = weekStartsAt + 2 * DAY + 18 * HOUR;

View File

@@ -1,12 +1,11 @@
import { addWeeks, subWeeks } from "date-fns";
import type { Tables } from "~/db/tables";
import { databaseTimestampToDate } from "~/utils/dates";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import type { TimeRange } from "../availability-types";
import * as Availability from "./Availability";
import * as Commitments from "./Commitments.server";
import { estimatedEndsAt } from "./TournamentDuration.server";
import * as VisibleSchedules from "./VisibleSchedules.server";
export type RegistrationAvailability = Awaited<
ReturnType<typeof registrationAvailability>
@@ -21,6 +20,7 @@ export async function registrationAvailability({
tournament,
userIds,
timezone,
viewerId,
}: {
tournament: {
id: number;
@@ -33,6 +33,7 @@ export async function registrationAvailability({
};
userIds: Array<number>;
timezone: string;
viewerId: number;
}) {
const startDate = databaseTimestampToDate(tournament.startsAt);
@@ -55,14 +56,13 @@ export async function registrationAvailability({
endsAt: await estimatedEndsAt(tournament),
};
const [weeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...window }),
Commitments.busyBlocksByUserIds({
const { reportedWeeks: weeks, busyByUserId } =
await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...window,
excludeTournamentId: tournament.id,
}),
]);
});
const windowDates = [
Availability.dateInTimezone(window.startsAt, timezone),

View File

@@ -10,6 +10,7 @@ import * as RosterSchedule from "./RosterSchedule.server";
const users = UserFactory.pool();
const memberId = () => users.id(1);
const teammateId = () => users.id(2);
const viewerId = () => users.id(3);
const TIMEZONE = "Europe/Helsinki";
const HOUR = 60 * 60;
@@ -18,14 +19,18 @@ const currentWeekStartsAt = () =>
Availability.weekStartsAt(new Date(), TIMEZONE);
const dataOf = (userIds: Array<number>) =>
RosterSchedule.rosterScheduleData({ userIds, timezone: TIMEZONE });
RosterSchedule.rosterScheduleData({
userIds,
timezone: TIMEZONE,
viewerId: viewerId(),
});
const memberOf = async (userId: number) =>
(await dataOf([userId])).members.find((member) => member.userId === userId);
describe("RosterSchedule.rosterScheduleData", () => {
beforeEach(async () => {
await users.create(2);
await users.create(3);
});
test("lays out the current and the next week as seven days each", async () => {
@@ -42,6 +47,29 @@ describe("RosterSchedule.rosterScheduleData", () => {
}
});
test("keeps an entry for a member not sharing their schedule, with nothing in it", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [
{
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
],
});
await UserFactory.grant(memberId(), {
preferences: { scheduleVisibility: { friends: false, teamIds: [] } },
});
expect(await memberOf(memberId())).toEqual({
userId: memberId(),
reportedWeekStarts: [],
ranges: [],
});
});
test("reports which of the weeks the member has filled in", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),
@@ -103,10 +131,11 @@ describe("RosterSchedule.windowSchedules", () => {
const schedulesOf = async (
windows: Array<ReturnType<typeof window>>,
userIds: Array<number> = [memberId()],
) => RosterSchedule.windowSchedules({ windows, userIds });
) =>
RosterSchedule.windowSchedules({ windows, userIds, viewerId: viewerId() });
beforeEach(async () => {
await users.create(2);
await users.create(3);
});
test("reports what the member has free inside the window", async () => {
@@ -139,6 +168,29 @@ describe("RosterSchedule.windowSchedules", () => {
]);
});
test("keeps an entry for a member not sharing their schedule, with nothing in it", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [
{
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
],
});
await UserFactory.grant(memberId(), {
preferences: { scheduleVisibility: { friends: false, teamIds: [] } },
});
const [schedules] = await schedulesOf([window(1, 20, 23)]);
expect(schedules.members).toEqual([
{ userId: memberId(), reported: false, ranges: [], busy: [] },
]);
});
test("cuts a commitment out of the availability and reports it", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),

View File

@@ -5,12 +5,11 @@ import {
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
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";
import * as VisibleSchedules from "./VisibleSchedules.server";
const DAY_SECONDS = 24 * 60 * 60;
@@ -27,9 +26,11 @@ export type RosterScheduleData = SerializeFrom<
export async function rosterScheduleData({
userIds,
timezone,
viewerId,
}: {
userIds: Array<number>;
timezone: string;
viewerId: number;
}) {
const now = new Date();
const horizon = {
@@ -40,10 +41,11 @@ export async function rosterScheduleData({
).endsAt,
};
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...horizon }),
Commitments.busyBlocksByUserIds({ userIds, ...horizon }),
]);
const { reportedWeeks, busyByUserId } = await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...horizon,
});
const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) =>
weekView({
@@ -118,9 +120,12 @@ function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
export async function windowSchedules({
windows,
userIds,
viewerId,
}: {
windows: Array<TimeRange & { id: number }>;
userIds: Array<number>;
/** Null when logged out, which the scrims page is viewable as. */
viewerId: number | null;
}) {
// the horizon's last week starts at the current week's start at the latest, so nothing inside it reaches this far
const horizonEndsAt = dateToDatabaseTimestamp(
@@ -130,17 +135,20 @@ export async function windowSchedules({
(window) => window.startsAt < horizonEndsAt,
);
if (withinHorizon.length === 0 || userIds.length === 0) return [];
if (withinHorizon.length === 0 || userIds.length === 0 || viewerId === null) {
return [];
}
const range = {
startsAt: Math.min(...withinHorizon.map((window) => window.startsAt)),
endsAt: Math.max(...withinHorizon.map((window) => window.endsAt)),
};
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...range }),
Commitments.busyBlocksByUserIds({ userIds, ...range }),
]);
const { reportedWeeks, busyByUserId } = await VisibleSchedules.findByUserIds({
userIds,
viewerId,
...range,
});
return withinHorizon.map((window) => ({
id: window.id,

View File

@@ -0,0 +1,43 @@
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import type { TimeRange } from "../availability-types";
import * as Commitments from "./Commitments.server";
/**
* Reported weeks and busy blocks overlapping the window, of those of `userIds` who share their
* schedule with the viewer. Every read of other users' schedules goes through here so the
* visibility rule is applied in one place; a user left out looks like one who never filled the
* week in. `excludeTournamentId` keeps that tournament's own registrations from counting as busy.
*/
export async function findByUserIds({
userIds,
viewerId,
startsAt,
endsAt,
excludeTournamentId,
}: TimeRange & {
userIds: Array<number>;
viewerId: number;
excludeTournamentId?: number;
}) {
const visibleUserIds =
await AvailabilityRepository.findScheduleVisibleUserIds({
userIds,
viewerId,
});
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({
userIds: visibleUserIds,
startsAt,
endsAt,
}),
Commitments.busyBlocksByUserIds({
userIds: visibleUserIds,
startsAt,
endsAt,
excludeTournamentId,
}),
]);
return { reportedWeeks, busyByUserId };
}

View File

@@ -18,8 +18,8 @@ import type {
TimeRange,
} from "../availability-types";
import * as Availability from "../core/Availability";
import * as Commitments from "../core/Commitments.server";
import * as ScheduleWeek from "../core/ScheduleWeek";
import * as VisibleSchedules from "../core/VisibleSchedules.server";
export type TeamScheduleLoaderData = SerializeFrom<typeof loader>;
@@ -52,15 +52,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
timezone,
).endsAt,
};
const [reportedWeeks, busyByUserId, teamEvents] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({
userIds: members.map((member) => member.id),
...horizon,
}),
Commitments.busyBlocksByUserIds({
const [{ reportedWeeks, busyByUserId }, teamEvents] = await Promise.all([
VisibleSchedules.findByUserIds({
userIds: members.map((member) => member.id),
viewerId: user.id,
...horizon,
}),
// team events are the team's own data, visible to every member no matter what they share
AvailabilityRepository.findTeamEventsByTeamId({
teamId: team.id,
...horizon,

View File

@@ -8,6 +8,7 @@ import {
teamEventToSidebarEvent,
tournamentToSidebarEvent,
} from "~/features/sidebar/core/sidebar.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
@@ -27,6 +28,7 @@ export const loader = async () => {
);
const mySchedule = await myScheduleData(user.id);
const teamEvents = await findUpcomingTeamEvents(user.id);
const myTeams = await TeamRepository.findAllMemberOfByUserId(user.id);
const registered = tournamentsData.participatingFor
.map(tournamentToSidebarEvent)
@@ -57,5 +59,15 @@ export const loader = async () => {
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startsAt - b.startsAt);
return { registered, hosting, scrims, team, saved, organization, mySchedule };
return {
registered,
hosting,
scrims,
team,
saved,
organization,
mySchedule,
/** Audiences of the schedule visibility picker, alongside the user's friends. */
myTeams: myTeams.map((myTeam) => ({ id: myTeam.id, name: myTeam.name })),
};
};

View File

@@ -68,6 +68,7 @@ export default function EventsPage() {
<MySchedule
key={data.mySchedule.weeks[0].weekStartsAt}
data={data.mySchedule}
teams={data.myTeams}
/>
<div>
<div className={styles.eventsListHeader}>

View File

@@ -25,11 +25,10 @@ export const loader = async () => {
FriendRepository.findPendingSentRequests(user.id),
FriendRepository.findPendingReceivedRequests(user.id),
resolveSendouQMatchStreams(),
// everyone listed is a friend or a teammate, which is what makes their
// schedule theirs to see
FriendSchedule.findByUserIds({
userIds: unique.map((f) => f.id),
timezone: getViewerTimezone() ?? "UTC",
viewerId: user.id,
}),
]);

View File

@@ -34,6 +34,7 @@ export const loader = async () => {
schedule: await RosterSchedule.rosterScheduleData({
userIds: scheduleUserIds,
timezone: getViewerTimezone() ?? "UTC",
viewerId: user.id,
}),
scheduleUsers: R.uniqueBy(
friendsAndTeammates.friends.map((friend) => ({

View File

@@ -70,6 +70,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
availability: await rosterAvailability({
posts: dividedPosts.neutral,
teams,
viewerId: user?.id ?? null,
}),
filters,
canSaveAsDefault:
@@ -85,9 +86,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
async function rosterAvailability({
posts,
teams,
viewerId,
}: {
posts: Array<ScrimPost>;
teams: Awaited<ReturnType<typeof TeamRepository.findAllByMemberUserId>>;
viewerId: number | null;
}) {
const userIds = R.unique(
teams.flatMap((team) =>
@@ -105,6 +108,7 @@ async function rosterAvailability({
...postSpan({ post, now }),
})),
userIds,
viewerId,
}),
};
}

View File

@@ -93,6 +93,7 @@ function rosterAvailability({
},
userIds: R.unique([userId, ...friendIds]),
timezone: getViewerTimezone() ?? "UTC",
viewerId: userId,
});
}

View File

@@ -0,0 +1,9 @@
---
navItem: calendar
type: feature
---
Choose who can see your availability
- A new "Visibility" button next to "My availability" picks the friends and teams you share it with
- Anyone you leave out sees you as not having filled in the week, wherever schedules show up
- If you narrow it down, teams you join later are not shared with until you add them

View File

@@ -3,6 +3,7 @@ import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as Availability from "~/features/availability/core/Availability";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { weekDates, weekRange } from "./helpers/availability";
import {
expect,
impersonate,
@@ -11,13 +12,16 @@ import {
setTimezoneCookie,
test,
} from "./helpers/playwright";
import { createNamedUsers } from "./helpers/sidebar";
import { EventsPage } from "./pages/calendar/events-page";
import { FriendsPage } from "./pages/friends/friends-page";
import { TeamSchedulePage } from "./pages/team/team-schedule-page";
const JOINED_TOURNAMENT_NAME = "Joined Tournament";
const ORGANIZED_TOURNAMENT_NAME = "Organized Tournament";
const WEDNESDAY = 2;
const DAY_SECONDS = 24 * 60 * 60;
const ALL_FRIENDS = "All friends";
test.describe("Events", () => {
test("filters between tabs and navigates to an event", async ({
@@ -274,3 +278,106 @@ test.describe("My schedule", () => {
await expect(page.getByText("Availability saved")).toBeAttached();
});
});
test.describe("Schedule visibility", () => {
test("stops friends seeing the schedule, then opens it up to a team joined later", async ({
page,
factories,
}) => {
const [teammate] = await createNamedUsers(factories, ["Teammate"]);
// N-ZAP is a friend and deliberately not a teammate, so only the friends toggle reaches them
await factories.FriendshipFactory.create({
userOneId: ADMIN_ID,
userTwoId: NZAP_TEST_ID,
});
await factories.AvailabilityWeekFactory.create({
userId: ADMIN_ID,
weekStartsAt: weekRange().startsAt,
timezone: MACHINE_TIMEZONE,
slots: [daySlot(WEDNESDAY, "18:00", "22:00")],
});
const friends = new FriendsPage(page);
await impersonate(page, NZAP_TEST_ID);
await setTimezoneCookie(page);
await friends.goto();
await expect(friends.scheduleButton(ADMIN_ID)).toBeVisible();
const events = new EventsPage(page);
await impersonate(page, ADMIN_ID);
await events.goto();
// nothing restricted yet, so the editor says nothing about who is left out
await isNotVisible(events.locators.notSharedWith);
await events.setScheduleVisibility({ uncheck: [ALL_FRIENDS] });
await expect(events.locators.visibilityButton).toHaveText("Limited");
await expect(events.locators.notSharedWith).toHaveText(
"· Not shared with friends",
);
await impersonate(page, NZAP_TEST_ID);
await friends.goto();
await isNotVisible(friends.scheduleButton(ADMIN_ID));
// joined after the visibility was saved, so it starts outside the allow-list
const team = await factories.TeamFactory.create({
name: "Team Olive",
memberUserIds: [teammate.id, ADMIN_ID],
});
const schedule = new TeamSchedulePage(page);
await impersonate(page, teammate.id);
await setTimezoneCookie(page);
await schedule.goto(team.customUrl);
await schedule.locators.gridViewTab.click();
await isNotVisible(schedule.cellRange(ADMIN_ID, WEDNESDAY));
await impersonate(page, ADMIN_ID);
await events.goto();
// the team joined after the save is outside the allow-list, and named as such
await expect(events.locators.notSharedWith).toHaveText(
"· Not shared with friends and Team Olive",
);
await events.setScheduleVisibility({ check: ["Team Olive"] });
await expect(events.locators.notSharedWith).toHaveText(
"· Not shared with friends",
);
await impersonate(page, teammate.id);
await schedule.goto(team.customUrl);
await schedule.locators.gridViewTab.click();
await expect(schedule.cellRange(ADMIN_ID, WEDNESDAY)).toBeVisible();
// adding the team did not quietly restore the friends sharing the dialog opened with
await impersonate(page, NZAP_TEST_ID);
await friends.goto();
await isNotVisible(friends.scheduleButton(ADMIN_ID));
});
});
function daySlot(dayIndex: number, start: string, end: string) {
const date = weekDates()[dayIndex];
return {
startsAt: Availability.localToTimestamp({
date,
time: start,
timezone: MACHINE_TIMEZONE,
}),
endsAt: Availability.localToTimestamp({
date,
time: end,
timezone: MACHINE_TIMEZONE,
}),
};
}

View File

@@ -1,6 +1,6 @@
import type { Locator, Page } from "@playwright/test";
import { EVENTS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { navigate, submit } from "../../helpers/playwright";
const VIEW_LABELS = {
registered: "Registered",
@@ -30,6 +30,8 @@ export class EventsPage {
copyLastWeekButton: page.getByTestId("copy-last-week-button"),
dayEditorPopover: page.getByRole("dialog"),
teamScheduleLink: page.getByTestId("team-schedule-link"),
visibilityButton: page.getByTestId("schedule-visibility-button"),
notSharedWith: page.getByTestId("schedule-not-shared-with"),
// the chip radio input is visually hidden, so the label is what clicks
nextWeekToggle: page.locator(
'label[for="chip-radio-my-schedule-week-next"]',
@@ -81,6 +83,30 @@ export class EventsPage {
await navigate({ page: this.page, url: EVENTS_PAGE });
}
/**
* Opens the schedule visibility dialog, toggles the named audiences and saves. The options are
* team names and "All friends", data rather than locale keys, so they are matched by their name.
*/
async setScheduleVisibility({
check = [],
uncheck = [],
}: {
check?: Array<string>;
uncheck?: Array<string>;
}) {
await this.locators.visibilityButton.click();
const dialog = this.page.getByRole("dialog");
for (const name of uncheck) {
await dialog.getByRole("checkbox", { name, exact: true }).uncheck();
}
for (const name of check) {
await dialog.getByRole("checkbox", { name, exact: true }).check();
}
await submit(this.page);
}
/** The tabs carry the category's event count, so they are matched by their start. */
async openView(view: keyof typeof VIEW_LABELS) {
await this.locators.viewTabs

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "Hidden trophies",
"bottomTexts.trophyModel": "The 3D model state exported from the",
"errors.trophyNameTaken": "A trophy with this name already exists",
"errors.trophyWithBadges": "Cannot combine trophy and badges"
"errors.trophyWithBadges": "Cannot combine trophy and badges",
"labels.scheduleSharedWith": "Shared with"
}

View File

@@ -12,7 +12,7 @@
"editor.title": "My availability",
"editor.teamSchedule": "Team schedule",
"editor.timesInYourTimezone": "Times in your time zone",
"editor.visibility": "Visible to your teammates and friends",
"editor.notSharedWith": "Not shared with {{audiences}}",
"events.title": "Team events",
"events.add": "Add event",
"events.addDialogTitle": "Add team event",
@@ -50,5 +50,10 @@
"picker.andOthers": "{{amount}} more",
"picker.legend.full": "{{players}}+ free",
"picker.legend.oneShort": "{{players}} free (sub?)",
"scrims.availableOfRoster": "{{amount}}/{{total}} available"
"scrims.availableOfRoster": "{{amount}}/{{total}} available",
"visibility.button": "Visibility",
"visibility.dialogTitle": "Who can see your schedule?",
"visibility.allFriends": "All friends",
"visibility.limited": "Limited",
"visibility.friends": "friends"
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "Trofeos ocultos",
"bottomTexts.trophyModel": "El estado del modelo 3D exportado desde el",
"errors.trophyNameTaken": "Ya existe un trofeo con este nombre",
"errors.trophyWithBadges": "No se pueden combinar trofeo e insignias"
"errors.trophyWithBadges": "No se pueden combinar trofeo e insignias",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "Trofeos ocultos",
"bottomTexts.trophyModel": "El estado del modelo 3D exportado desde el",
"errors.trophyNameTaken": "Ya existe un trofeo con este nombre",
"errors.trophyWithBadges": "No se pueden combinar trofeo e insignias"
"errors.trophyWithBadges": "No se pueden combinar trofeo e insignias",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -463,5 +463,6 @@
"labels.profileHiddenTrophies": "",
"bottomTexts.trophyModel": "",
"errors.trophyNameTaken": "",
"errors.trophyWithBadges": ""
"errors.trophyWithBadges": "",
"labels.scheduleSharedWith": ""
}

View File

@@ -12,7 +12,7 @@
"editor.title": "",
"editor.teamSchedule": "",
"editor.timesInYourTimezone": "",
"editor.visibility": "",
"editor.notSharedWith": "",
"events.title": "",
"events.add": "",
"events.addDialogTitle": "",
@@ -50,5 +50,10 @@
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
"scrims.availableOfRoster": "",
"visibility.button": "",
"visibility.dialogTitle": "",
"visibility.allFriends": "",
"visibility.limited": "",
"visibility.friends": ""
}

View File

@@ -151,6 +151,15 @@ export function buildCases(fx: Fixtures): {
LogInLinkRepository.findValidByCode(code),
);
add(
"AvailabilityRepository.findScheduleVisibleUserIds",
both(fx.manyUserIds, fx.heavyUser),
([userIds, user]) =>
AvailabilityRepository.findScheduleVisibleUserIds({
userIds,
viewerId: user.id,
}),
);
add(
"AvailabilityRepository.findAllWeeksByUserIds",
both(fx.manyUserIds, fx.availabilityWindow),