feat(calendar): M4.a — events adopt the unified visibility system

Third consumer of @mana/shared-privacy. Calendar events now carry a
VisibilityLevel the owner flips from the EventDetailModal via
<VisibilityPicker>; a new calendar.events embed source lets the user
drop a moduleEmbed block on their website that pulls their public
events in.

This unblocks concrete use-cases the Website-Builder audit surfaced:
band tour dates, public workshops, public rehearsals on a team-space
website, meeting-with-the-host pages.

Changes:
- calendar/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalEvent; CalendarEvent (UI type) requires
  visibility. timeBlockToCalendarEvent forwards the field; cross-module
  TimeBlocks (tasks, habits, time entries) without an owning
  LocalEvent fall back to 'space' so they stay off the public embed
- calendar/stores/events: createEvent stamps
  defaultVisibilityFor(activeSpace.type); createDraftEvent seeds a
  'private' draft until the user explicitly opts in; new
  setVisibility(id, level) mints/clears the unlisted token on the
  transition boundary and emits cross-module VisibilityChanged
- calendar/components/EventDetailModal: <VisibilityPicker compact>
  sits in the modal-actions row left of copy/edit/delete

website embed:
- website-blocks/moduleEmbed/schema: EmbedSourceSchema adds
  'calendar.events'; the filter shape gains optional `upcomingDays`
  (1-365) and `tagIds` (up to 16). Old filters (isFavorite/status/kind)
  remain — each source uses only its own subset
- website/embeds: resolveCalendarEvents gates hard on
  canEmbedOnWebsite(event.visibility ?? 'private'), joins each event
  to its LocalTimeBlock for the real start/end, applies the optional
  upcomingDays window and tag-id AND-filter, sorts upcoming-first with
  id as stable tiebreaker

Redaction is whitelist-per-design (plan §2): the inlined snapshot
carries only title, formatted date range, and location — NOT
description, reminders, tag labels, or the guest list. Fields that
typically hold private context stay out of the public blob regardless
of the visibility toggle.

Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test calendar + website: 26/26
- pnpm run validate:all green

Next: M4.b — Todo, M4.c — Goals. Same pattern; split out because
goals lives under $lib/companion/goals/ with its own structure and
Todo has a complex view-column/filter surface that warrants its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 02:32:25 +02:00
parent 0e9f574dfb
commit ac44d51363
5 changed files with 177 additions and 3 deletions

View file

@ -26,7 +26,7 @@ export const EmbedResolvedSchema = z.object({
* Supported embed sources. Add new sources here + a matching provider
* in the editor's publish resolver.
*/
export const EmbedSourceSchema = z.enum(['picture.board', 'library.entries']);
export const EmbedSourceSchema = z.enum(['picture.board', 'library.entries', 'calendar.events']);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
export const ModuleEmbedSchema = z.object({
@ -38,14 +38,20 @@ export const ModuleEmbedSchema = z.object({
layout: z.enum(['grid', 'list']).default('grid'),
maxItems: z.number().int().min(1).max(48).default(12),
/**
* Optional filters depending on source. Library uses { isFavorite?,
* status?, kind? }; picture ignores them in M4.
* Optional filters depending on source.
* library.entries: { isFavorite?, status?, kind? }
* picture.board: ignored (board is the source)
* calendar.events: { upcomingDays?, tagIds? } omit upcomingDays
* to include past events; tagIds AND-filter on
* event tag assignments
*/
filter: z
.object({
isFavorite: z.boolean().optional(),
status: z.string().max(32).optional(),
kind: z.string().max(32).optional(),
upcomingDays: z.number().int().min(1).max(365).optional(),
tagIds: z.array(z.string().max(64)).max(16).optional(),
})
.default({}),
/**