Skip to content

Local tasks integration, booking pages - #328

Merged
XiovV merged 33 commits into
mainfrom
develop
Sep 29, 2026
Merged

XiovV merged 33 commits into
mainfrom
develop

Conversation

@XiovV

@XiovV XiovV commented Sep 29, 2026

Copy link
Copy Markdown
Owner

No description provided.

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
@XiovV
XiovV merged commit 1c53a0b into main Sep 29, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant