Repository navigation
Conversation
Supersedes ADR-0074's unconditional Linked-Calendar hiding with a
per-(calendar, user) Exposure choice: a Linked Calendar's own Owner
defaults to unexposed, a Workspace Member it's Shared to defaults to
exposed, and either default is overridable. Every direct CalDAV path that
resolves a Calendar off its request URL — PROPFIND, calendar-query,
calendar-multiget, sync-collection, and PROPPATCH — now consults it, not
just the home-set listing.
Adds calendar_exposures (mirroring calendar_user_colors), a
PUT /api/calendars/{id}/exposure endpoint, a sidebar row-menu toggle for
Linked Calendars, and a first-App-Password nudge reusing ADR-0027's
pattern. Read-only exposure only; write-back over CalDAV is #299.
Fixes #297
…general update EventRepository.Update wrote ProviderEtag/RSVPStatus/ConferenceURL/ GuestCount/ProviderColor from whatever EventFields it was handed, with no merge against the stored row. EventService.Update papered over this with a carry-forward; upsertSeries (PutSeries, and Subscribed Calendar reconciliation) had none, so a CalDAV PUT reaching a Connection Source once #299 lands would silently null a Linked Calendar's Provider RSVP, conference link, guest count and etag. Remove the five columns from Update's SQL entirely, so no caller can touch them regardless of what it populates on EventFields, and delete the carry-forward it made unnecessary. They now move only through the three narrow writers built for exactly this (ApplyProviderOwnedFields, widened to also carry ProviderColor, UpdateProviderEtag, and AdoptProviderIdentity): upsertSeries calls the first two on its update branches when the caller legitimately owns fresh Provider data (a Refresh reconcile), and skips them otherwise (PutSeries, ImportSeries), so a plain edit can no longer blank this state by construction. Fixes #298
…shes PutSeries' blanket refusal of Connection Sources becomes conditional: it proceeds where the requesting principal has Exposure on (ADR-0080) and a live, writable Connection, diffing the incoming series against stored state to derive Write-back pushes rather than blindly re-pushing an unchanged Master on every save. Six deltas: Master fields changed (PATCH), Override added/changed (INSTANCE), Exdate added (CANCEL_INSTANCE), series newly created (POST) all push; Override removed or Exdate removed are refused before any local write, atomically — except an Override removed alongside a matching new Exdate, the standard shape for "delete this already-modified Occurrence", which cancels rather than refusing. DeleteCalendarObject needed no new write logic, only the same exposure-aware visibility check. A permanently failed push now also raises a Notification, coalesced on the Calendar (not the Event) via a partial unique index, so a dead grant failing dozens of queued pushes still surfaces exactly one. Delivers ADR-0081. Fixes #299
A Calendar Set (ADR-0082) is a named, private selection of a Workspace's Calendars belonging to one User. This delivers the Set itself, CRUD only — membership, the top-bar switcher and the narrowing rule are later tickets. Backend: calendar_sets/calendar_set_members tables (shaped after groups verbatim), CalendarSetRepository/Service registered in the object graph, and /api/calendar-sets routes gated like Groups. Every route resolves a Set by (id, user, active workspace) together and 404s on any mismatch, since a Set is private outright with no Role and no sharing mechanism. Frontend: a Calendar sets Section in Settings' Personal group, mirroring GroupsSection's inline create/rename and delete-confirmation shape, scoped to and re-rendered on the active Workspace. Fixes #301
Adds PUT/DELETE /api/calendar-sets/{id}/calendars/{calendarId} to add and
remove a Calendar from a Set. Adding validates independently that the
Calendar belongs to the active Workspace and that the caller has Access
to it, both collapsing to 404 like the Set's own scoping. Removal relies
on the existing calendar_set_members ON DELETE CASCADE for the
delete/unsubscribe/unshare cascade, so an emptied Set survives with no
reconciliation code.
GET /api/calendar-sets now embeds each Set's member Calendar ids
(ADR-0082's REST surface), which also fixes Rename to stop reporting a
renamed Set as membership-free.
On the frontend, a new membership dialog in the Calendar Sets Settings
Section toggles a Calendar in or out of a Set with no save step, grouped
exactly as the sidebar groups Calendars — that grouping logic moves out
of CalendarList into a shared src/lib/calendarGrouping.ts so the two
can't drift apart.
Closes #302
A single pure function, inScopeCalendars (src/lib/calendarSetScope.ts), answers which Calendars exist right now: every Calendar with no Active Calendar Set, only its members with one active, and nothing for an empty Set. The sidebar filters through it before grouping, so an out-of-Set Calendar is absent rather than unchecked, and a heading left with no in-Set Calendars hides itself — while checkedCalendarIds stays completely untouched, so a Calendar's toggle value survives a Set switch and back. The grid composes over the same rule: a new useVisibleCalendarIds hook intersects in-scope Calendars with checked ones, feeding both useVisibleOccurrences (Day/Week/Month) and the Year overview, so neither grid needs to know Sets exist. The Active Calendar Set lives in shellStore as activeCalendarSetId, alongside selectedDate and activeView, with null meaning "All calendars" — the absence of a Set rather than a row of its own. A top-bar CalendarSetSwitcher lists it plus the caller's own Sets. Built on Select for now, matching WorkspaceSwitcher; #304 upgrades it to the Menu primitive once it needs a divider and a "Manage sets" entry, and adds the zero-Set, reset-on-reload/Workspace-switch, and deleted-while-active handling this ticket deliberately leaves out. Closes #303
Rebuilds CalendarSetSwitcher on the Menu primitive (Menu.RadioGroup / Menu.RadioItem) instead of Select, per ADR-0082, so it can carry a divider and a "Manage sets" entry that navigates to the Calendar Sets Settings Section. Always rendered once a Workspace is active, even at zero Sets, and the trigger's label only changes when a Set is actually selected, so creating the first Set never reflows the top bar. Two reset rules close out the Active Calendar Set's lifecycle: workspacesStore's setActiveWorkspaceId now clears it on every genuine Workspace switch, since a Set belongs to one Workspace and its id is meaningless in another; calendarSetsStore's deleteCalendarSet clears it when the deleted Set was the active one, so deleting falls back to "All calendars" rather than leaving the caller looking through nothing. Reset on reload needed no new code — shellStore carries no persistence middleware, so a reload already re-initializes activeCalendarSetId to null; a test now documents that default explicitly. Closes #304
The picker's invariant — you can only create an Event on a Calendar you can currently see — already covered unchecked Calendars; the Active Calendar Set is a second way of not seeing one, so it narrows the picker too. checkedCalendars now composes inScopeCalendars over writableCalendars before the checked filter, so the default selection falls inside the Active Calendar Set by construction, with no new Set-awareness of its own. calendarPickerEmptyReason gains a fourth case, "outOfSet", firing only when the Active Calendar Set's own (already narrowed) membership is empty — distinct from "none", which means no Calendars exist anywhere. Its remedy is switching to All calendars, so it's excluded from the picker's "Create a calendar" button exactly like "hidden" already is. Inside a non-empty Set the existing hidden/unwritable precedence is unchanged, now proven by tests that pass a non-empty Set's membership instead of the full Calendar list — including a Set holding only read-only Calendars, which correctly falls through to "unwritable" rather than "outOfSet". calendar.ts itself stays free of any Calendar-Set import; the narrowing happens entirely in EventModal, the only caller. The eventForOptions rescue (an edited Event's own Calendar surviving in the picker even when hidden) stays keyed off the unscoped Calendar list deliberately, so an Event's Calendar falling out of the Active Set never strands its own edit. Closes #305
An empty Active Calendar Set (created empty, or emptied by cascade when a Share was revoked or a Calendar deleted) now names itself in the sidebar instead of rendering no headings at all, with a route to the Calendar Sets Settings Section and a way back to All calendars. The grid already renders nothing correctly via inScopeCalendars, so that acceptance criterion needed no change. Fixes #306
Each row's action menu gains an "Add to set" submenu listing the caller's Calendar Sets, marking membership and toggling it with immediate effect. Ungated by canManage/origin since membership carries no Role and grants no Access, so it works identically on owned, Subscribed, Linked and shared-in rows. Absent entirely at zero Sets. Fixes #307
… Set The Calendar create dialog, the Subscribe dialog and the Connection Calendar picker each gain an unticked "Add to <Set>" checkbox, present only while a Calendar Set is active. The picker's checkbox covers the whole imported batch rather than one per row. Membership stays hand- curated (ADR-0082): ticking the box is the only way a newly created Calendar joins the Active Calendar Set. Fixes #308
CalendarModal, SubscribeCalendarModal and CalendarPickerModal each carried an identical label+Checkbox block for #308's add-to-Set checkbox. Pulled into AddToActiveSetCheckbox, which renders nothing when there's no Active Calendar Set so callers need no guard around it.
CONTEXT.md gains a Tasks section — Task, Task List, Deadline, Time block, Task bucket, Completion, Priority, Tasks panel — plus explicit non-terms for a repeating Task and for a Reminder on a Task, so a later reader can tell a decision from an omission. ADR-0083 records the entity model and, at more length, the two cheaper designs it refuses: a Task as an Event with a flag (which puts an "unless it's a task" branch into recurrence expansion, the Anchor zone, multi-day rendering and the Save plan), and a Task List as a Calendar carrying CalDAV's supported-calendar-component-set (whose blast radius was measured at 16 query sites before being rejected on other grounds — it doubles the meaning of Access, Source and import). The second turns entirely on Tasks being private, so the ADR names the question to re-ask before reopening it. Deadline (DUE) and Time block (DTSTART + DURATION) are independent: RFC 5545 makes DUE and DURATION mutually exclusive and DUE means a deadline, so storing a block's end in DUE would destroy the deadline the moment work is scheduled against it. "Due Friday, blocked Tuesday" is the ordinary case, not a conflict to reconcile. Spec and tickets in #309.
A User can open the Tasks panel (top-bar button, closed by default, session state in shellStore) and manage their Task Lists in it. The Lists filter dropdown shows each Task List with a colour checkbox and a checked-count, and creates a new one inline via "New list" without leaving the panel — checked automatically, on the same reconcile rule Calendar toggle uses, now extracted into a shared reconcileCheckedIds helper both stores call. task_lists is scoped (user_id, workspace_id), cascading on both, with a colour and a movable is_default flag (ADR-0083). Inbox is provisioned inside the existing transaction seam (ADR-0018) at both points a (User, Workspace) pair comes into being: createForOwnerTx (Workspace creation, covering Bootstrap and Register) and AddMemberInTx (Workspace Invite acceptance, covering both the new-account and existing-account paths). /api/task-lists supports list/create/rename/recolour/set-default/ delete, private outright like Calendar Sets (ADR-0082) — every route resolves by (id, caller, active workspace) and a mismatch is ErrNotFound, not a distinguishable forbidden. Promoting a Task List to default clears the old one atomically in one transaction; deleting the current default is refused until another is promoted. Handler tests (mirroring handlers/calendar_set_test.go, the only backend seam) cover CRUD, cross-user 404s, Inbox provisioning on both call paths, default-flag moves, and cascade on User and Workspace deletion. Fixes #317
Quick-add commits a Task on Enter with no dialog, targeting the default Task List or the single checked one in the Lists filter. Every row gets a one-click completion control, written optimistically with rollback on failure; completed Tasks stay hidden behind "Show completed" and are fetched only when asked for, bounded to a recent tail. Deleting a Task List now reparents its Tasks to the default in one transaction instead of destroying them. Fixes #310
Read the default Task List inside the same transaction as the reparent and the delete, so a concurrent SetDefault can't land in between and reparent Tasks to a Task List that's no longer default by commit time. Quick-add now clears its field only once the create succeeds, instead of losing the typed title on a failed request. Also drops the unused update/delete wiring from the Task API client and store (no UI calls either yet) and adds the missing same-User-two-Workspaces scoping test for the Tasks list endpoint.
A Task gains a Deadline, notes and Priority (raw 0-9 VTODO value, mapped to None/Low/Medium/High for display), settable from a small detail surface opened by clicking a Task row, which also moves a Task to another Task List and deletes it. Deliberately not EventModal. taskScheduling.ts derives each Task's bucket (Overdue/Today/Upcoming/No date) from its Deadline against today in the Viewer zone, falling back to its Time block's start, at render only — nothing is stored. The panel now groups into these four buckets, each with a count and each row showing its own deadline; a completed Task shown via "Show completed" rejoins its own bucket rather than a separate pile. Fixes #311
A Task with a Deadline and no Time block now renders as a chip in the all-day lane on its due date (Day/Week view), drawn in its Task List's colour and carrying the same completion control as the panel. taskScheduling.ts gains the placement precedence every later grid ticket depends on: Time block wins outright, else Deadline, else panel-only. "Show tasks on calendar" (default on) lives in the Tasks panel's new "..." menu and gates the whole surface. Fixes #312
A Task dragged out of the Tasks panel onto the hourly grid now gains a Time block: a 1h default at the drop time, drawn in its Task List's colour, carrying the completion control, and joining Events in the existing overlap-layout pass rather than drawing over them. Placement precedence (#312) already moves a blocked Task off the all-day lane with no special case needed. Drop semantics live in the pure taskDropIntent.ts, per the ticket's own rule that grid drag logic left inside components is exactly the logic that goes untested. The drag itself reuses usePointerDrag from TaskRow, resolving its drop target the same elementFromPoint way TimeGrid's own all-day drag already does. Also fixes a latent gap from #312: Task List colours were only ever fetched once the Tasks panel had been opened, so a chip or block could render in the unresolved fallback colour on a fresh load with the panel closed. Fixes #313
Completes the Task time-blocking drag matrix: rescheduling a block elsewhere on the grid, dragging one into the all-day lane (which sets the Deadline and clears the block in one step), dragging one back to the panel, dragging a Deadline-only chip onto the grid, resizing a block by the same gesture as an Event, and an "Unschedule" menu item. Fixes #314
Task chips render in Month Day cells, placed by placement precedence (Time block's date, else the Deadline's), in their Task List's colour with the completion control, merged chronologically with Event chips. Dropping a Time-blocked Task on a Day cell moves it and preserves time-of-day (mirroring an Event's own Month drag-to-move); dropping a Task with no Time block sets a Deadline instead and creates no Time block, since Month never invents an hour from a gesture that named none. Year view stays untouched and fully inert to Tasks. Fixes #315
The Tasks panel's grouping becomes a choice: the "..." menu's new Group by submenu switches between Task bucket (default), Task List, and Priority, regrouping the rows without touching any stored data since every axis is still derived at render. Fixes #316
An Event now carries Busy or Free, stored as iCalendar's TRANSP (OPAQUE/TRANSPARENT), behind More options in the Event modal (ADR-0056). A newly created timed Event defaults to Busy, matching RFC 5545; an all-day Event defaults to Free (ADR-0086), applied live as the create form is filled in until the User overrides it directly. Every pre-existing Event reads as Busy — nothing is backfilled. Threaded through end to end: the DB column, the repository and service layers (settable independently on a Master and an Override), the iCalendar codec (both directions, CalDAV round-trips it), ICS import (a source's own transparency is honoured, never re-defaulted from all-day-ness), and the Google mapper (reads and writes the Provider's own transparency field, including Write-back). Closes #319
A named weekly pattern of time ranges belonging to one User, carrying its own IANA timezone, separate from the Working hours Preference (ADR-0085). Several ranges per weekday are allowed for split days. The first Schedule is seeded lazily as "Default" the first time a User's schedules are listed, copied once from Working hours (or Mon-Fri 09:00-17:00 when unset) and never touched again. Managed via a new Settings section and a CRUD API at /api/availability-schedules. Closes #320
A nullable, instance-wide-unique identifier a User claims and renames in Settings → Account, compared and folded case-insensitively like Email (ADR-0084). A suggestion is derived from the Email's local part and disambiguated with a numeric suffix against both existing Handles and a new reserved-word registry, which is the single source of truth the future public router will consume. Renaming warns that every URL published under the old Handle breaks, with nothing forwarding. Closes #321
A named, slug-addressed offer to be booked, scoped to (User, Workspace) like a Task List, with a Conflict set (a Calendar join table plus a Tasks flag) seeded from every owned Calendar in the Workspace and the Book-into Calendar itself, pinned. A "Booking links" sidebar section lists the Active Workspace's links with copy/edit/duplicate/delete controls; the create/edit modal follows ADR-0056's progressive disclosure, asking only Title, Duration, Slug, Schedule and Book-into up front. Creating a first link claims the caller's Handle if they have none (ADR-0084). Also closes ADR-0085's and ADR-0087's own "deleting a Schedule/Calendar still referenced is refused" requirements, now that a referencing entity exists, and fixes AccountService.Delete's disposition ordering so a self-deleting User's own Booking Links no longer trip that same guard against their own Calendars. Closes #322
A new pure package, internal/availability, derives a Booking Link's
bookable slots from an Availability Schedule's ranges and timezone, a set
of Busy intervals, Duration, minimum notice, and booking horizon — no I/O,
mirroring internal/recurrence's own leaf-package shape. Every range
boundary and day-step is computed via time.Date/AddDate rather than
duration arithmetic, so a range spanning a DST transition yields the
wall-clock hours stated.
BookingLinkService.DeriveSlotsForMonth assembles that module's inputs from
the database: Busy Occurrences in the link's Conflict set (expanded with
the same recurrence expander the reminder scheduler uses, Overrides
replacing their own Occurrence's busyness independently of the Master's),
plus incomplete Tasks' Time blocks when the Tasks row is set. A new
authenticated endpoint, GET /api/booking-links/{id}/slots, returns the
result for one link and one calendar month.
Closes #323
A stranger with no Session opens /:handle/:slug and sees the host's
Name, Duration, Location, Description and timezone, a month grid of
derived availability, and the selected day's slots in their own
detected timezone (with a picker to correct it) and 12h/24h format
(seeded from browser locale) — all per-visit state, persisted nowhere.
Month paging is bounded by the link's booking horizon. Nothing can be
booked yet; selecting a slot only highlights it.
The public GET surface (/api/public/{handle}/{slug} and its /slots
route) reuses the reserved-handle registry from #321 and the slot
derivation from #323, folds an unknown Handle, an unknown Slug and a
reserved-word Handle into one indistinguishable 404 so the namespace
can't enumerate Users, and clamps a link to Paused (explicit Visibility,
no SMTP configured, or a lost Book-into Calendar Access re-checked at
read time) per ADR-0087. Both public routes are rate limited per IP.
Fixes #324
/:handle renders the derived index of a User's Public Booking Links — their Name and initials, and every Public link they hold, unioned across every Workspace they belong to. There is no row behind it: it's computed exactly like a Task bucket, in the sense ADR-0084 already describes it. PublicIndexService reuses PublicBookingService's own isPaused rule wholesale rather than duplicating it, so a Private, explicitly Paused, SMTP-less, or Access-lost link is excluded from the index on the exact same terms it'd be Paused on the per-link page (ADR-0087). An unknown Handle, a reserved-word Handle, and a real Handle whose links are all excluded for any of those reasons render byte-identical (empty HostName, empty Links) — the index's own answer to ADR-0084's enumeration guard, extended past the "unknown vs Private" case #324 already closed. The new GET /api/public/{handle} sits behind the same PublicBookingRateLimiter and shares the /:handle/:slug router's public namespace. Fixes #325
A visitor picks a slot, gives their name and email, and confirms. The booking creates an ordinary Busy Event on the host's Book-into Calendar with the visitor as an email-shaped Attendee, who receives the ordinary Invitation. The slot is re-derived inside the same write transaction as the insert, so two visitors racing one slot can never both win — the loser sees the time was just taken and gets refreshed slots. Access to the Book-into Calendar and SMTP are re-checked at booking time, and a writable Linked Calendar is a legal Book-into target, queuing an ordinary Write-back push. Also fixes BookingLinkRepository.GetBySlug, which never populated a Booking Link's Conflict set — silently breaking busy-interval detection on the public page's slot derivation for #324. Cancellation is out of scope here; tracked separately in #327. Fixes #326
A visitor can call off a booking without emailing anyone. The confirmation mail now carries a signed cancel link (an HMAC over the Event id, no accompanying bookings row per ADR-0087); following it deletes the Event, which emits the METHOD:CANCEL the Attendee machinery already sends, frees the slot immediately, and queues the host a plain cancellation notice. Both notices go out as a new BOOKING_NOTICE outbox method rather than inline, per ADR-0060. A bad or tampered signature is refused, cancelling after the Event has started is refused, and following the link twice is idempotent. Cancellation reuses EventService.Delete wholesale, so a booking on a writable Linked Calendar still queues the ordinary delete Write-back. No Notification is raised — the three producers in CONTEXT.md stay three. Fixes #327
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.