Team events: select members & allow editing

This commit is contained in:
Kalle
2026-09-01 17:52:38 +03:00
parent 0380612093
commit 5e6b282b2f
42 changed files with 659 additions and 37 deletions

View File

@@ -1384,6 +1384,12 @@ export interface TeamEvent {
createdAt: Generated<number>;
}
/** Participant of a team event limited to selected members. No rows for an event = the whole team takes part. */
export interface TeamEventMember {
teamEventId: number;
userId: number;
}
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
@@ -1538,4 +1544,5 @@ export interface DB {
AvailabilitySlot: AvailabilitySlot;
AvailabilityDayNote: AvailabilityDayNote;
TeamEvent: TeamEvent;
TeamEventMember: TeamEventMember;
}

View File

@@ -394,6 +394,82 @@ describe("AvailabilityRepository.findTeamEventsByTeamId", () => {
expect(events).toHaveLength(1);
expect(events[0].name).toBe("VoD review");
});
test("returns the participants of an event limited to selected members, empty for a whole-team event", async () => {
const team = await TeamFactory.create({
name: "Alpha",
memberUserIds: [users.id(1), users.id(2)],
});
await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "Whole team",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
});
await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "Selected only",
startsAt: at("2026-08-26", "20:00"),
endsAt: at("2026-08-26", "21:00"),
participantUserIds: [users.id(2)],
});
const events = await AvailabilityRepository.findTeamEventsByTeamId({
teamId: team.id,
...WINDOW,
});
expect(events).toHaveLength(2);
expect(events[0].participants).toEqual([]);
expect(events[1].participants).toEqual([{ userId: users.id(2) }]);
});
});
describe("AvailabilityRepository.findAllTeamEventsByUserIds", () => {
beforeEach(async () => {
await users.create(2);
});
test("an event limited to selected members produces rows only for them", async () => {
const team = await TeamFactory.create({
name: "Alpha",
memberUserIds: [users.id(1), users.id(2)],
});
await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "Whole team",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
});
await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "Selected only",
startsAt: at("2026-08-26", "20:00"),
endsAt: at("2026-08-26", "21:00"),
participantUserIds: [users.id(2)],
});
const events = await AvailabilityRepository.findAllTeamEventsByUserIds({
userIds: [users.id(1), users.id(2)],
...WINDOW,
});
expect(
events.filter((event) => event.userId === users.id(1)).map((e) => e.name),
).toEqual(["Whole team"]);
expect(
events
.filter((event) => event.userId === users.id(2))
.map((e) => e.name)
.sort(),
).toEqual(["Selected only", "Whole team"]);
});
});
describe("AvailabilityRepository.findAllUpcomingTeamEventsByUserId", () => {
@@ -461,6 +537,104 @@ describe("AvailabilityRepository.findAllUpcomingTeamEventsByUserId", () => {
}),
).toEqual([]);
});
test("leaves out an event limited to selected members the user is not one of", async () => {
const team = await TeamFactory.create({
name: "Alpha",
memberUserIds: [users.id(1), users.id(2)],
});
await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "Selected only",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
participantUserIds: [users.id(2)],
});
expect(
await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
userId: users.id(1),
...WINDOW,
}),
).toEqual([]);
expect(
await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
userId: users.id(2),
...WINDOW,
}),
).toHaveLength(1);
});
});
describe("AvailabilityRepository.updateTeamEvent", () => {
beforeEach(async () => {
await users.create(2);
});
test("updates the event and replaces its participant limitation", async () => {
const team = await TeamFactory.create({
name: "Alpha",
memberUserIds: [users.id(1), users.id(2)],
});
const event = await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "VoD review",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
});
await AvailabilityRepository.updateTeamEvent({
id: event.id,
name: "Scrim review",
startsAt: at("2026-08-26", "19:00"),
endsAt: at("2026-08-26", "20:30"),
participantUserIds: [users.id(2)],
});
const [updated] = await AvailabilityRepository.findTeamEventsByTeamId({
teamId: team.id,
...WINDOW,
});
expect(updated).toMatchObject({
name: "Scrim review",
startsAt: at("2026-08-26", "19:00"),
endsAt: at("2026-08-26", "20:30"),
participants: [{ userId: users.id(2) }],
});
});
test("clears the participant limitation when updated back to the whole team", async () => {
const team = await TeamFactory.create({
name: "Alpha",
memberUserIds: [users.id(1), users.id(2)],
});
const event = await TeamEventFactory.create({
teamId: team.id,
authorId: users.id(1),
name: "VoD review",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
participantUserIds: [users.id(2)],
});
await AvailabilityRepository.updateTeamEvent({
id: event.id,
name: "VoD review",
startsAt: at("2026-08-25", "20:00"),
endsAt: at("2026-08-25", "21:00"),
});
const [updated] = await AvailabilityRepository.findTeamEventsByTeamId({
teamId: team.id,
...WINDOW,
});
expect(updated.participants).toEqual([]);
});
});
describe("AvailabilityRepository.deleteTeamEvent", () => {

View File

@@ -150,7 +150,8 @@ export async function findWeekReminderUserIds(weekStartsAt: number) {
/**
* Team events of every team the given users are members of (secondary teams
* included) that overlap the given window, one row per member.
* included) that overlap the given window, one row per member. Events limited
* to selected participants only produce rows for those participants.
*/
export function findAllTeamEventsByUserIds({
userIds,
@@ -179,10 +180,27 @@ export function findAllTeamEventsByUserIds({
.where("TeamMemberWithSecondary.userId", "in", userIds)
.where("TeamEvent.startsAt", "<", endsAt)
.where("TeamEvent.endsAt", ">", startsAt)
.where((eb) => {
const participants = eb
.selectFrom("TeamEventMember")
.select("TeamEventMember.userId")
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id");
return eb.or([
eb.not(eb.exists(participants)),
eb.exists(
participants.whereRef(
"TeamEventMember.userId",
"=",
"TeamMemberWithSecondary.userId",
),
),
]);
})
.execute();
}
/** Team events of one team overlapping the given window. */
/** Team events of one team overlapping the given window, with the participant user ids of events limited to selected members (empty = the whole team). */
export function findTeamEventsByTeamId({
teamId,
startsAt,
@@ -194,11 +212,18 @@ export function findTeamEventsByTeamId({
}) {
return db
.selectFrom("TeamEvent")
.select([
.select((eb) => [
"TeamEvent.id",
"TeamEvent.name",
"TeamEvent.startsAt",
"TeamEvent.endsAt",
jsonArrayFrom(
eb
.selectFrom("TeamEventMember")
.select("TeamEventMember.userId")
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id")
.orderBy("TeamEventMember.userId", "asc"),
).as("participants"),
])
.where("TeamEvent.teamId", "=", teamId)
.where("TeamEvent.startsAt", "<", endsAt)
@@ -210,7 +235,8 @@ export function findTeamEventsByTeamId({
/**
* Ongoing and upcoming team events of every team the given user is a member of
* (secondary teams included), starting within the given window, with the
* owning team attached. For the user's personal calendar surfaces.
* owning team attached. Events limited to selected participants show up only
* for those participants. For the user's personal calendar surfaces.
*/
export function findAllUpcomingTeamEventsByUserId({
userId,
@@ -244,6 +270,17 @@ export function findAllUpcomingTeamEventsByUserId({
.where("TeamMemberWithSecondary.userId", "=", userId)
.where("TeamEvent.endsAt", ">", startsAt)
.where("TeamEvent.startsAt", "<", endsAt)
.where((eb) => {
const participants = eb
.selectFrom("TeamEventMember")
.select("TeamEventMember.userId")
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id");
return eb.or([
eb.not(eb.exists(participants)),
eb.exists(participants.where("TeamEventMember.userId", "=", userId)),
]);
})
.orderBy("TeamEvent.startsAt", "asc")
.execute();
}
@@ -374,20 +411,75 @@ export function deleteTeamEventsEndedBefore(endsAt: number) {
}
/**
* Adds an event the whole team takes part in. Author is the acting user.
* Adds a team event. Author is the acting user. Without `participantUserIds`
* the whole team takes part; with them only the given members do.
*
* @returns id of the new event
*/
export async function insertTeamEvent(
args: Omit<TablesInsertable["TeamEvent"], "authorId">,
) {
const event = await db
.insertInto("TeamEvent")
.values({ ...args, authorId: actorId() })
.returning("id")
.executeTakeFirstOrThrow();
export function insertTeamEvent({
participantUserIds,
...args
}: Omit<TablesInsertable["TeamEvent"], "authorId"> & {
participantUserIds?: Array<number>;
}) {
const authorId = actorId();
return event.id;
return db.transaction().execute(async (trx) => {
const event = await trx
.insertInto("TeamEvent")
.values({ ...args, authorId })
.returning("id")
.executeTakeFirstOrThrow();
if (participantUserIds && participantUserIds.length > 0) {
await trx
.insertInto("TeamEventMember")
.values(
participantUserIds.map((userId) => ({
teamEventId: event.id,
userId,
})),
)
.execute();
}
return event.id;
});
}
/** Updates a team event, replacing its participant limitation (none = the whole team takes part). */
export function updateTeamEvent({
id,
participantUserIds,
...args
}: {
id: number;
name: string;
startsAt: number;
endsAt: number;
participantUserIds?: Array<number>;
}) {
return db.transaction().execute(async (trx) => {
await trx
.updateTable("TeamEvent")
.set(args)
.where("TeamEvent.id", "=", id)
.execute();
await trx
.deleteFrom("TeamEventMember")
.where("TeamEventMember.teamEventId", "=", id)
.execute();
if (participantUserIds && participantUserIds.length > 0) {
await trx
.insertInto("TeamEventMember")
.values(
participantUserIds.map((userId) => ({ teamEventId: id, userId })),
)
.execute();
}
});
}
export function deleteTeamEvent(id: number) {

View File

@@ -36,18 +36,23 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "ADD_EVENT": {
const startsAt = dateToDatabaseTimestamp(data.startsAt);
const participantUserIds = validatedParticipantUserIds(data, team);
await AvailabilityRepository.insertTeamEvent({
teamId: team.id,
name: data.name,
startsAt,
endsAt: startsAt + Number(data.duration) * 60,
participantUserIds,
});
await notify({
userIds: team.members
.filter(
(member) => member.id !== user.id && member.role !== "CHEERLEADER",
(member) =>
member.id !== user.id &&
member.role !== "CHEERLEADER" &&
(!participantUserIds || participantUserIds.includes(member.id)),
)
.map((member) => member.id),
notification: {
@@ -63,6 +68,27 @@ export const action: ActionFunction = async ({ request, params }) => {
return null;
}
case "EDIT_EVENT": {
const event = notFoundIfNullish(
await AvailabilityRepository.findTeamEventById(data.eventId),
);
errorToastIfFalsy(
event.teamId === team.id,
"Event does not belong to the team",
);
const startsAt = dateToDatabaseTimestamp(data.startsAt);
await AvailabilityRepository.updateTeamEvent({
id: event.id,
name: data.name,
startsAt,
endsAt: startsAt + Number(data.duration) * 60,
participantUserIds: validatedParticipantUserIds(data, team),
});
return null;
}
case "DELETE_EVENT": {
const event = notFoundIfNullish(
await AvailabilityRepository.findTeamEventById(data.eventId),
@@ -80,3 +106,25 @@ export const action: ActionFunction = async ({ request, params }) => {
assertUnreachable(data);
}
};
function validatedParticipantUserIds(
data: {
participants: "ALL" | "SELECTED";
participantUserIds: Array<string>;
},
team: NonNullable<Awaited<ReturnType<typeof TeamRepository.findByCustomUrl>>>,
) {
if (data.participants !== "SELECTED") return undefined;
const userIds = data.participantUserIds.map(Number);
errorToastIfFalsy(
userIds.every((userId) =>
team.members.some(
(member) => member.id === userId && member.role !== "CHEERLEADER",
),
),
"Participants must be members of the team",
);
return userIds;
}

View File

@@ -1,7 +1,15 @@
import { add, sub } from "date-fns";
import { add, addWeeks, startOfWeek, sub } from "date-fns";
import * as v from "valibot";
import { datetime, select, stringConstant, textField } from "~/form/fields";
import { _action, id } from "~/utils/schema";
import {
checkboxGroupDynamic,
datetime,
idConstant,
radioGroup,
select,
stringConstant,
textField,
} from "~/form/fields";
import { _action, id, superRefine, type ValidationCtx } from "~/utils/schema";
import { AVAILABILITY } from "./availability-constants";
const DAY_MINUTES = 24 * 60;
@@ -66,8 +74,7 @@ const teamEventDurationItems = [
{ label: "options.duration.6h" as const, value: "360" },
] as const;
export const addTeamEventSchema = v.object({
_action: stringConstant("ADD_EVENT"),
const teamEventFields = {
name: textField({
label: "labels.name",
maxLength: AVAILABILITY.TEAM_EVENT_NAME_MAX_LENGTH,
@@ -75,7 +82,16 @@ export const addTeamEventSchema = v.object({
startsAt: datetime({
label: "labels.start",
min: () => sub(new Date(), { hours: 1 }),
max: () => add(new Date(), { months: 2 }),
// end of the next week, the furthest the schedule shows; the hours are
// slack for server-side validation running in another timezone than the
// viewer's week
max: () =>
add(
startOfWeek(addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON), {
weekStartsOn: 1,
}),
{ hours: 14 },
),
minMessage: "errors.dateInPast",
maxMessage: "errors.dateTooFarAway",
}),
@@ -84,7 +100,49 @@ export const addTeamEventSchema = v.object({
items: [...teamEventDurationItems],
initialValue: "60",
}),
});
participants: radioGroup({
label: "labels.participants",
items: [
{ label: "options.participants.all", value: "ALL" },
{ label: "options.participants.selected", value: "SELECTED" },
],
}),
participantUserIds: checkboxGroupDynamic({
label: "labels.members",
}),
};
const validateSelectedParticipants = (
data: { participants: "ALL" | "SELECTED"; participantUserIds: Array<string> },
ctx: ValidationCtx,
) => {
if (
data.participants === "SELECTED" &&
data.participantUserIds.length === 0
) {
ctx.addIssue({
path: ["participantUserIds"],
message: "forms:errors.required",
});
}
};
export const addTeamEventSchema = v.pipe(
v.object({
_action: stringConstant("ADD_EVENT"),
...teamEventFields,
}),
superRefine((data, ctx) => validateSelectedParticipants(data, ctx)),
);
export const editTeamEventSchema = v.pipe(
v.object({
_action: stringConstant("EDIT_EVENT"),
eventId: idConstant(),
...teamEventFields,
}),
superRefine((data, ctx) => validateSelectedParticipants(data, ctx)),
);
const deleteTeamEventSchema = v.object({
_action: _action("DELETE_EVENT"),
@@ -93,5 +151,6 @@ const deleteTeamEventSchema = v.object({
export const teamScheduleActionSchema = v.union([
addTeamEventSchema,
editTeamEventSchema,
deleteTeamEventSchema,
]);

View File

@@ -194,3 +194,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.eventParticipants {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,10 +1,11 @@
import clsx from "clsx";
import { isSameDay } from "date-fns";
import { Flag, Plus, Trash } from "lucide-react";
import { Flag, Pencil, Plus, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLoaderData, useMatches } from "react-router";
import * as R from "remeda";
import type * as v from "valibot";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
@@ -15,7 +16,8 @@ import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import { getMemberRoleType } from "~/features/team/team-utils";
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
import { SendouForm } from "~/form/SendouForm";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHasPermission } from "~/modules/permissions/hooks";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
@@ -25,6 +27,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.schedule.server";
import {
addTeamEventSchema,
editTeamEventSchema,
teamScheduleActionSchema,
} from "../availability-schemas";
import { scheduleWeekSearchParams } from "../availability-search-params";
@@ -45,6 +48,8 @@ export const handle: SendouRouteHandle = {
};
type WeekData = NonNullable<TeamScheduleLoaderData["weeks"]>[number];
type TeamEventData = WeekData["teamEvents"][number];
type TeamEventDuration = v.InferOutput<typeof editTeamEventSchema>["duration"];
type MemberWeekRow = WeekData["members"][number];
type TeamMember = TeamLoaderData["team"]["members"][number];
@@ -291,8 +296,12 @@ function WeekNotes({ week }: { week: WeekData }) {
function TeamEvents({ week }: { week: WeekData }) {
const { t } = useTranslation(["schedule"]);
const team = useTeam();
const members = useTeamMembers();
const canEdit = useHasPermission(team, "EDIT");
const [addDialogOpen, setAddDialogOpen] = React.useState(false);
const [editedEvent, setEditedEvent] = React.useState<TeamEventData | null>(
null,
);
const { formatter: dayFormatter } = useDateTimeFormat({
weekday: "short",
day: "numeric",
@@ -302,6 +311,19 @@ function TeamEvents({ week }: { week: WeekData }) {
minute: "2-digit",
});
const participantNames = (event: TeamEventData) =>
event.participants
.map(
(participant) =>
members.find((member) => member.id === participant.userId)?.username,
)
.filter(R.isDefined)
.join(", ");
// an event that already started can no longer pass the form's start time validation
const canEditEvent = (event: TeamEventData) =>
canEdit && databaseTimestampToDate(event.startsAt) > new Date();
if (week.teamEvents.length === 0 && !canEdit) return null;
return (
@@ -345,6 +367,26 @@ function TeamEvents({ week }: { week: WeekData }) {
: `${timeFormatter.format(databaseTimestampToDate(event.startsAt))} ${timeFormatter.format(databaseTimestampToDate(event.endsAt))}`}
</span>
<span className={styles.eventName}>{event.name}</span>
{event.participants.length > 0 ? (
<span
className={clsx(
styles.eventParticipants,
"text-lighter text-xs",
)}
>
{participantNames(event)}
</span>
) : null}
{canEditEvent(event) ? (
<SendouButton
variant="minimal"
size="miniscule"
icon={<Pencil />}
onPress={() => setEditedEvent(event)}
aria-label={t("schedule:events.edit")}
data-testid={`edit-team-event-${event.id}`}
/>
) : null}
{canEdit ? (
<ActionButton
schema={teamScheduleActionSchema}
@@ -369,6 +411,12 @@ function TeamEvents({ week }: { week: WeekData }) {
{addDialogOpen ? (
<AddTeamEventDialog close={() => setAddDialogOpen(false)} />
) : null}
{editedEvent ? (
<EditTeamEventDialog
event={editedEvent}
close={() => setEditedEvent(null)}
/>
) : null}
</div>
);
}
@@ -379,21 +427,84 @@ function AddTeamEventDialog({ close }: { close: () => void }) {
return (
<SendouDialog heading={t("schedule:events.addDialogTitle")} onClose={close}>
<SendouForm schema={addTeamEventSchema} onSuccess={close}>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="startsAt" />
<FormField name="duration" />
<FormMessage type="info">
{t("schedule:events.membersWillSee")}
</FormMessage>
</>
)}
<TeamEventFormFields />
</SendouForm>
</SendouDialog>
);
}
function EditTeamEventDialog({
event,
close,
}: {
event: TeamEventData;
close: () => void;
}) {
const { t } = useTranslation(["schedule"]);
return (
<SendouDialog
heading={t("schedule:events.editDialogTitle")}
onClose={close}
>
<SendouForm
schema={editTeamEventSchema}
onSuccess={close}
defaultValues={{
eventId: event.id,
name: event.name,
startsAt: databaseTimestampToDate(event.startsAt),
duration: String(
(event.endsAt - event.startsAt) / 60,
) as TeamEventDuration,
participants: event.participants.length > 0 ? "SELECTED" : "ALL",
participantUserIds: event.participants.map((participant) =>
String(participant.userId),
),
}}
>
<TeamEventFormFields />
</SendouForm>
</SendouDialog>
);
}
function TeamEventFormFields() {
const { t } = useTranslation(["schedule"]);
return (
<>
<FormField name="name" />
<FormField name="startsAt" />
<FormField name="duration" />
<FormField name="participants" />
<ParticipantUserIdsFormField />
<FormMessage type="info">
{t("schedule:events.membersWillSee")}
</FormMessage>
</>
);
}
function ParticipantUserIdsFormField() {
const { values } = useFormFieldContext();
const members = useTeamMembers();
if (values.participants !== "SELECTED") return null;
return (
<FormField
name="participantUserIds"
options={members
.filter((member) => member.role !== "CHEERLEADER")
.map((member) => ({
value: String(member.id),
label: () => member.username,
}))}
/>
);
}
function useTeam() {
const [, parentRoute] = useMatches();
invariant(parentRoute);

View File

@@ -0,0 +1,9 @@
---
navItem: calendar
type: feature
---
Team event improvements
- When adding a team event you can now choose whether the whole team takes part or only selected members
- Team events can now be edited
- Team events can only be scheduled within the two weeks shown on the schedule, so a new event can no longer land outside the visible range

View File

@@ -1,7 +1,10 @@
import { addWeeks } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { addTeamEventSchema } from "~/features/availability/availability-schemas";
import {
addTeamEventSchema,
editTeamEventSchema,
} from "~/features/availability/availability-schemas";
import * as Availability from "~/features/availability/core/Availability";
import { weekDates, weekRange } from "./helpers/availability";
import type { Factories } from "./helpers/factories";
@@ -513,7 +516,7 @@ test.describe("Team schedule", () => {
await isNotVisible(schedule.locators.grid);
});
test("owner adds and deletes a team event, a regular member only sees it", async ({
test("owner adds, edits and deletes a team event, a regular member only sees it", async ({
page,
factories,
}) => {
@@ -540,6 +543,18 @@ test.describe("Team schedule", () => {
"VoD review vs. FTWin",
);
await page.getByTestId(/edit-team-event/).click();
const editForm = createFormHelpers(page, editTeamEventSchema);
await editForm.fill("name", "Strategy meeting");
await editForm.checkItems("participants", ["SELECTED"]);
await page.getByLabel("N-ZAP", { exact: true }).click();
await editForm.submit();
await expect(schedule.locators.teamEvents).toContainText(
"Strategy meeting",
);
await expect(schedule.locators.teamEvents).toContainText("N-ZAP");
await impersonate(page, NZAP_TEST_ID);
await schedule.goto(customUrl);
await schedule.locators.nextWeekToggle.click();

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Beskrivelse",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Beschreibung",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -138,7 +138,7 @@
"options.duration.5h": "5 hours",
"options.duration.6h": "6 hours",
"errors.dateInPast": "Date can not be in the past",
"errors.dateTooFarAway": "Date is too far in the future",
"errors.dateTooFarAway": "Date must be within the weeks shown on the schedule",
"errors.dateTooEarly": "Date is too early",
"errors.dateTooLate": "Date is too late",
"errors.dateTooFarInFuture": "Date can not be more than 2 weeks in the future",
@@ -159,6 +159,9 @@
"labels.end": "End",
"labels.member": "Member",
"labels.members": "Members",
"labels.participants": "Participants",
"options.participants.all": "Everyone",
"options.participants.selected": "Selected members",
"labels.urls": "URLs",
"labels.description": "Description",
"labels.user": "User",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "Add team event",
"events.membersWillSee": "Members will see this on their calendar.",
"events.none": "No events this week",
"events.edit": "Edit event",
"events.editDialogTitle": "Edit team event",
"events.delete": "Delete event",
"events.deleteConfirm": "Delete the event {{name}}?",
"friends.availabilityOf": "{{name}}'s availability",

View File

@@ -159,6 +159,9 @@
"labels.end": "Fin",
"labels.member": "Miembro",
"labels.members": "Miembros",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "URLs",
"labels.description": "Descripción",
"labels.user": "Usuario",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "Fin",
"labels.member": "Miembro",
"labels.members": "Miembros",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "URLs",
"labels.description": "Descripción",
"labels.user": "Usuario",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Description",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Description",
"labels.user": "utilisateur",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "תיאור",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Descrizione",
"labels.user": "Utente",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "説明",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "설명",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Beschrijving",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Opis",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Descrição",
"labels.user": "",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "",
"labels.member": "",
"labels.members": "",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "",
"labels.description": "Описание",
"labels.user": "Пользователь",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -159,6 +159,9 @@
"labels.end": "结束",
"labels.member": "成员",
"labels.members": "成员",
"labels.participants": "",
"options.participants.all": "",
"options.participants.selected": "",
"labels.urls": "URL",
"labels.description": "简介",
"labels.user": "用户",

View File

@@ -17,6 +17,8 @@
"events.addDialogTitle": "",
"events.membersWillSee": "",
"events.none": "",
"events.edit": "",
"events.editDialogTitle": "",
"events.delete": "",
"events.deleteConfirm": "",
"friends.availabilityOf": "",

View File

@@ -0,0 +1,21 @@
import { type Kysely, sql } from "kysely";
/** Participants of a team event limited to selected members. No rows for an event = the whole team takes part. */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("TeamEventMember")
.addColumn("teamEventId", "integer", (col) =>
col.notNull().references("TeamEvent.id").onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addPrimaryKeyConstraint("team_event_member_pk", [
"teamEventId",
"userId",
])
.modifyEnd(sql`strict`)
.execute();
});
}