feat(calendar): M8.3 — calendar pilot for unlisted-share end-to-end

Wires Calendar through the M8.1+M8.2 backbone: flipping an event to
'unlisted' now publishes a server-side snapshot, the visible link in
the DetailView/EventDetailModal opens a real /share/[token] page, and
recipients can download an .ics file for their own calendar.

Changes:
- lib/data/unlisted/resolvers.ts (new):
    buildUnlistedBlob(collection, recordId) dispatcher.
    buildEventBlob: load LocalEvent + linked TimeBlock, decrypt
    client-side, return { title, location, startTime, endTime,
    isAllDay, timezone }. Description, reminders, tagIds, calendarId,
    color stay out of the blob — sensitive context the user didn't
    consent to share by flipping a single flag.
- modules/calendar/types: CalendarEvent gains `unlistedToken: string`
  (empty string when no active token). timeBlockToCalendarEvent
  forwards from LocalEvent. Draft-event scaffold initializes empty.
- modules/calendar/stores/events:
    setVisibility now coordinates with mana-api. Flip-to-unlisted:
      build blob -> publishUnlistedSnapshot -> store server-issued
      token in patch.unlistedToken -> commit local update. If the
      server call fails, no local change happens (no drift).
    Flip-from-unlisted: revoke server snapshot first, then clear
      local token + commit visibility change.
    deleteEvent: revoke active unlisted snapshot before tombstoning,
      so the share-link dies in lock-step with the local delete.
    updateEvent + updateSingleInstance fire-and-forget
      refreshUnlistedSnapshot(id) so the published blob tracks any
      whitelist-field edits. Failures log; the next successful
      refresh heals.
    New regenerateUnlistedToken(id): revoke + republish in one call,
      returns the fresh token. Powers the "Neu erzeugen" UI.
- routes/share/[token]/+layout.svelte: minimal anonymous chrome —
  no app nav, no auth, no Dexie. Light/dark via prefers-color-scheme.
  Footer carries "Geteilt via Mana" + signup CTA.
- routes/share/[token]/+page.server.ts: SSR loader. Fetches
  /api/v1/unlisted/public/:token, dispatches 404/410 cleanly,
  sets Cache-Control: private, max-age=60 + X-Robots-Tag: noindex.
- routes/share/[token]/+page.svelte: dispatcher; renders
  SharedEventView for collection='events', stub message otherwise.
- modules/calendar/SharedEventView.svelte: standalone public render —
  big date, location, "Zum eigenen Kalender hinzufügen" .ics link,
  optional expiry note. OG/Twitter meta tags for WhatsApp/Slack
  preview embedding. Uses $derived everywhere so prop updates
  propagate through reactive recompute.
- routes/share/[token]/ical/+server.ts: RFC 5545 builder. No npm
  library — small enough to inline. Escapes per spec, CRLF endings,
  DTSTART/DTEND swap between VALUE=DATE and UTC depending on isAllDay.
  Wrong-collection requests get 400.
- modules/calendar/views/DetailView (Workbench) + components/
  EventDetailModal (/calendar route): SharedLinkControls dropped in
  below the visibility row when event.visibility === 'unlisted'
  AND event.unlistedToken AND shareUrl computed. The URL is built
  client-side via buildShareUrl(window.location.origin, token) so it
  stays in sync with whichever host the editor is open on.

Verified:
- pnpm check (web): 7541 files, 0 errors, 0 warnings
- pnpm test calendar + website: 26/26
- typecheck of new resolver, store hooks, SSR loader, iCal builder

Manual test path:
1. Open /calendar event in Detail view, flip Sichtbarkeit -> "Per Link"
2. Server publishes snapshot, Dexie record gets the server token
3. SharedLinkControls appear with copy + regenerate + revoke buttons
4. Open the URL in incognito → SSR fetches snapshot, renders
   SharedEventView with date / location / .ics download
5. Edit the event title back in the main app → snapshot auto-refreshes
   (refreshUnlistedSnapshot fires after updateEvent succeeds)
6. Flip back to "Bereich" → snapshot revoked server-side; subsequent
   incognito reloads return 410 Gone

Next: M8.4 — same wiring for Library + Places. Uses the same
infra (resolvers dispatcher, share dispatcher) — just adds two new
buildXBlob functions, two SharedXView components, and the store
hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 11:40:53 +02:00
parent 8b9fbd2e1c
commit fbbadc91f0
13 changed files with 1093 additions and 11 deletions

View file

@ -0,0 +1,104 @@
/**
* Unlisted-snapshot resolvers client-side blob builders.
*
* When a user flips a record to `visibility === 'unlisted'`, the store
* calls `buildUnlistedBlob(collection, recordId)` here to produce the
* whitelist-filtered plaintext payload that gets pushed to the
* unlisted-snapshots table via `publishUnlistedSnapshot`.
*
* Whitelist is mandatory per module. What isn't listed explicitly does
* NOT make it into the snapshot protection against accidentally
* leaking encrypted fields like description / guest-lists / private
* notes. Same principle as `website/embeds.ts` for public snapshots.
*
* See docs/plans/unlisted-sharing.md §3.
*/
import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export class UnsupportedCollectionError extends Error {
constructor(collection: string) {
super(`Unlisted sharing is not supported for collection "${collection}"`);
this.name = 'UnsupportedCollectionError';
}
}
export class RecordNotFoundError extends Error {
constructor(collection: string, recordId: string) {
super(`${collection}/${recordId} not found`);
this.name = 'RecordNotFoundError';
}
}
/**
* Build the whitelist-filtered blob for a record. Dispatcher
* delegates to per-collection builders.
*/
export async function buildUnlistedBlob(
collection: string,
recordId: string
): Promise<Record<string, unknown>> {
switch (collection) {
case 'events':
return buildEventBlob(recordId);
default:
throw new UnsupportedCollectionError(collection);
}
}
/**
* Calendar event snapshot blob.
*
* Whitelist: title, location, startTime, endTime, allDay, timezone.
* Decryption happens client-side here (events table carries encrypted
* title/description/location). Time dimension comes from the linked
* TimeBlock LocalEvent only stores the `timeBlockId` reference.
*
* NOT inlined:
* - description (often holds agenda, private notes, guest info)
* - reminders (implementation detail)
* - tagIds (internal labels)
* - calendarId (internal routing)
* - color (cosmetic, the share page picks its own scheme)
*/
async function buildEventBlob(recordId: string): Promise<Record<string, unknown>> {
const raw = await db.table<LocalEvent>('events').get(recordId);
if (!raw || raw.deletedAt) {
throw new RecordNotFoundError('events', recordId);
}
const decrypted = (await decryptRecord('events', { ...raw })) as LocalEvent;
let startTime: string | null = null;
let endTime: string | null = null;
let isAllDay = false;
let timezone: string | null = null;
if (decrypted.timeBlockId) {
const block = await db.table<LocalTimeBlock>('timeBlocks').get(decrypted.timeBlockId);
if (block && !block.deletedAt) {
startTime = block.startDate;
endTime = block.endDate ?? block.startDate;
isAllDay = block.allDay;
timezone = block.timezone ?? null;
}
}
if (!startTime || !endTime) {
throw new Error(`Event ${recordId} is missing a time-block — cannot build share snapshot`);
}
return {
// Keep the field names stable — the SSR renderer (SharedEventView)
// reads these directly.
title: decrypted.title,
location: decrypted.location ?? null,
startTime,
endTime,
isAllDay,
timezone,
};
}

View file

@ -0,0 +1,230 @@
<!--
Shared-Event-View — the public render of a calendar event behind
an unlisted share link.
Whitelist: title, startTime, endTime, isAllDay, timezone, location.
That's everything the resolver put into the blob (see
apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts :: buildEventBlob).
Design goals: legible date, one-glance "when + where", trivial
"add to calendar" via .ics download, quiet CTA at the end. Works
in light + dark (via prefers-color-scheme in the layout).
-->
<script lang="ts">
interface EventBlob {
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string | null;
location: string | null;
}
let {
blob,
token,
expiresAt,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const event = $derived(blob as unknown as EventBlob);
const start = $derived(new Date(event.startTime));
const end = $derived(new Date(event.endTime));
function formatDate(d: Date): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(d);
}
function formatTime(d: Date): string {
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
}).format(d);
}
const dateLabel = $derived(formatDate(start));
const timeLabel = $derived(
event.isAllDay ? 'Ganztägig' : `${formatTime(start)} ${formatTime(end)}`
);
// Same-day range = compact; otherwise show two dates
const sameDay = $derived(
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
);
const dateRangeLabel = $derived(
sameDay ? dateLabel : `${formatDate(start)} ${formatDate(end)}`
);
const icsUrl = $derived(`/share/${token}/ical`);
const ogDescription = $derived(
[event.isAllDay ? dateLabel : `${dateLabel}, ${formatTime(start)}`, event.location]
.filter(Boolean)
.join(' · ')
);
</script>
<svelte:head>
<title>{event.title} · Mana</title>
<meta name="robots" content="noindex, nofollow" />
<meta property="og:title" content={event.title} />
<meta property="og:description" content={ogDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<article class="event">
<span class="event__kind">Termin</span>
<h1 class="event__title">{event.title}</h1>
<dl class="event__meta">
<div class="event__row">
<dt>Wann</dt>
<dd>
<div class="event__date">{dateRangeLabel}</div>
<div class="event__time">{timeLabel}</div>
{#if event.timezone}
<div class="event__tz">Zeitzone: {event.timezone}</div>
{/if}
</dd>
</div>
{#if event.location}
<div class="event__row">
<dt>Wo</dt>
<dd>{event.location}</dd>
</div>
{/if}
</dl>
<a class="event__ics" href={icsUrl} download="event.ics">📅 Zum eigenen Kalender hinzufügen</a>
{#if expiresAt}
<p class="event__expiry">
Dieser Link läuft am {new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(new Date(expiresAt))} ab.
</p>
{/if}
</article>
<style>
.event {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.event__kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
font-weight: 600;
}
.event__title {
margin: 0;
font-size: 2rem;
font-weight: 700;
line-height: 1.15;
}
.event__meta {
margin: 0;
padding: 1.25rem;
background: rgba(0, 0, 0, 0.03);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.event__row {
display: grid;
grid-template-columns: 5rem 1fr;
gap: 1rem;
align-items: baseline;
}
.event__row dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
font-weight: 600;
}
.event__row dd {
margin: 0;
}
.event__date {
font-weight: 600;
font-size: 1.0625rem;
}
.event__time {
font-size: 0.9375rem;
color: #374151;
margin-top: 0.15rem;
}
.event__tz {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
.event__ics {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
background: #4f46e5;
color: white;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
align-self: flex-start;
}
.event__ics:hover {
background: #4338ca;
}
.event__expiry {
font-size: 0.8125rem;
color: #6b7280;
margin: 0;
font-style: italic;
}
@media (prefers-color-scheme: dark) {
.event__kind {
color: #9ca3af;
}
.event__meta {
background: rgba(255, 255, 255, 0.05);
}
.event__row dt {
color: #9ca3af;
}
.event__time {
color: #d1d5db;
}
.event__tz {
color: #9ca3af;
}
.event__ics {
background: #818cf8;
}
.event__ics:hover {
background: #6366f1;
}
.event__expiry {
color: #9ca3af;
}
}
</style>

View file

@ -2,7 +2,12 @@
import { getDateFnsLocale } from '$lib/i18n/format';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { eventsStore } from '../stores/events.svelte';
import { getCalendarById, getCalendarColor } from '../queries';
import type { Calendar, CalendarEvent } from '../types';
@ -33,6 +38,20 @@
await eventsStore.setVisibility(event.id, next);
}
async function handleRegenerate() {
await eventsStore.regenerateUnlistedToken(event.id);
}
async function handleRevoke() {
await eventsStore.setVisibility(event.id, 'space');
}
const shareUrl = $derived.by(() => {
if (!event.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, event.unlistedToken);
});
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let isEditing = $state(false);
@ -249,6 +268,21 @@
</div>
</div>
<!-- Share link (only when visibility = unlisted) -->
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
<div class="detail-row">
<span class="detail-label">Link</span>
<div class="detail-content">
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
/>
</div>
</div>
{/if}
<!-- Time -->
<div class="detail-row">
<span class="detail-icon"><Clock size={18} /></span>

View file

@ -15,9 +15,13 @@ import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import {
defaultVisibilityFor,
generateUnlistedToken,
publishUnlistedSnapshot,
revokeUnlistedSnapshot,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers';
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import {
@ -166,6 +170,9 @@ export const eventsStore = {
fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
});
CalendarEvents.eventUpdated();
// If this event is shared via unlisted-link, keep the server
// snapshot fresh so the shared view tracks local edits.
void this.refreshUnlistedSnapshot(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update event';
@ -215,6 +222,7 @@ export const eventsStore = {
await encryptRecord('events', localData);
await db.table('events').update(id, localData);
CalendarEvents.eventUpdated();
void this.refreshUnlistedSnapshot(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update instance';
@ -361,6 +369,26 @@ export const eventsStore = {
await deleteBlock(event.timeBlockId);
}
// If the event is shared via unlisted-link, revoke the server
// snapshot before the local tombstone — the link should die
// the moment the user deletes the record, not whenever the cron
// happens to notice.
if (event?.visibility === 'unlisted' && event.unlistedToken) {
const jwt = await authStore.getValidToken();
if (jwt) {
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
});
} catch (e) {
console.error('[calendar/events] revoke on delete failed', e);
}
}
}
await db.table('events').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@ -407,6 +435,7 @@ export const eventsStore = {
color: data.color || null,
tagIds: data.tagIds || [],
visibility: data.visibility ?? 'private',
unlistedToken: data.unlistedToken ?? '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
blockType: 'event',
@ -444,10 +473,21 @@ export const eventsStore = {
},
/**
* 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).
* Flip the event's visibility. Coordinates with the server-side
* unlisted-snapshots table when the transition involves the
* 'unlisted' level:
*
* - private|space|public unlisted:
* build the whitelist blob, publish to mana-api, server returns
* the authoritative token, store it on the Dexie record.
* - unlisted anything else:
* revoke the server snapshot first, then clear the local token.
*
* Server call failures abort the flip so Dexie and server don't
* drift out of sync the user sees an error and can retry. Emits
* the cross-module VisibilityChanged domain event for audit.
*
* See docs/plans/unlisted-sharing.md §4 (Store-Integration).
*/
async setVisibility(id: string, next: VisibilityLevel) {
error = null;
@ -464,11 +504,38 @@ export const eventsStore = {
visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
};
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
// Server-authoritative token. Publish first; local update only
// if the server accepted the snapshot so a share-link always
// resolves to a real row.
if (next === 'unlisted') {
const blob = await buildUnlistedBlob('events', id);
const jwt = await authStore.getValidToken();
if (!jwt) return { success: false, error: 'Nicht eingeloggt' };
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
spaceId,
blob,
});
patch.unlistedToken = token;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
});
}
patch.unlistedToken = undefined;
}
await db.table('events').update(id, patch);
emitDomainEvent('VisibilityChanged', 'calendar', 'events', id, {
@ -483,4 +550,81 @@ export const eventsStore = {
return { success: false, error };
}
},
/**
* Force-regenerate the unlisted token for an event. Revoke the
* existing snapshot, then publish a fresh one server gives back
* a new token because the previous row is marked revoked. UI
* intent: "the old link is leaked or I want a clean slate".
*
* No-op if the event isn't currently 'unlisted'.
*/
async regenerateUnlistedToken(id: string) {
const existing = await db.table<LocalEvent>('events').get(id);
if (!existing || existing.visibility !== 'unlisted') {
return { success: false, error: 'Event is not unlisted' };
}
const jwt = await authStore.getValidToken();
if (!jwt) return { success: false, error: 'Nicht eingeloggt' };
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
});
const blob = await buildUnlistedBlob('events', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
spaceId,
blob,
});
await db.table('events').update(id, {
unlistedToken: token,
updatedAt: new Date().toISOString(),
});
return { success: true, token };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to regenerate link';
return { success: false, error };
}
},
/**
* Re-publish the unlisted snapshot for an event. Called by
* updateEvent/updateSingleInstance/etc. when the owning record is
* currently flagged 'unlisted' keeps the share-link in sync with
* local edits to the whitelist fields.
*
* Fire-and-forget in practice: a failure logs but doesn't revert the
* edit. The next successful re-publish will heal any drift, and the
* user can re-flip visibility to force a fresh publish.
*/
async refreshUnlistedSnapshot(id: string) {
const existing = await db.table<LocalEvent>('events').get(id);
if (!existing || existing.visibility !== 'unlisted') return;
try {
const blob = await buildUnlistedBlob('events', id);
const jwt = await authStore.getValidToken();
if (!jwt) return;
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
spaceId,
blob,
});
} catch (e) {
console.error('[calendar/events] refreshUnlistedSnapshot failed', e);
}
},
};

View file

@ -54,6 +54,12 @@ export interface CalendarEvent {
color: string | null;
tagIds: string[];
visibility: VisibilityLevel;
/**
* Server-issued share token for `visibility === 'unlisted'`. Empty
* string for any other visibility (UI checks `event.unlistedToken`
* to know whether to render the share-link controls).
*/
unlistedToken: string;
createdAt: string;
updatedAt: string;
// TimeBlock metadata (for universal calendar view)
@ -107,6 +113,7 @@ export function timeBlockToCalendarEvent(
// carry a calendar-specific visibility — they inherit 'space' so
// they stay invisible on the website (public requires explicit opt-in).
visibility: eventData?.visibility ?? 'space',
unlistedToken: eventData?.unlistedToken ?? '',
createdAt: block.createdAt,
updatedAt: block.updatedAt,
blockType: block.type,

View file

@ -10,7 +10,12 @@
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { eventsStore } from '../stores/events.svelte';
import { MapPin, Clock, X } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalEvent } from '../types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
@ -98,6 +103,21 @@
await eventsStore.setVisibility(eventId, next);
}
async function handleRegenerate() {
await eventsStore.regenerateUnlistedToken(eventId);
}
async function handleRevoke() {
await eventsStore.setVisibility(eventId, 'space');
}
const shareUrl = $derived.by(() => {
const token = detail.entity?.unlistedToken;
if (!token) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, token);
});
async function deleteEvent() {
const id = eventId;
await eventsStore.deleteEvent(id);
@ -133,6 +153,17 @@
<VisibilityPicker level={event.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
<div class="prop-row prop-row--share">
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
/>
</div>
{/if}
<div class="prop-row">
<span class="prop-icon"><Clock size={14} /></span>
<div class="time-fields">

View file

@ -0,0 +1,71 @@
<!--
Share-Link layout — minimal chrome for anonymous visitors.
No app nav, no auth, no sidebar. Just the snapshot + a quiet
"shared via Mana" footer + a CTA for signup.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="share-shell">
<main class="share-main">
{@render children()}
</main>
<footer class="share-footer">
<span class="share-footer__meta">Geteilt via Mana</span>
<a class="share-footer__cta" href="https://mana.how" target="_blank" rel="noopener noreferrer">
mana.how →
</a>
</footer>
</div>
<style>
.share-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: #fafafa;
color: #111;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif;
}
.share-main {
flex: 1;
max-width: 640px;
margin: 0 auto;
width: 100%;
padding: 2.5rem 1.5rem;
}
.share-footer {
padding: 1.5rem;
text-align: center;
border-top: 1px solid rgba(0, 0, 0, 0.08);
font-size: 0.8125rem;
color: #6b7280;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share-footer__cta {
color: #4f46e5;
text-decoration: none;
}
.share-footer__cta:hover {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.share-shell {
background: #0f1216;
color: #f4f4f5;
}
.share-footer {
color: #9ca3af;
border-top-color: rgba(255, 255, 255, 0.08);
}
.share-footer__cta {
color: #818cf8;
}
}
</style>

View file

@ -0,0 +1,51 @@
/**
* Share-Link SSR loader.
*
* Fetches the unlisted snapshot blob from mana-api and passes it to
* the dispatcher page. The page picks the right per-collection
* component and renders statically. No client-side hydration of the
* user's encrypted data everything the visitor sees is this blob.
*
* See docs/plans/unlisted-sharing.md §5.
*/
import { error } from '@sveltejs/kit';
import { getManaApiUrl } from '$lib/api/config';
import type { PageServerLoad } from './$types';
export interface SnapshotResponse {
token: string;
collection: string;
blob: Record<string, unknown>;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => {
const token = params.token;
if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) {
error(404, 'Link nicht gefunden');
}
const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`);
if (res.status === 404) error(404, 'Link nicht gefunden');
if (res.status === 410) {
const body = await res.json().catch(() => ({ code: 'GONE' }));
const message = body.code === 'EXPIRED' ? 'Link ist abgelaufen' : 'Link wurde widerrufen';
error(410, message);
}
if (!res.ok) error(502, 'Fehler beim Laden des geteilten Inhalts');
const payload = (await res.json()) as SnapshotResponse;
// Short private cache — revocation at the source propagates in ≤60s.
// noindex as both header and meta tag keeps search engines out.
setHeaders({
'cache-control': 'private, max-age=60',
'x-robots-tag': 'noindex, nofollow',
});
return payload;
};

View file

@ -0,0 +1,35 @@
<!--
Share-Link dispatcher. Picks the per-collection render component.
All data lives on `data.blob` — whatever the resolver put there is
what the view consumes, no Dexie roundtrips.
-->
<script lang="ts">
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#if data.collection === 'events'}
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>
<p>Diese Art von geteiltem Inhalt wird von dieser Mana-Version nicht unterstützt.</p>
</div>
{/if}
<style>
.unknown {
text-align: center;
padding: 3rem 1rem;
}
.unknown h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
.unknown p {
margin: 0;
color: #6b7280;
}
</style>

View file

@ -0,0 +1,126 @@
/**
* iCal download for unlisted-shared calendar events.
*
* Fetches the same snapshot the /share/[token] page renders, then
* serialises it to RFC 5545. Only 'events' snapshots return a file;
* other collections get 400.
*
* See docs/plans/unlisted-sharing.md §7.
*/
import { error } from '@sveltejs/kit';
import { getManaApiUrl } from '$lib/api/config';
import type { RequestHandler } from './$types';
interface SnapshotResponse {
token: string;
collection: string;
blob: Record<string, unknown>;
}
interface EventBlob {
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string | null;
location: string | null;
}
export const GET: RequestHandler = async ({ params, fetch }) => {
const token = params.token;
if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) {
error(404, 'Link nicht gefunden');
}
const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`);
if (res.status === 404) error(404, 'Link nicht gefunden');
if (res.status === 410) error(410, 'Link nicht mehr gültig');
if (!res.ok) error(502, 'Fehler beim Laden');
const payload = (await res.json()) as SnapshotResponse;
if (payload.collection !== 'events') {
error(400, 'iCal-Export nur für Kalender-Termine verfügbar');
}
const event = payload.blob as unknown as EventBlob;
const ics = buildIcs(token, event);
return new Response(ics, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'content-disposition': `attachment; filename="event-${token.slice(0, 8)}.ics"`,
'cache-control': 'private, max-age=60',
},
});
};
/**
* Build a minimal RFC 5545 iCalendar body. No library the fields we
* inline are trivial enough. Escaping per the spec: commas,
* semicolons and backslashes in TEXT fields are `\\`-escaped; newlines
* become `\\n`.
*/
function buildIcs(token: string, event: EventBlob): string {
const uid = `unlisted-${token}@mana.how`;
const now = formatIcsUtc(new Date());
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Mana//Unlisted Event//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${now}`,
...formatDtStartEnd(event),
`SUMMARY:${escapeIcs(event.title)}`,
];
if (event.location) {
lines.push(`LOCATION:${escapeIcs(event.location)}`);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
// Line endings per spec: CRLF.
return lines.join('\r\n') + '\r\n';
}
function formatDtStartEnd(event: EventBlob): string[] {
if (event.isAllDay) {
return [
`DTSTART;VALUE=DATE:${formatIcsDate(new Date(event.startTime))}`,
`DTEND;VALUE=DATE:${formatIcsDate(new Date(event.endTime))}`,
];
}
return [
`DTSTART:${formatIcsUtc(new Date(event.startTime))}`,
`DTEND:${formatIcsUtc(new Date(event.endTime))}`,
];
}
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function formatIcsUtc(d: Date): string {
return (
d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
'T' +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
'Z'
);
}
function formatIcsDate(d: Date): string {
return d.getFullYear().toString() + pad(d.getMonth() + 1) + pad(d.getDate());
}
function escapeIcs(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}

View file

@ -26,7 +26,8 @@
"validate:theme-parity": "node scripts/validate-theme-parity.mjs",
"validate:i18n-parity": "node scripts/validate-i18n-parity.mjs",
"validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs",
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run check:crypto && pnpm run audit:encrypted-tools",
"validate:i18n-keys": "node scripts/validate-i18n-keys.mjs",
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run validate:i18n-keys && pnpm run check:crypto && pnpm run audit:encrypted-tools",
"check:crypto": "node scripts/audit-crypto-registry.mjs",
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",

View file

@ -0,0 +1,37 @@
{
"apps/mana/apps/web/src/lib/components/dashboard/widgets/TasksTodayWidget.svelte": 1,
"apps/mana/apps/web/src/lib/components/dashboard/widgets/TransactionsWidget.svelte": 1,
"apps/mana/apps/web/src/lib/components/OfflineIndicator.svelte": 3,
"apps/mana/apps/web/src/lib/components/PwaUpdatePrompt.svelte": 3,
"apps/mana/apps/web/src/lib/modules/period/ListView.svelte": 1,
"apps/mana/apps/web/src/lib/modules/plants/ListView.svelte": 5,
"apps/mana/apps/web/src/lib/modules/quotes/components/QuoteCard.svelte": 4,
"apps/mana/apps/web/src/lib/modules/times/components/EntryForm.svelte": 6,
"apps/mana/apps/web/src/lib/modules/times/components/EntryItem.svelte": 6,
"apps/mana/apps/web/src/lib/modules/times/components/EntryList.svelte": 2,
"apps/mana/apps/web/src/lib/modules/times/components/TimerCard.svelte": 6,
"apps/mana/apps/web/src/lib/modules/times/components/TimerIndicator.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte": 10,
"apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte": 21,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte": 10,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte": 27,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte": 22,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte": 26,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/quotes/+page.svelte": 3,
"apps/mana/apps/web/src/routes/(app)/quotes/categories/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/quotes/category/[category]/+page.svelte": 8,
"apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte": 8,
"apps/mana/apps/web/src/routes/(app)/quotes/lists/[id]/+page.svelte": 31,
"apps/mana/apps/web/src/routes/(app)/quotes/lists/+page.svelte": 16,
"apps/mana/apps/web/src/routes/(app)/times/+page.svelte": 4,
"apps/mana/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/times/clients/+page.svelte": 12,
"apps/mana/apps/web/src/routes/(app)/times/clock/alarms/+page.svelte": 8,
"apps/mana/apps/web/src/routes/(app)/times/entries/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte": 12,
"apps/mana/apps/web/src/routes/(app)/times/projects/+page.svelte": 11,
"apps/mana/apps/web/src/routes/(app)/times/reports/+page.svelte": 12,
"apps/mana/apps/web/src/routes/(app)/times/templates/+page.svelte": 8
}

View file

@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* Cross-checks i18n key usage in code against keys defined in DE
* locale JSONs. Two directions:
*
* - **Missing**: a `$_('a.b.c')` call where `a.b.c` is not in DE.
* These would render as the raw key string at runtime a
* user-visible bug. Tracked against a per-file baseline so the
* existing backlog doesn't block CI but new misses fail hard.
*
* - **Dead**: a key in DE that no `$_(…)` call references (statically
* or via a known dynamic prefix). Reported as INFO; not enforced
* because the writing-key-first workflow would otherwise block.
*
* Dynamic suffixes via template literals (`$_(`ns.foo.${x}`)`) and
* concatenations (`$_('ns.foo.' + x)`) become "prefix masks": every
* key under `ns.foo.` is treated as potentially used.
*
* Usage:
* node scripts/validate-i18n-keys.mjs # check against baseline
* node scripts/validate-i18n-keys.mjs --update # rewrite baseline
* node scripts/validate-i18n-keys.mjs --report # print full dead-key list
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const LOCALES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/i18n/locales');
const SRC_DIR = 'apps/mana/apps/web/src';
const BASELINE_PATH = join(__dirname, 'i18n-missing-baseline.json');
function flattenKeys(obj, prefix = '') {
const keys = [];
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) keys.push(...flattenKeys(v, path));
else keys.push(path);
}
return keys;
}
function loadDefinedKeys() {
const defined = new Set();
const namespaces = readdirSync(LOCALES_DIR).filter((f) =>
statSync(join(LOCALES_DIR, f)).isDirectory()
);
for (const ns of namespaces) {
const path = join(LOCALES_DIR, ns, 'de.json');
if (!existsSync(path)) continue;
for (const k of flattenKeys(JSON.parse(readFileSync(path, 'utf8')))) {
defined.add(`${ns}.${k}`);
}
}
return defined;
}
function scanUsages() {
const files = execSync(`git ls-files '${SRC_DIR}/**/*.svelte' '${SRC_DIR}/**/*.ts'`, {
cwd: REPO_ROOT,
})
.toString()
.trim()
.split('\n')
.filter(Boolean);
// per-key list of files where it's referenced — for nice error reporting
const usedByFile = new Map();
const dynamicPrefixes = new Set();
for (const f of files) {
const src = readFileSync(join(REPO_ROOT, f), 'utf8');
// $_('a.b.c') or _('a.b.c')
for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*)['"]/g)) {
const key = m[1];
if (!usedByFile.has(key)) usedByFile.set(key, new Set());
usedByFile.get(key).add(f);
}
// $_(`a.b.${x}`) → prefix "a.b."
for (const m of src.matchAll(/\$?_\(\s*`([a-zA-Z][\w.-]*\.)\$\{/g)) {
dynamicPrefixes.add(m[1]);
}
// $_('a.b.' + x) → prefix "a.b."
for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*\.)['"]\s*\+/g)) {
dynamicPrefixes.add(m[1]);
}
}
return { usedByFile, dynamicPrefixes };
}
function loadBaseline() {
if (!existsSync(BASELINE_PATH)) return {};
return JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
}
function buildPerFileMissing(usedByFile, defined) {
// Returns: { file: { count, keys: [...] } }
const perFile = {};
for (const [key, files] of usedByFile) {
if (defined.has(key)) continue;
for (const f of files) {
if (!perFile[f]) perFile[f] = { count: 0, keys: new Set() };
perFile[f].count++;
perFile[f].keys.add(key);
}
}
const result = {};
for (const [f, { count, keys }] of Object.entries(perFile)) {
result[f] = count;
}
return { perFileCount: result, missingKeysByFile: perFile };
}
function main() {
const update = process.argv.includes('--update');
const reportMode = process.argv.includes('--report');
const defined = loadDefinedKeys();
const { usedByFile, dynamicPrefixes } = scanUsages();
const used = new Set(usedByFile.keys());
const dead = [...defined].filter(
(k) => !used.has(k) && ![...dynamicPrefixes].some((p) => k.startsWith(p))
);
const { perFileCount, missingKeysByFile } = buildPerFileMissing(usedByFile, defined);
const totalMissing = Object.values(perFileCount).reduce((a, b) => a + b, 0);
if (reportMode) {
console.log(`Defined keys: ${defined.size}`);
console.log(`Statically used: ${used.size}, dynamic prefixes: ${dynamicPrefixes.size}`);
console.log(`Dead keys (defined, never referenced): ${dead.length}`);
console.log('\n--- top 30 dead keys ---');
for (const k of dead.slice(0, 30)) console.log(' ' + k);
console.log('\n--- missing keys (used, undefined) ---');
for (const [f, info] of Object.entries(missingKeysByFile).slice(0, 20)) {
console.log(` ${f}: ${info.count}`);
for (const k of [...info.keys].slice(0, 5)) console.log(` - ${k}`);
}
return;
}
if (update) {
const sorted = Object.fromEntries(
Object.entries(perFileCount).sort(([a], [b]) => a.localeCompare(b))
);
writeFileSync(BASELINE_PATH, JSON.stringify(sorted, null, 2) + '\n');
console.log(
`✓ Baseline updated: ${totalMissing} missing-key reference(s) across ${Object.keys(perFileCount).length} files.`
);
return;
}
const baseline = loadBaseline();
const baselineTotal = Object.values(baseline).reduce((a, b) => a + b, 0);
const violations = [];
for (const [file, n] of Object.entries(perFileCount)) {
const b = baseline[file] ?? 0;
if (n > b) {
violations.push({
file,
current: n,
baseline: b,
delta: n - b,
keys: [...missingKeysByFile[file].keys].filter(
(k) => !(baseline[file] && false) // we don't track which exact keys were baselined; show all
),
});
}
}
if (violations.length > 0) {
console.error(`\n✗ i18n missing-key check FAILED — ${violations.length} file(s) over baseline:\n`);
for (const v of violations.slice(0, 20)) {
console.error(` ${v.file}: ${v.current} (was ${v.baseline}, +${v.delta})`);
for (const k of v.keys.slice(0, 3)) console.error(` - ${k}`);
if (v.keys.length > 3) console.error(` … +${v.keys.length - 3} more keys`);
}
if (violations.length > 20) console.error(` … +${violations.length - 20} more files`);
console.error(
`\nA $_('…') call references a key that does not exist in any DE locale.\n` +
`At runtime this renders as the raw key string. Add the key to the\n` +
`appropriate locales/<ns>/de.json (parity validator will demand the\n` +
`other locales) — or fix the typo.\n` +
`If intentional (e.g. you renamed away a key still being referenced\n` +
`in legacy code), run: pnpm run validate:i18n-keys -- --update\n`
);
process.exit(1);
}
const shrunk = Object.keys(baseline).filter((f) => (perFileCount[f] ?? 0) < baseline[f]).length;
const cleaned = Object.keys(baseline).filter((f) => !(f in perFileCount)).length;
console.log(
`✓ i18n keys: ${totalMissing} missing reference(s) (baseline ${baselineTotal}); ` +
`${dead.length} dead key(s) defined but unused.` +
(shrunk || cleaned
? `\n ${shrunk} file(s) shrunk, ${cleaned} file(s) fully cleaned — ` +
`run 'pnpm run validate:i18n-keys -- --update' to ratchet.`
: '')
);
}
main();