Skip to content

Calendar — meetings, reminders and the time people have closed off ​

In plain terms: one shared month for the team. You put meetings and calls on it, invite people or whole departments, see who is free before you pick a time, and close off your own hours so nobody books over them.

Two rules shape everything below, and both are about not telling people things:

You see an event only if it is open to the team, or you are on it.

Free/busy discloses that somebody is occupied, never what by.

That second one is why the picker shows a red dot and not a title. Knowing a colleague is busy at three is what you need to schedule around them; knowing they are at a hospital appointment is not.

The calendar (/team/calendar) ​

Month/week/day views of every event you can see (see Who sees what below) — create, move and resize by dragging (see How to... below for the exact gestures). The six event kinds, visibility rules, recurrence, reminders and free/busy behavior covered in the sections below all apply here; this heading exists so the header help's Technical reference link has a literal target of its own, not a restatement of what follows it.

How to... ​

The calendar ​

Add an event — the + button in the top-right corner; or hover any empty cell and press the faint + that appears in its corner; or drag across empty time to set the length as you go.

A plain click on empty space does nothing on purpose. It used to open the form, which meant every stray click did — including the one you make beside an open card just to close it. The + has to be aimed at, so nothing opens by accident. On a touch screen, where there is no hover, use the + in the corner of the page.

See what an event is — hover it for a summary; click it for the card, which has Edit and Delete. The editor opens only when you press Edit, so a look never becomes an accidental change.

Move something — drag it. Drag its bottom edge to make it longer or shorter. Both save straight away, and put it back with a message if the server refuses.

Close time for yourself — the lock button next to +. This needs no special permission, and nobody else can edit it — see Blocked time.

Find something — the search box filters what is already on screen by title. The funnel filters by kind, by person, and by department.

Jump to another date — click the period title ("August 2026"). A small calendar opens with month and year selects, so November next year is two clicks rather than sixteen presses of an arrow. The arrows beside it step by a period, and Today comes back to today.

Answer an invitation — click the event and press Going, Maybe or Not going on the card. You can do this without any permission to edit the event, and a notification takes you straight to it.

See what is waiting on you — the band under the view switch. Anything you were invited to and have not answered, with the three answers on each row. Answer and the row goes. The band is not there when nothing is waiting.

It's your inbox, not a slice of the grid. It doesn't depend on what's on screen — not the view, not the month, not the search, not the filters. Clicking a row takes the calendar to that event and opens its card, even if that date isn't currently visible.

Only answering clears an invitation, not opening it. The bell keeps counting an invitation no matter how many times you open it — answer it here, on the event's card, or anywhere else, and it clears. Every other kind of notification clears the moment you read it; an invitation is the one that asks for something back.

A notification about an invitation opens the Agenda, not the event's card. It moves the calendar to the event's date and highlights its row in the band, deliberately without opening the card — that would ask the same question a second time. Answer it in the band; the event's own details are one more click away on that same row. Every other calendar notification (moved, cancelled, somebody answered, a reminder) does open the card, since none of those has a row in the band.

One person's schedule ​

See what somebody is on — Team management → Employees → open the person → Schedule. It lists what is coming up and the hours they have closed off.

Book them — the Schedule a meeting button on that tab opens the form with that person already invited.

The six kinds ​

Each has a colour and a glyph. Colour makes a month legible from across the room; the glyph names it precisely, and keeps the distinction working for anyone who cannot separate two of the hues.

KindWhat it is forBehaves differently
Calla remote conversationno
Meetingpeople in a room, or a scheduled discussionno
Reminder"submit the report by six" — a point in timeyes — see below
Holidaya day marked for everyoneno
Otherthe catch-allno
Blocked timeyour own hours, closed offyes — see Blocked time

The key in the header row (the shapes icon, beside the view switch) lists all six with their colours.

A reminder is a moment, not a span ​

Choosing Reminder removes the end time and the All day switch from the form, because a point in time has neither. It follows that:

  • it never makes anybody busy — a deadline that blocked everyone's afternoon would make the whole free/busy idea useless;
  • it cannot be resized in the time grid — dragging its edge would quietly turn it into a span;
  • on the grid it is drawn as a mark on its own line rather than as a block.

Every other kind must last longer than nothing; the form will not offer an end at or before the start.

Who sees what ​

Two levels, set on each event:

  • Whole team — anyone with workspace:calendar:read sees the title and the details.
  • Participants only — everybody else does not see the event at all. Not a grey box, not an anonymous "busy" — it is absent from the calendar, and asking for it by its address answers 404, never "forbidden". A "found, but you may not look" answer would confirm the event exists, which for a private event is exactly what must not leak.

The one place other people's private time does surface is availability, and only as an interval — see below.

Availability, and closing your own time ​

While you are picking people ​

As you choose participants, each one carries a mark for the window currently in the form: free, busy, or probably busy (an invitation they have not answered). Change the time and the marks follow it.

The mark informs, it never blocks. Booking over somebody deliberately is normal, and a picker that refuses is a picker people work around. If anybody is busy, a line under the list says who — by name, and nothing else.

Approved time off counts as busy for the whole day, and shows on the calendar itself as a hatched all-day bar, "Anna — away". Neither says why: not whether it is a vacation or sick leave. Click the bar to open Time off.

What the platform sends to draw those marks is a list of intervals: start, end, and how certain. No title, no kind, not even an event id, because an id would turn availability into a way to enumerate other people's private events.

Blocked time ​

The lock button closes hours for yourself — lunch, a commute, the gym. It differs from everything else on the calendar:

  • anyone can create it, with no workspace:calendar:manage; closing your own time is not an administrative act;
  • nobody else can edit or delete it — not even an administrator. This is the one deliberate hole in the platform's usual "a privileged role can always act" rule;
  • it has no participants and no departments;
  • it can repeat weekly, on chosen days;
  • you decide whether the label is visible to the team, or whether colleagues see only that the time is taken.

On the grid it is the quietest thing there: muted and hatched, because it is context for somebody else's scheduling decision rather than something to look at.

Participants and departments ​

You invite people. Inviting a department is a shortcut that adds its members — and files the event under that department at the same time. One decision, two effects, so the two can never disagree about the same event.

Each chosen department gets a block under the field with its people listed and ticked. Untick anyone who should not come; the header then reads "5 of 7" instead of "Everyone", and its checkbox puts everybody back. Removing the department takes its people with it, but leaves anyone you added by name, and anyone still covered by another chosen department.

Who is ticked is worked out fresh each time the event is opened, from who is in the department now. Attendance itself is stored per person, at the moment of the invitation — so a colleague who joins the department next month does not silently appear in last month's meeting, and one who leaves does not vanish from it.

The department filter matches events that are filed under it. An event somebody from Marketing merely attends is not Marketing's event.

Answering ​

Everyone invited answers for themselves: Going, Maybe, Not going. The card shows the count, and opens into who said what — so an organizer chasing a reply knows whom to chase. The same marks sit beside each name in the editor. Until they do, the event is drawn hatched on their own calendar — an unanswered invitation still occupies the slot but is not a settled plan. The organizer is told each answer; nobody else is.

Repeating events ​

Any event can repeat: daily, weekly on chosen days, every other week, or monthly on a date. A repeat always has an end date.

Saving or deleting a repeating event asks one question first — this occurrence or the whole series:

  • this occurrence splits that one out. Move it, rename it, or cancel it, and the rest of the series is untouched.
  • the whole series changes every occurrence, including ones already split out where the change makes sense.

A monthly repeat on the 31st skips months that have no such date rather than sliding to the 28th — a series that quietly moves is worse than one that misses.

Repeating events cannot be dragged. The gesture would have to be followed by that same modal question, and that is the one moment a drag should not be interrupted; the editor offers both scopes plainly instead.

Reminders ​

An event can carry up to five, chosen from 15 minutes, an hour and a day before. Each arrives separately — they are deliberately not collapsed into one notification, because "in 15 minutes" and "tomorrow" are different messages.

Everyone on the event gets them. A reminder whose time has already passed when the event is created is never sent, so putting an event in the calendar an hour before it starts does not fire a day-ahead reminder immediately.

Moving an event moves its reminders with it.

Outcomes ​

A meeting can carry a short write-up. Any participant can add or edit it, not only the organizer, and only once the event has started — a summary of a meeting that has not happened is either a guess or a mistake.

Views ​

  • Month — six rows, always, so the grid does not jump between months. A day showing more than it fits ends with +N more, which opens that day in the agenda rather than growing the cell.
  • Week and Day — an hour grid with a line at the current time. Drag to move, drag the bottom edge to resize, drag across empty space to create.
  • Agenda — the next 30 days as a list, grouped by day.

The view you pick goes into the page address, so it survives a reload and the calendar can be linked to in a particular view. The date deliberately does not: it would turn the browser's Back button into a per-month undo instead of a way off the calendar.

All-day events sit in a band above the hour grid. An all-day event is a date, not a 24-hour span: 5 August is 5 August for a colleague in another timezone too.

Times and timezones ​

Every event stores the instant plus the zone it was created in — not an offset, because offsets go stale with daylight saving and the law. Times on screen are shown in your browser's zone, and the form says which zone that is under the time fields.

Notifications ​

You were invited to an event — when somebody adds you as a participant. Clicking it takes the calendar to that event's date and opens its card on the chip, so you can see where it sits before answering. The answer buttons are on the card.

An event you are on has moved — its time changed.

An event you are on was cancelled — a cancellation is still news, which is why cancelled events are kept rather than erased.

Somebody answered your invitation — the organizer only. Telling every invitee who is coming would turn one meeting into a great many notifications, and none of them asked.

A summary was added — to everyone who was on the event.

Your event starts soon — the reminders above.

Invitations, changes, cancellations and answers are configured under Calendar invitations; reminders under Event reminders, separately, because muting invitation traffic should not silence the reminder for a meeting you have already agreed to attend. See Notifications.

One row per event, not one per thing that happened to it. Invitations, moves and cancellations of the same event merge while the row is still unread, and the row shows the most recent of them — so an organizer who invites you and then moves the meeting a minute later leaves you "has moved", not two separate lines. See deduplication.

You are never notified about something you did yourself.

Permissions ​

PermissionWhat it allows
workspace:calendar:readsee the calendar, answer your own invitations, close your own time
workspace:calendar:managecreate, edit and cancel events, and manage their participants

Two things need neither: answering an invitation you were sent, and closing your own time. And one thing :manage deliberately cannot do: touch somebody else's blocked time.

One person's schedule (/team/members/[id]/schedule) ​

The Schedule tab on an employee's own card (see Staffing → Employees) — the same events as the team calendar above, filtered to one person, plus the hours they've closed off. Schedule a meeting on this tab opens the same event form as the main calendar, pre-filled with that person already added as a participant (see How to... below).

Testing this module ​

/team/calendar had zero test coverage, unit or live, before 2026-08-12 — this is a round-based effort like /team/members, block by block.

  • Block 1 — view modes, URL sync, and filters. apps/portal/pages/team/calendar/index.test.ts (10 unit tests) covers the view-mode URL sync (?view=month|week|day|agenda, an invalid value falling back to month rather than crashing), the exact date window computed per view (agenda: 30 days from the anchor; week: padded to the real week start; day: exactly 1 day; month: padded a week before and two weeks after the calendar month so all 6 grid rows always have something to show, even from neighbouring months), kind/person/department filters each triggering a real server-side reload with the right query params, search staying purely client-side over the already-loaded events (no extra fetch per keystroke), "Only mine" reusing the existing participant filter rather than a second parallel flag (picking a different person in the filter panel silently turns it back off, since there is nothing separate that could disagree with it), activeFilterCount deliberately excluding search from its count, and the title string per view. scripts/e2e/menu/calendar.mjs (7 checks, new file) confirms all four view buttons actually update the URL (not just that they're clickable), a real "Only mine" toggle round trip, and that the filter panel's Kind/"Also invited" fields render with real option lists (meaning the staff/department endpoints they're built from actually loaded). CalendarView (the underlying grid/week/day/agenda renderer) is real and self-contained, already covered by its own packages/ui-kit/src/patterns/calendar/CalendarView.test.ts — mounted for real here too rather than stubbed. CalendarEventPopover/CalendarEventDialog/CalendarBlockDialog/ CalendarInvitations stay stubbed this block — each is its own dialog/component, out of scope until the block that actually exercises it.
  • Block 2 — event mutations (create, drag-move, block time), same round. Nine more unit tests (19 total) cover canDrag gating on workspace:calendar:manage, onMoveEvent's optimistic update plus its rollback if the PATCH fails, onCreateRange/openCreate/openBlock pre-filling the right dialog with the right date/range, expandDay (the "+N more" overflow link) jumping the anchor date and switching to the agenda view, and openEvent correctly reading the SERIES id from a chip's eventId field rather than the chip's own render key — which for a recurring occurrence is <eventId>#<index>, so getting this wrong would edit the wrong occurrence's series. calendar.mjs grew to 10 checks: creating a real event (fills the title, submits, confirms the success toast, then confirms the event is actually findable via search afterwards — not just that the dialog closed) and closing your own time via the block dialog (confirms its own success toast). Drag-move itself is deliberately not exercised live — a real pointer-drag against CalendarView's own grid math has no established pattern anywhere in this e2e suite, and is already covered by the onMoveEvent unit test plus CalendarView's own dedicated coverage; same judgment call as skipping live "Duplicate"/ "Transfer ownership" elsewhere in this initiative. One live gotcha: the block dialog's submit button stays disabled until the form is dirty, so the check fills the Label field before submitting — the defaults alone (today, 13:00–14:00, visible to the team) don't count as a change.
  • Block 3 — notification deep-linking + the invitations band, same round. This closes out /team/calendar entirely (all 3 blocks, one round each). 7 more unit tests appended to the same file (26 total now) cover the ?event=<id> query watcher's three branches — opens the event's card and switches to the agenda view, clearing the query so a reload doesn't reopen it; for an invitation still needing a response, highlights that row in the band instead (the card and the band both showing the same three answer buttons inches apart was a real bug reported and fixed 2026-08-09, per the code's own comment); and for an unanswered invitation that has no row in the band because it has already happened, falls through to opening the card anyway, since there is nothing left to point at — plus the "event not found, it may have been cancelled" error toast for a deleted or no-longer- visible id, goToEvent/onInvitationAnswered (the band's own two actions — jump to the event, and clear the highlight + refresh both the grid and the inbox after answering), and invitationWhen's three shapes (all-day, timed, and a zero-length "instant" where start equals end). calendar.mjs grew to 14 checks: the ?event= deep link opening a real event by id (captured from the real creation response via page.waitForResponse, since the id isn't otherwise visible anywhere in the UI) and confirming both the card's content and the URL update, plus the "gone" toast for a nonexistent id — both single-actor paths — AND the invitations-band highlight itself, using the suite's own second dedicated account (e2e-test-staff2@p4p.internal) in a separate browser context: e2e-test-admin invites STAFF2 to a real event, STAFF2 opens ?event=<id>, the band highlights the row, answering "Going" removes it. (This file's first draft wrongly assumed no second account existed and skipped this live — corrected the same round once that account's long-standing use elsewhere in this suite, e.g. realtime-new-items-pill.mjs, was noticed.) Also added this round: a shared deleteEventByTitle() cleanup helper, retrofitted onto block 2's own create-event and block-time checks too (neither had cleaned up after itself before this round) — every real event this file creates across all three blocks is now deleted by the end of the run, matching every other menu/*.mjs file's own cleanup discipline. Found and fixed a real bug in that cleanup helper itself the same day: its chip-click locator used substring (exact: false) text matching, whose .first() can resolve to an ancestor element whose text is several chips concatenated rather than the one chip — the click then lands on a container with no click handler and silently no-ops, leaving the event behind with no error anywhere. Found via 3 real orphaned "E2E Calendar Event ..." rows left behind by earlier runs in this same round; fixed to exact: true with an explicit visibility wait before each click, and the orphans cleaned up by hand. The invitations-band check itself needed a second, similar fix: both the "band shows the row" and "answering removes it" assertions must scope to the band row's own [data-invitation-id] attribute, not a page-wide title-text search — the dev DB is shared, so STAFF2 can genuinely have OTHER real pending invitations at the same moment as the test's own (a blind "click the first Going button" answered a stranger's unrelated invite instead), and separately ?event= switches the view to agenda, where the SAME event's own agenda-list row carries the identical title text, so a page-wide search can't tell "still in the band" from "visible somewhere on the calendar" apart.

The Schedule tab on an employee's own card (/team/members/[id]/schedule, see Staffing) is a per-person view of this same underlying system, and its own round (apps/portal/pages/team/members/[id]/schedule.test.ts) was the first to stub useCalendarEvents/useEventTooltip/toCalendarItems/utils/calendarEvent.ts for real — this round promoted both to shared apps/portal/test/setup.ts on their second real reuse.