mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
0e9f574dfb
commit
ac44d51363
5 changed files with 177 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue