diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 45d1e222c..023c828f7 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -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 }; } export type Pronouns = { diff --git a/app/features/availability/AvailabilityRepository.server.test.ts b/app/features/availability/AvailabilityRepository.server.test.ts index 2a8b9b738..8fc508153 100644 --- a/app/features/availability/AvailabilityRepository.server.test.ts +++ b/app/features/availability/AvailabilityRepository.server.test.ts @@ -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 = { + 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([]); + }); +}); diff --git a/app/features/availability/AvailabilityRepository.server.ts b/app/features/availability/AvailabilityRepository.server.ts index 3f683659d..a188618d8 100644 --- a/app/features/availability/AvailabilityRepository.server.ts +++ b/app/features/availability/AvailabilityRepository.server.ts @@ -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; + viewerId: number; +}): Promise> { + 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, diff --git a/app/features/availability/actions/events.server.test.ts b/app/features/availability/actions/events.server.test.ts index 7f0791a10..fe772992f 100644 --- a/app/features/availability/actions/events.server.test.ts +++ b/app/features/availability/actions/events.server.test.ts @@ -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({ 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: [], + }); + }); +}); diff --git a/app/features/availability/actions/events.server.ts b/app/features/availability/actions/events.server.ts index 2f2eacb53..00a0d247b 100644 --- a/app/features/availability/actions/events.server.ts +++ b/app/features/availability/actions/events.server.ts @@ -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); } diff --git a/app/features/availability/availability-constants.ts b/app/features/availability/availability-constants.ts index 10b689ac1..91560bfd4 100644 --- a/app/features/availability/availability-constants.ts +++ b/app/features/availability/availability-constants.ts @@ -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, diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts index c7985d0ea..5aa089e43 100644 --- a/app/features/availability/availability-schemas.ts +++ b/app/features/availability/availability-schemas.ts @@ -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 = [ diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index 1e58eba15..7546e7b6b 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -19,6 +19,12 @@ export interface PlayableWindow extends TimeRange { userIds: Array; } +/** 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; diff --git a/app/features/availability/availability-utils.test.ts b/app/features/availability/availability-utils.test.ts new file mode 100644 index 000000000..042ad19c9 --- /dev/null +++ b/app/features/availability/availability-utils.test.ts @@ -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" }]); + }); +}); diff --git a/app/features/availability/availability-utils.ts b/app/features/availability/availability-utils.ts new file mode 100644 index 000000000..c23a10db5 --- /dev/null +++ b/app/features/availability/availability-utils.ts @@ -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; +}) { + 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; +}): { friends: boolean; teams: Array } { + if (!visibility) return { friends: false, teams: [] }; + + return { + friends: !visibility.friends, + teams: teams.filter((team) => !visibility.teamIds.includes(team.id)), + }; +} diff --git a/app/features/availability/components/MySchedule.module.css b/app/features/availability/components/MySchedule.module.css index 01ce5666b..1b1d74cbe 100644 --- a/app/features/availability/components/MySchedule.module.css +++ b/app/features/availability/components/MySchedule.module.css @@ -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 { diff --git a/app/features/availability/components/MySchedule.tsx b/app/features/availability/components/MySchedule.tsx index bf6b29bdd..4eb3a6a02 100644 --- a/app/features/availability/components/MySchedule.tsx +++ b/app/features/availability/components/MySchedule.tsx @@ -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; +}) { const { t } = useTranslation(["schedule"]); const user = useUser(); const [{ week }, setParams] = useSearchParamsTyped(scheduleWeekSearchParams); const [weeks, setWeeks] = React.useState>(() => 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")} ) : null} + : } + onClick={() => setVisibilityDialogOpen(true)} + testId="schedule-visibility-button" + > + {isRestricted + ? t("schedule:visibility.limited") + : t("schedule:visibility.button")} + 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")} + {visibilityDialogOpen ? ( + setVisibilityDialogOpen(false)} + /> + ) : null} ); } diff --git a/app/features/availability/components/ScheduleVisibilityDialog.tsx b/app/features/availability/components/ScheduleVisibilityDialog.tsx new file mode 100644 index 000000000..b070a8131 --- /dev/null +++ b/app/features/availability/components/ScheduleVisibilityDialog.tsx @@ -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; + close: () => void; +}) { + const { t } = useTranslation(["schedule"]); + const user = useUser(); + const saved = user?.preferences.scheduleVisibility; + + const teamValues = teams.map((team) => String(team.id)); + + return ( + + saved.teamIds.includes(team.id)) + .map((team) => String(team.id)), + ] + : [SCHEDULE_VISIBILITY_FRIENDS_VALUE, ...teamValues], + }} + > + t("schedule:visibility.allFriends"), + }, + ...teams.map((team) => ({ + value: String(team.id), + label: () => team.name, + })), + ]} + /> + + + ); +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css index dfde1ea51..923b5afd3 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.module.css +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -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; diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx index 25ccfe089..df798cb0b 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.tsx +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -78,15 +78,18 @@ export function WeekAvailabilityEditor({ value, onChange, commitments = [], + notSharedWith = [], onPendingDraftChange, }: { value: AvailabilityEditorWeek; onChange: (value: AvailabilityEditorWeek) => void; commitments?: Array; + /** Named friends and teams the week is kept from, called out under the tracks. */ + notSharedWith?: Array; /** 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({ })}

- {t("schedule:editor.timesInYourTimezone")} ·{" "} - {t("schedule:editor.visibility")} + {t("schedule:editor.timesInYourTimezone")} + {notSharedWith.length > 0 ? ( + + {" · "} + {t("schedule:editor.notSharedWith", { + audiences: new Intl.ListFormat(i18n.language).format( + notSharedWith, + ), + })} + + ) : null}

{openDay ? ( diff --git a/app/features/availability/core/Commitments.server.ts b/app/features/availability/core/Commitments.server.ts index 606cf6a34..8bff66153 100644 --- a/app/features/availability/core/Commitments.server.ts +++ b/app/features/availability/core/Commitments.server.ts @@ -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, diff --git a/app/features/availability/core/FriendSchedule.server.test.ts b/app/features/availability/core/FriendSchedule.server.test.ts index 361dcc41d..bca42a1c3 100644 --- a/app/features/availability/core/FriendSchedule.server.test.ts +++ b/app/features/availability/core/FriendSchedule.server.test.ts @@ -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(), diff --git a/app/features/availability/core/FriendSchedule.server.ts b/app/features/availability/core/FriendSchedule.server.ts index da02ce2f9..e9652b3a0 100644 --- a/app/features/availability/core/FriendSchedule.server.ts +++ b/app/features/availability/core/FriendSchedule.server.ts @@ -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; timezone: string; + viewerId: number; }): Promise>> { 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, diff --git a/app/features/availability/core/RegistrationAvailability.server.test.ts b/app/features/availability/core/RegistrationAvailability.server.test.ts index f8939d9e8..aa200f3ac 100644 --- a/app/features/availability/core/RegistrationAvailability.server.test.ts +++ b/app/features/availability/core/RegistrationAvailability.server.test.ts @@ -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; diff --git a/app/features/availability/core/RegistrationAvailability.server.ts b/app/features/availability/core/RegistrationAvailability.server.ts index 95153fe59..b81cd6fb9 100644 --- a/app/features/availability/core/RegistrationAvailability.server.ts +++ b/app/features/availability/core/RegistrationAvailability.server.ts @@ -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 @@ -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; 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), diff --git a/app/features/availability/core/RosterSchedule.server.test.ts b/app/features/availability/core/RosterSchedule.server.test.ts index 29180b6f1..81679fbd9 100644 --- a/app/features/availability/core/RosterSchedule.server.test.ts +++ b/app/features/availability/core/RosterSchedule.server.test.ts @@ -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) => - 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>, userIds: Array = [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(), diff --git a/app/features/availability/core/RosterSchedule.server.ts b/app/features/availability/core/RosterSchedule.server.ts index 6a0ff69f7..d91882dfa 100644 --- a/app/features/availability/core/RosterSchedule.server.ts +++ b/app/features/availability/core/RosterSchedule.server.ts @@ -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; 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; userIds: Array; + /** 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, diff --git a/app/features/availability/core/VisibleSchedules.server.ts b/app/features/availability/core/VisibleSchedules.server.ts new file mode 100644 index 000000000..78d25d9d7 --- /dev/null +++ b/app/features/availability/core/VisibleSchedules.server.ts @@ -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; + 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 }; +} diff --git a/app/features/availability/loaders/t.$customUrl.schedule.server.ts b/app/features/availability/loaders/t.$customUrl.schedule.server.ts index 240c94f09..ca323a9f1 100644 --- a/app/features/availability/loaders/t.$customUrl.schedule.server.ts +++ b/app/features/availability/loaders/t.$customUrl.schedule.server.ts @@ -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; @@ -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, diff --git a/app/features/calendar/loaders/events.server.ts b/app/features/calendar/loaders/events.server.ts index 48ad1b9af..2d07b7384 100644 --- a/app/features/calendar/loaders/events.server.ts +++ b/app/features/calendar/loaders/events.server.ts @@ -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 })), + }; }; diff --git a/app/features/calendar/routes/events.tsx b/app/features/calendar/routes/events.tsx index 67aae1b77..8c5e87795 100644 --- a/app/features/calendar/routes/events.tsx +++ b/app/features/calendar/routes/events.tsx @@ -68,6 +68,7 @@ export default function EventsPage() {
diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 4f7262d07..73192c1f9 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -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, }), ]); diff --git a/app/features/scrims/loaders/scrims.new.server.ts b/app/features/scrims/loaders/scrims.new.server.ts index 234ed720b..b24c264bf 100644 --- a/app/features/scrims/loaders/scrims.new.server.ts +++ b/app/features/scrims/loaders/scrims.new.server.ts @@ -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) => ({ diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 95a192e2c..3fa2e7b00 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -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; teams: Awaited>; + viewerId: number | null; }) { const userIds = R.unique( teams.flatMap((team) => @@ -105,6 +108,7 @@ async function rosterAvailability({ ...postSpan({ post, now }), })), userIds, + viewerId, }), }; } diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index c843b0201..65edb5f5d 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -93,6 +93,7 @@ function rosterAvailability({ }, userIds: R.unique([userId, ...friendIds]), timezone: getViewerTimezone() ?? "UTC", + viewerId: userId, }); } diff --git a/changelog/2026-09-08-schedule-visibility.md b/changelog/2026-09-08-schedule-visibility.md new file mode 100644 index 000000000..3730a1e73 --- /dev/null +++ b/changelog/2026-09-08-schedule-visibility.md @@ -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 diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index 091bee947..4ad030b00 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -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, + }), + }; +} diff --git a/e2e/pages/calendar/events-page.ts b/e2e/pages/calendar/events-page.ts index f07378e9b..7f531cd4e 100644 --- a/e2e/pages/calendar/events-page.ts +++ b/e2e/pages/calendar/events-page.ts @@ -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; + uncheck?: Array; + }) { + 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 diff --git a/locales/da/forms.json b/locales/da/forms.json index fcc5749b3..4b1397cd6 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/da/schedule.json b/locales/da/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -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": "" } diff --git a/locales/de/forms.json b/locales/de/forms.json index 3b74e8b27..8f30a4b17 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/de/schedule.json b/locales/de/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -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": "" } diff --git a/locales/en/forms.json b/locales/en/forms.json index 01f2634f6..5d1f871e2 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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" } diff --git a/locales/en/schedule.json b/locales/en/schedule.json index 90bd8e6f5..42a552eec 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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" } diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 7cc8466db..a13fa0a8f 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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": "" } diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -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": "" } diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 4f997a1f5..142254478 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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": "" } diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -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": "" } diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 9b19e7166..48d3a3020 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -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": "" } diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 97a129284..2f870a571 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -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": "" } diff --git a/locales/he/forms.json b/locales/he/forms.json index e9e481508..7163342bd 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/he/schedule.json b/locales/he/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -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": "" } diff --git a/locales/it/forms.json b/locales/it/forms.json index 8d1083443..84d2a4f5a 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/it/schedule.json b/locales/it/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -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": "" } diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 931a67004..7d4c26707 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -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": "" } diff --git a/locales/ko/forms.json b/locales/ko/forms.json index d93fbd00a..e0c6470c4 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -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": "" } diff --git a/locales/nl/forms.json b/locales/nl/forms.json index cad2bac06..4f8490cca 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -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": "" } diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 76d382d9a..1935eec4c 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -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": "" } diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 71b811fd2..b66ef97eb 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -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": "" } diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 7eb688e6e..e563e9cbf 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -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": "" } diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 2f3592d51..d9b79d9a4 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -463,5 +463,6 @@ "labels.profileHiddenTrophies": "", "bottomTexts.trophyModel": "", "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "errors.trophyWithBadges": "", + "labels.scheduleSharedWith": "" } diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index a3ebd18a1..38113834a 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -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": "" } diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 575735407..0934723a0 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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),