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

@ -1,6 +1,7 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { eventsStore } from '../stores/events.svelte';
import { getCalendarById, getCalendarColor } from '../queries';
import type { Calendar, CalendarEvent } from '../types';
@ -29,6 +30,10 @@
let { event, onClose }: Props = $props();
async function handleVisibilityChange(next: VisibilityLevel) {
await eventsStore.setVisibility(event.id, next);
}
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let isEditing = $state(false);
@ -206,6 +211,7 @@
</div>
<div class="modal-actions">
{#if !isEditing}
<VisibilityPicker level={event.visibility} onChange={handleVisibilityChange} compact />
<button class="btn btn-ghost" onclick={copyToClipboard} title="Kopieren">
{#if copied}<Check size={16} />{:else}<Copy size={16} />{/if}
</button>

View file

@ -11,6 +11,13 @@
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import {
defaultVisibilityFor,
generateUnlistedToken,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import {
@ -76,6 +83,7 @@ export const eventsStore = {
location: input.location ?? null,
color: input.color ?? null,
reminders: null,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
@ -398,6 +406,7 @@ export const eventsStore = {
parentEventId: null,
color: data.color || null,
tagIds: data.tagIds || [],
visibility: data.visibility ?? 'private',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
blockType: 'event',
@ -433,4 +442,45 @@ export const eventsStore = {
}
return eventId;
},
/**
* Flip the event's visibility. Mints/clears an unlisted token on the
* transition boundary, emits the cross-module VisibilityChanged event.
* Publishes a public event on the next website snapshot with
* field-level redaction applied server-side (see embeds.ts).
*/
async setVisibility(id: string, next: VisibilityLevel) {
error = null;
try {
const existing = await db.table<LocalEvent>('events').get(id);
if (!existing) return { success: false, error: 'Event not found' };
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return { success: true };
const now = new Date().toISOString();
const patch: Partial<LocalEvent> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
};
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined;
}
await db.table('events').update(id, patch);
emitDomainEvent('VisibilityChanged', 'calendar', 'events', id, {
recordId: id,
collection: 'events',
before,
after: next,
});
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set visibility';
return { success: false, error };
}
},
};

View file

@ -6,6 +6,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
export interface LocalCalendar extends BaseRecord {
@ -25,6 +26,10 @@ export interface LocalEvent extends BaseRecord {
color?: string | null;
reminders?: unknown | null;
tagIds?: string[];
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
}
export type CalendarViewType = 'week' | 'month' | 'agenda';
@ -48,6 +53,7 @@ export interface CalendarEvent {
parentEventId: string | null;
color: string | null;
tagIds: string[];
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
// TimeBlock metadata (for universal calendar view)
@ -97,6 +103,10 @@ export function timeBlockToCalendarEvent(
parentEventId: null,
color: eventData?.color ?? block.color,
tagIds: eventData?.tagIds ?? [],
// Cross-module TimeBlock entries (tasks, habits, time entries) don't
// carry a calendar-specific visibility — they inherit 'space' so
// they stay invisible on the website (public requires explicit opt-in).
visibility: eventData?.visibility ?? 'space',
createdAt: block.createdAt,
updatedAt: block.updatedAt,
blockType: block.type,

View file

@ -17,10 +17,13 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { canEmbedOnWebsite } from '@mana/shared-privacy';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import { mediaFileUrl } from './upload';
import type { EmbedItem, EmbedSource, ModuleEmbedProps } from '@mana/website-blocks';
import type { LocalBoard, LocalBoardItem, LocalImage } from '$lib/modules/picture/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export interface ResolvedEmbed {
items: EmbedItem[];
@ -40,6 +43,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'library.entries':
items = await resolveLibraryEntries(props);
break;
case 'calendar.events':
items = await resolveCalendarEvents(props);
break;
default:
return {
items: [],
@ -158,3 +164,99 @@ async function resolveLibraryEntries(props: ModuleEmbedProps): Promise<EmbedItem
};
});
}
/**
* Calendar-events: returns events whose owner flipped visibility to
* 'public'. By design (plan §2), the snapshot carries a whitelist of
* fields only title, start/end time, location. Description, guest
* list, reminders, and tags are NOT inlined because they frequently
* carry private context that an event's visibility toggle shouldn't
* accidentally expose.
*
* Optional filters on top of the hard gate:
* - upcomingDays: number of days forward from now; events starting
* later are dropped. Omit to include all (past + future).
* - tagIds: at-least-one overlap with event.tagIds.
*/
async function resolveCalendarEvents(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let events = await db.table<LocalEvent>('events').toArray();
events = events.filter((e) => !e.deletedAt && canEmbedOnWebsite(e.visibility ?? 'private'));
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
events = events.filter((e) => (e.tagIds ?? []).some((t) => wanted.has(t)));
}
const decrypted = (await decryptRecords('events', events)) as LocalEvent[];
// Join with TimeBlock for the actual start/end times (events only
// store a reference). Fetch in one pass, then attach by id.
const blockIds = decrypted.map((e) => e.timeBlockId).filter((id): id is string => Boolean(id));
const blocks = await timeBlockTable.where('id').anyOf(blockIds).toArray();
const byBlockId = new Map<string, LocalTimeBlock>();
for (const b of blocks) byBlockId.set(b.id, b);
const now = Date.now();
const upcomingCutoff =
typeof props.filter?.upcomingDays === 'number'
? now + props.filter.upcomingDays * 24 * 60 * 60 * 1000
: null;
const withBlock: Array<{ event: LocalEvent; block: LocalTimeBlock; startMs: number }> = [];
for (const e of decrypted) {
const b = byBlockId.get(e.timeBlockId);
if (!b) continue;
const startMs = Date.parse(b.startDate);
if (Number.isNaN(startMs)) continue;
if (upcomingCutoff !== null && (startMs < now || startMs > upcomingCutoff)) continue;
withBlock.push({ event: e, block: b, startMs });
}
// Upcoming-first; same-day ties broken by id so the snapshot is stable.
withBlock.sort((a, b) => a.startMs - b.startMs || a.event.id.localeCompare(b.event.id));
return withBlock.map(({ event, block }) => ({
title: event.title,
subtitle: formatEventSubtitle(block.startDate, block.endDate, block.allDay, event.location),
}));
}
/**
* Build the subtitle shown under a calendar-event embed card. Kept in
* the plaintext layer (not in the Svelte renderer) so the inlined blob
* is self-contained and the public page needs no locale-aware
* formatting round-trip. German only for now matches the rest of the
* module copy.
*/
function formatEventSubtitle(
startIso: string,
endIso: string | null,
allDay: boolean,
location: string | null | undefined
): string {
const start = new Date(startIso);
const dateParts = new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(start);
let timePart = '';
if (!allDay) {
const timeFormat = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
const startTime = timeFormat.format(start);
if (endIso) {
const endTime = timeFormat.format(new Date(endIso));
timePart = ` · ${startTime}${endTime}`;
} else {
timePart = ` · ${startTime}`;
}
}
const loc = location?.trim();
const locPart = loc ? ` · ${loc}` : '';
return `${dateParts}${timePart}${locPart}`;
}

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({}),
/**