mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 06:06:28 -05:00
My availability visibility
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
98
app/features/availability/availability-utils.test.ts
Normal file
98
app/features/availability/availability-utils.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
42
app/features/availability/availability-utils.ts
Normal file
42
app/features/availability/availability-utils.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
app/features/availability/core/VisibleSchedules.server.ts
Normal file
43
app/features/availability/core/VisibleSchedules.server.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function rosterAvailability({
|
||||
},
|
||||
userIds: R.unique([userId, ...friendIds]),
|
||||
timezone: getViewerTimezone() ?? "UTC",
|
||||
viewerId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
9
changelog/2026-09-08-schedule-visibility.md
Normal file
9
changelog/2026-09-08-schedule-visibility.md
Normal 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
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -463,5 +463,6 @@
|
||||
"labels.profileHiddenTrophies": "",
|
||||
"bottomTexts.trophyModel": "",
|
||||
"errors.trophyNameTaken": "",
|
||||
"errors.trophyWithBadges": ""
|
||||
"errors.trophyWithBadges": "",
|
||||
"labels.scheduleSharedWith": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user