diff --git a/app/db/tables.ts b/app/db/tables.ts index d5aa0b35b..30f4a6bf7 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1384,6 +1384,12 @@ export interface TeamEvent { createdAt: Generated; } +/** 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 }; export type TablesInsertable = { [P in keyof DB]: Insertable }; @@ -1538,4 +1544,5 @@ export interface DB { AvailabilitySlot: AvailabilitySlot; AvailabilityDayNote: AvailabilityDayNote; TeamEvent: TeamEvent; + TeamEventMember: TeamEventMember; } diff --git a/app/features/availability/AvailabilityRepository.server.test.ts b/app/features/availability/AvailabilityRepository.server.test.ts index ebae161d7..ec17ab39d 100644 --- a/app/features/availability/AvailabilityRepository.server.test.ts +++ b/app/features/availability/AvailabilityRepository.server.test.ts @@ -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", () => { diff --git a/app/features/availability/AvailabilityRepository.server.ts b/app/features/availability/AvailabilityRepository.server.ts index 4c045bcde..c62dd7fcd 100644 --- a/app/features/availability/AvailabilityRepository.server.ts +++ b/app/features/availability/AvailabilityRepository.server.ts @@ -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, -) { - const event = await db - .insertInto("TeamEvent") - .values({ ...args, authorId: actorId() }) - .returning("id") - .executeTakeFirstOrThrow(); +export function insertTeamEvent({ + participantUserIds, + ...args +}: Omit & { + participantUserIds?: Array; +}) { + 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; +}) { + 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) { diff --git a/app/features/availability/actions/t.$customUrl.schedule.server.ts b/app/features/availability/actions/t.$customUrl.schedule.server.ts index 4a246d530..4dc764be1 100644 --- a/app/features/availability/actions/t.$customUrl.schedule.server.ts +++ b/app/features/availability/actions/t.$customUrl.schedule.server.ts @@ -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; + }, + team: NonNullable>>, +) { + 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; +} diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts index a4d835e5c..60305c538 100644 --- a/app/features/availability/availability-schemas.ts +++ b/app/features/availability/availability-schemas.ts @@ -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 }, + 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, ]); diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index 5a8850a20..de035697c 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -194,3 +194,9 @@ text-overflow: ellipsis; white-space: nowrap; } + +.eventParticipants { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/app/features/availability/routes/t.$customUrl.schedule.tsx b/app/features/availability/routes/t.$customUrl.schedule.tsx index 7cd07514b..7a3ae82e0 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.tsx +++ b/app/features/availability/routes/t.$customUrl.schedule.tsx @@ -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[number]; +type TeamEventData = WeekData["teamEvents"][number]; +type TeamEventDuration = v.InferOutput["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( + 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))}`} {event.name} + {event.participants.length > 0 ? ( + + {participantNames(event)} + + ) : null} + {canEditEvent(event) ? ( + } + onPress={() => setEditedEvent(event)} + aria-label={t("schedule:events.edit")} + data-testid={`edit-team-event-${event.id}`} + /> + ) : null} {canEdit ? ( setAddDialogOpen(false)} /> ) : null} + {editedEvent ? ( + setEditedEvent(null)} + /> + ) : null} ); } @@ -379,21 +427,84 @@ function AddTeamEventDialog({ close }: { close: () => void }) { return ( - {({ FormField }) => ( - <> - - - - - {t("schedule:events.membersWillSee")} - - - )} + ); } +function EditTeamEventDialog({ + event, + close, +}: { + event: TeamEventData; + close: () => void; +}) { + const { t } = useTranslation(["schedule"]); + + return ( + + 0 ? "SELECTED" : "ALL", + participantUserIds: event.participants.map((participant) => + String(participant.userId), + ), + }} + > + + + + ); +} + +function TeamEventFormFields() { + const { t } = useTranslation(["schedule"]); + + return ( + <> + + + + + + + {t("schedule:events.membersWillSee")} + + + ); +} + +function ParticipantUserIdsFormField() { + const { values } = useFormFieldContext(); + const members = useTeamMembers(); + + if (values.participants !== "SELECTED") return null; + + return ( + member.role !== "CHEERLEADER") + .map((member) => ({ + value: String(member.id), + label: () => member.username, + }))} + /> + ); +} + function useTeam() { const [, parentRoute] = useMatches(); invariant(parentRoute); diff --git a/changelog/2026-09-01-team-event-participants.md b/changelog/2026-09-01-team-event-participants.md new file mode 100644 index 000000000..5b1e115bb --- /dev/null +++ b/changelog/2026-09-01-team-event-participants.md @@ -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 diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index ffe0c4400..162f1c064 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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(); diff --git a/locales/da/forms.json b/locales/da/forms.json index 43209d9dd..b5719d831 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/da/schedule.json b/locales/da/schedule.json index deed72853..7673a7253 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 97ee5eed7..a4a8b93f3 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index deed72853..7673a7253 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index 69047cabc..d7e7f3d6e 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index a6c0c704d..f5a088b9b 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 1233622a7..49a77775c 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index deed72853..7673a7253 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 1b8883402..e328f522c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index deed72853..7673a7253 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 7cf260a4b..baab1aaf8 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index deed72853..7673a7253 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index e3e091e3b..d81d5f57e 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index deed72853..7673a7253 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index e9548f971..718d61c7a 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -159,6 +159,9 @@ "labels.end": "", "labels.member": "", "labels.members": "", + "labels.participants": "", + "options.participants.all": "", + "options.participants.selected": "", "labels.urls": "", "labels.description": "תיאור", "labels.user": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index deed72853..7673a7253 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 5ac452fd8..eec4d4189 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -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", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index deed72853..7673a7253 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 397f79646..650703ca7 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -159,6 +159,9 @@ "labels.end": "", "labels.member": "", "labels.members": "", + "labels.participants": "", + "options.participants.all": "", + "options.participants.selected": "", "labels.urls": "", "labels.description": "説明", "labels.user": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index deed72853..7673a7253 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index abceebdc2..2943954b4 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -159,6 +159,9 @@ "labels.end": "", "labels.member": "", "labels.members": "", + "labels.participants": "", + "options.participants.all": "", + "options.participants.selected": "", "labels.urls": "", "labels.description": "설명", "labels.user": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index deed72853..7673a7253 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index dfa189efb..da653a273 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -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": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index deed72853..7673a7253 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 21afa2ec0..bb483e8da 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -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": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index deed72853..7673a7253 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 53f897d78..b1e721e40 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index deed72853..7673a7253 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index c177c11c7..c80c25da5 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -159,6 +159,9 @@ "labels.end": "", "labels.member": "", "labels.members": "", + "labels.participants": "", + "options.participants.all": "", + "options.participants.selected": "", "labels.urls": "", "labels.description": "Описание", "labels.user": "Пользователь", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index deed72853..7673a7253 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 81cff2877..2f10a3a35 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -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": "用户", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index deed72853..7673a7253 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -17,6 +17,8 @@ "events.addDialogTitle": "", "events.membersWillSee": "", "events.none": "", + "events.edit": "", + "events.editDialogTitle": "", "events.delete": "", "events.deleteConfirm": "", "friends.availabilityOf": "", diff --git a/migrations/20260901141631-team-event-participants.ts b/migrations/20260901141631-team-event-participants.ts new file mode 100644 index 000000000..0468c0d74 --- /dev/null +++ b/migrations/20260901141631-team-event-participants.ts @@ -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): Promise { + 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(); + }); +}