feat(events): scaffold social events module (Phase 1a, local-only)

New 'events' module for planning gatherings with guest lists and RSVPs,
distinct from the personal calendar. Events surface in the calendar via
TimeBlock with sourceModule='events'. Guests, RSVPs and a publish stub
work fully local-first; the public RSVP server lands in Phase 1b.
This commit is contained in:
Till JS 2026-04-07 14:12:41 +02:00
parent 22d3d2b695
commit 30022e82e1
19 changed files with 1717 additions and 5 deletions

View file

@ -12,6 +12,7 @@ import { trackFirstContent } from '$lib/stores/funnel-tracking';
import { fire as fireTrigger } from '$lib/triggers/registry';
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
import { getEffectiveUserId } from './current-user';
import { isQuotaError, notifyQuotaExceeded } from './quota-detect';
// ─── Database ──────────────────────────────────────────────
@ -623,6 +624,26 @@ export function setApplyingServerChanges(v: boolean): void {
const pendingChangesTable = db.table('_pendingChanges');
/**
* Fire-and-forget pending-change writer that surfaces quota errors via the
* QUOTA_EVENT bus. Without this wrapper, a full IndexedDB would silently
* swallow the change-tracking entry while the user-visible write succeeded
* meaning the user types something, sees it, and the edit never syncs.
*
* The Dexie creating/updating hook itself is synchronous and cannot await
* a recovery, so we just dispatch the event and let the UI / sync engine
* decide what to do (e.g. surface a toast, run cleanupTombstones).
*/
function trackPendingChange(table: string, change: Record<string, unknown>): void {
pendingChangesTable.add(change).catch((err: unknown) => {
if (isQuotaError(err)) {
notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false });
} else {
console.error('[mana-sync] failed to record pending change:', err);
}
});
}
/**
* Hidden field on every synced record holding per-field LWW timestamps.
* Not indexed, not sent to the server in pending-change payloads.
@ -638,7 +659,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const table = db.table(tableName);
table.hook('creating', function (_primKey, obj) {
if (_applyingServerChanges) return;
if (_applyingTables.has(tableName)) return;
const now = new Date().toISOString();
// Auto-stamp the active user. Module stores never set userId themselves,
@ -661,7 +682,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
// Build payload for pending-change WITHOUT the internal timestamp map
const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record<string, unknown>;
pendingChangesTable.add({
trackPendingChange(tableName, {
appId,
collection: tableName,
recordId: obj.id,
@ -682,7 +703,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
});
table.hook('updating', function (modifications, primKey, obj) {
if (_applyingServerChanges) return undefined;
if (_applyingTables.has(tableName)) return undefined;
const now = new Date().toISOString();
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
@ -705,7 +726,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
newFT[key] = now;
}
pendingChangesTable.add({
trackPendingChange(tableName, {
appId,
collection: tableName,
recordId: primKey as string,

View file

@ -14,7 +14,7 @@ export type TimeBlockKind = 'scheduled' | 'logged';
export type TimeBlockType = 'event' | 'task' | 'habit' | 'timeEntry' | 'focus' | 'break';
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits';
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits' | 'events';
// ─── Local Record Types (Dexie) ──────────────────────────

View file

@ -0,0 +1,214 @@
<script lang="ts">
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
import { eventsStore } from './stores/events.svelte';
import EventCard from './components/EventCard.svelte';
import type { SocialEvent } from './types';
interface Props {
onOpenEvent?: (id: string) => void;
}
let { onOpenEvent }: Props = $props();
const upcoming = useUpcomingEvents();
const past = usePastEvents();
const guestsByEvent = useGuestsByEvent();
let showCreate = $state(false);
let newTitle = $state('');
let newDate = $state('');
let newTime = $state('19:00');
let newLocation = $state('');
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
const title = newTitle.trim();
if (!title || !newDate) return;
const startTime = new Date(`${newDate}T${newTime || '19:00'}`).toISOString();
const endTime = new Date(new Date(startTime).getTime() + 2 * 60 * 60 * 1000).toISOString();
const result = await eventsStore.createEvent({
title,
startTime,
endTime,
location: newLocation.trim() || null,
});
if (result.success) {
newTitle = '';
newDate = '';
newLocation = '';
showCreate = false;
onOpenEvent?.(result.id);
}
}
function open(event: SocialEvent) {
onOpenEvent?.(event.id);
}
</script>
<svelte:head>
<title>Events - Mana</title>
</svelte:head>
<div class="events-page">
<header class="events-header">
<div>
<h1 class="page-title">Events</h1>
<p class="page-subtitle">
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
</p>
</div>
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Abbrechen' : '+ Neues Event'}
</button>
</header>
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
<input
class="input"
bind:value={newTitle}
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
required
/>
<div class="form-row">
<input class="input" type="date" bind:value={newDate} required />
<input class="input" type="time" bind:value={newTime} />
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
</div>
<button type="submit" class="action-btn primary">Event anlegen</button>
</form>
{/if}
<section class="event-section">
<h2 class="section-title">Bevorstehend</h2>
{#if (upcoming.value ?? []).length === 0}
<p class="empty">Keine bevorstehenden Events. Zeit für eine Party?</p>
{:else}
<div class="event-list">
{#each upcoming.value ?? [] as event (event.id)}
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
<EventCard {event} {summary} onclick={() => open(event)} />
{/each}
</div>
{/if}
</section>
{#if (past.value ?? []).length > 0}
<section class="event-section">
<h2 class="section-title">Vergangen</h2>
<div class="event-list">
{#each past.value ?? [] as event (event.id)}
<EventCard {event} onclick={() => open(event)} />
{/each}
</div>
</section>
{/if}
</div>
<style>
.events-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 880px;
margin: 0 auto;
}
.events-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.page-subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.new-btn {
padding: 0.5rem 0.875rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.form-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.form-row .input {
flex: 1;
min-width: 8rem;
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.action-btn {
padding: 0.5rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
align-self: flex-start;
}
.action-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.event-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.event-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty {
padding: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
border: 1px dashed hsl(var(--color-border));
border-radius: 0.625rem;
}
</style>

View file

@ -0,0 +1,10 @@
/**
* Events module collection accessors.
*/
import { db } from '$lib/data/database';
import type { LocalSocialEvent, LocalEventGuest, LocalEventInvitation } from './types';
export const socialEventTable = db.table<LocalSocialEvent>('socialEvents');
export const eventGuestTable = db.table<LocalEventGuest>('eventGuests');
export const eventInvitationTable = db.table<LocalEventInvitation>('eventInvitations');

View file

@ -0,0 +1,153 @@
<script lang="ts">
import type { SocialEvent, RsvpSummary } from '../types';
interface Props {
event: SocialEvent;
summary?: RsvpSummary | null;
onclick?: () => void;
}
let { event, summary = null, onclick }: Props = $props();
const startDate = $derived(new Date(event.startTime));
const dateLabel = $derived(
startDate.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: 'short',
})
);
const timeLabel = $derived(
event.isAllDay
? 'Ganztägig'
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
);
</script>
<button class="event-card" {onclick}>
<div class="date-block" style:background={event.color ?? '#6366F1'}>
<div class="date">{dateLabel}</div>
<div class="time">{timeLabel}</div>
</div>
<div class="event-body">
<div class="title-row">
<h3 class="title">{event.title}</h3>
{#if event.status === 'draft'}
<span class="status-badge draft">Entwurf</span>
{:else if event.status === 'cancelled'}
<span class="status-badge cancelled">Abgesagt</span>
{:else if event.isPublished}
<span class="status-badge published">Geteilt</span>
{/if}
</div>
{#if event.location}
<div class="location">📍 {event.location}</div>
{/if}
{#if summary}
<div class="summary-row">
<span class="yes-count">{summary.totalAttending} kommen</span>
{#if summary.pending > 0}
<span class="pending-count">· {summary.pending} offen</span>
{/if}
</div>
{/if}
</div>
</button>
<style>
.event-card {
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
text-align: left;
cursor: pointer;
overflow: hidden;
transition:
transform 0.1s ease,
box-shadow 0.15s;
width: 100%;
}
.event-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.date-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 5rem;
padding: 0.75rem 0.5rem;
color: white;
}
.date {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
opacity: 0.95;
}
.time {
font-size: 0.875rem;
font-weight: 700;
margin-top: 0.125rem;
}
.event-body {
flex: 1;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
flex: 1;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
}
.status-badge.draft {
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.status-badge.cancelled {
background: rgba(239, 68, 68, 0.15);
color: rgb(220, 38, 38);
}
.status-badge.published {
background: rgba(99, 102, 241, 0.15);
color: rgb(79, 70, 229);
}
.location {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.summary-row {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.yes-count {
font-weight: 600;
color: rgb(22, 163, 74);
}
</style>

View file

@ -0,0 +1,214 @@
<script lang="ts">
import { eventGuestsStore } from '../stores/guests.svelte';
import { useEventGuests } from '../queries';
import type { RsvpStatus } from '../types';
interface Props {
eventId: string;
}
let { eventId }: Props = $props();
const guests = useEventGuests(() => eventId);
let newName = $state('');
let newEmail = $state('');
const RSVP_OPTIONS: { value: RsvpStatus; label: string }[] = [
{ value: 'pending', label: 'Offen' },
{ value: 'yes', label: 'Ja' },
{ value: 'maybe', label: 'Vielleicht' },
{ value: 'no', label: 'Nein' },
];
async function handleAdd(e: SubmitEvent) {
e.preventDefault();
const name = newName.trim();
if (!name) return;
await eventGuestsStore.addGuest({
eventId,
name,
email: newEmail.trim() || null,
});
newName = '';
newEmail = '';
}
</script>
<div class="guest-editor">
<form class="add-row" onsubmit={handleAdd}>
<input type="text" bind:value={newName} placeholder="Name" class="input name-input" required />
<input
type="email"
bind:value={newEmail}
placeholder="E-Mail (optional)"
class="input email-input"
/>
<button type="submit" class="add-btn">Hinzufügen</button>
</form>
<ul class="guest-list">
{#each guests.value ?? [] as guest (guest.id)}
<li class="guest-row">
<div class="guest-info">
<div class="guest-name">{guest.name}</div>
{#if guest.email}
<div class="guest-email">{guest.email}</div>
{/if}
</div>
<div class="guest-controls">
<select
class="rsvp-select"
value={guest.rsvpStatus}
onchange={(e) =>
eventGuestsStore.setRsvp(guest.id, e.currentTarget.value as RsvpStatus)}
>
{#each RSVP_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<label class="plus-ones">
+
<input
type="number"
min="0"
max="20"
value={guest.plusOnes}
onchange={(e) =>
eventGuestsStore.updateGuest(guest.id, {
plusOnes: Number(e.currentTarget.value) || 0,
})}
/>
</label>
<button
class="remove-btn"
onclick={() => eventGuestsStore.deleteGuest(guest.id)}
title="Entfernen"
>
×
</button>
</div>
</li>
{/each}
{#if (guests.value ?? []).length === 0}
<li class="empty">Noch keine Gäste hinzugefügt.</li>
{/if}
</ul>
</div>
<style>
.guest-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.add-row {
display: flex;
gap: 0.5rem;
}
.input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.email-input {
flex: 1.2;
}
.add-btn {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.guest-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.guest-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.guest-info {
flex: 1;
min-width: 0;
}
.guest-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.guest-email {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.guest-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rsvp-select {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.plus-ones {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.plus-ones input {
width: 2.5rem;
padding: 0.25rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
background: hsl(var(--color-background));
font-size: 0.75rem;
text-align: center;
}
.remove-btn {
padding: 0.125rem 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
border-radius: 0.25rem;
}
.remove-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.empty {
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { RsvpSummary } from '../types';
interface Props {
summary: RsvpSummary;
capacity?: number | null;
}
let { summary, capacity = null }: Props = $props();
</script>
<div class="rsvp-summary">
<div class="badge yes">
<span class="count">{summary.yes}</span>
<span class="label">Ja</span>
</div>
<div class="badge maybe">
<span class="count">{summary.maybe}</span>
<span class="label">Vielleicht</span>
</div>
<div class="badge no">
<span class="count">{summary.no}</span>
<span class="label">Nein</span>
</div>
<div class="badge pending">
<span class="count">{summary.pending}</span>
<span class="label">Offen</span>
</div>
<div class="total">
<strong>{summary.totalAttending}</strong>
{#if capacity}
<span class="muted">/ {capacity}</span>
{/if}
<span class="muted">kommen</span>
</div>
</div>
<style>
.rsvp-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.badge {
display: flex;
align-items: baseline;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
font-size: 0.8125rem;
}
.badge .count {
font-weight: 700;
}
.badge.yes {
background: rgba(34, 197, 94, 0.15);
color: rgb(22, 163, 74);
}
.badge.maybe {
background: rgba(245, 158, 11, 0.15);
color: rgb(202, 138, 4);
}
.badge.no {
background: rgba(239, 68, 68, 0.15);
color: rgb(220, 38, 38);
}
.badge.pending {
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.total {
margin-left: auto;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.total .muted {
color: hsl(var(--color-muted-foreground));
margin-left: 0.25rem;
}
</style>

View file

@ -0,0 +1,5 @@
export * from './types';
export * from './collections';
export * from './queries';
export { eventsStore } from './stores/events.svelte';
export { eventGuestsStore } from './stores/guests.svelte';

View file

@ -0,0 +1,164 @@
/**
* Reactive queries & helpers for the events module.
*
* Joins LocalSocialEvent with its TimeBlock to produce the UI-facing SocialEvent.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type {
LocalSocialEvent,
LocalEventGuest,
SocialEvent,
EventGuest,
RsvpSummary,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | null): SocialEvent {
const now = new Date().toISOString();
return {
id: local.id,
title: local.title,
description: local.description ?? null,
location: local.location ?? null,
locationUrl: local.locationUrl ?? null,
hostContactId: local.hostContactId ?? null,
coverImage: local.coverImage ?? null,
color: local.color ?? null,
capacity: local.capacity ?? null,
isPublished: local.isPublished ?? false,
publicToken: local.publicToken ?? null,
status: local.status,
timeBlockId: local.timeBlockId,
startTime: block?.startDate ?? now,
endTime: block?.endDate ?? block?.startDate ?? now,
isAllDay: block?.allDay ?? false,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toEventGuest(local: LocalEventGuest): EventGuest {
const now = new Date().toISOString();
return {
id: local.id,
eventId: local.eventId,
contactId: local.contactId ?? null,
name: local.name,
email: local.email ?? null,
phone: local.phone ?? null,
rsvpStatus: local.rsvpStatus,
rsvpAt: local.rsvpAt ?? null,
plusOnes: local.plusOnes ?? 0,
note: local.note ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Reactive Hooks ────────────────────────────────────────
/** All non-deleted events, joined with their TimeBlock for time fields. */
export function useAllEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt);
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
return active.map((e, i) => toSocialEvent(e, blocks[i] ?? null));
}, [] as SocialEvent[]);
}
/** Upcoming events (startTime >= now), sorted ascending. */
export function useUpcomingEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled');
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
const now = Date.now();
return active
.map((e, i) => toSocialEvent(e, blocks[i] ?? null))
.filter((e) => new Date(e.startTime).getTime() >= now)
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}, [] as SocialEvent[]);
}
/** Past events. */
export function usePastEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt);
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
const now = Date.now();
return active
.map((e, i) => toSocialEvent(e, blocks[i] ?? null))
.filter((e) => new Date(e.startTime).getTime() < now)
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
}, [] as SocialEvent[]);
}
/** Single event by ID. */
export function useEvent(eventId: () => string) {
return useLiveQueryWithDefault(
async () => {
const id = eventId();
if (!id) return null;
const local = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!local || local.deletedAt) return null;
const block = await timeBlockTable.get(local.timeBlockId);
return toSocialEvent(local, block ?? null);
},
null as SocialEvent | null
);
}
/** All guests across all events, grouped by eventId. Useful for list views. */
export function useGuestsByEvent() {
return useLiveQueryWithDefault(
async () => {
const all = await db.table<LocalEventGuest>('eventGuests').toArray();
const map = new Map<string, EventGuest[]>();
for (const g of all) {
if (g.deletedAt) continue;
const guest = toEventGuest(g);
const arr = map.get(guest.eventId);
if (arr) arr.push(guest);
else map.set(guest.eventId, [guest]);
}
return map;
},
new Map() as Map<string, EventGuest[]>
);
}
/** Guests for a single event. */
export function useEventGuests(eventId: () => string) {
return useLiveQueryWithDefault(async () => {
const id = eventId();
if (!id) return [];
const guests = await db
.table<LocalEventGuest>('eventGuests')
.where('eventId')
.equals(id)
.toArray();
return guests.filter((g) => !g.deletedAt).map(toEventGuest);
}, [] as EventGuest[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
export function summarizeRsvps(guests: EventGuest[]): RsvpSummary {
const summary: RsvpSummary = { yes: 0, no: 0, maybe: 0, pending: 0, totalAttending: 0 };
for (const g of guests) {
summary[g.rsvpStatus]++;
if (g.rsvpStatus === 'yes') summary.totalAttending += 1 + (g.plusOnes ?? 0);
}
return summary;
}
export function getEventById(events: SocialEvent[], id: string): SocialEvent | undefined {
return events.find((e) => e.id === id);
}

View file

@ -0,0 +1,68 @@
/**
* Events QuickInputBar Adapter quick-create gatherings.
*
* MVP: very simple parser. The whole query becomes the title; default
* start = today 19:00, end = +2h. Future: parse date phrases like
* "Geburtstag Anna freitag 19 uhr".
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@mana/shared-ui';
import { db } from '$lib/data/database';
import type { LocalSocialEvent } from './types';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
function defaultStart(): Date {
const d = new Date();
d.setHours(19, 0, 0, 0);
return d;
}
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Neues Event oder suchen...',
appIcon: 'events',
deferSearch: true,
createText: 'Erstellen',
emptyText: 'Keine Events gefunden',
async onSearch(query) {
const q = query.toLowerCase();
const events = await db.table<LocalSocialEvent>('socialEvents').toArray();
return events
.filter((e) => !e.deletedAt && e.title?.toLowerCase().includes(q))
.slice(0, 10)
.map((e) => ({
id: e.id,
title: e.title,
subtitle: e.location ?? '',
}));
},
onSelect(item: QuickInputItem) {
window.location.href = `/events/${item.id}`;
},
onParseCreate(query) {
if (!query.trim()) return null;
const start = defaultStart();
return {
title: `"${query.trim()}" anlegen`,
subtitle: `Start: ${format(start, 'EEE, d. MMM, HH:mm', { locale: de })}`,
};
},
async onCreate(query) {
if (!query.trim()) return;
const start = defaultStart();
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
const { eventsStore } = await import('./stores/events.svelte');
await eventsStore.createEvent({
title: query.trim(),
startTime: start.toISOString(),
endTime: end.toISOString(),
});
},
};
}

View file

@ -0,0 +1,188 @@
/**
* Events store mutation-only service.
*
* Creates a TimeBlock + LocalSocialEvent pair so events show up in calendar
* via the universal time view (sourceModule: 'events').
*/
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalSocialEvent, EventStatus } from '../types';
let error = $state<string | null>(null);
export const eventsStore = {
get error() {
return error;
},
async createEvent(input: {
title: string;
description?: string | null;
location?: string | null;
locationUrl?: string | null;
startTime: string;
endTime: string;
isAllDay?: boolean;
hostContactId?: string | null;
coverImage?: string | null;
color?: string | null;
capacity?: number | null;
status?: EventStatus;
}) {
error = null;
try {
const eventId = crypto.randomUUID();
const timeBlockId = await createBlock({
startDate: input.startTime,
endDate: input.endTime,
allDay: input.isAllDay ?? false,
kind: 'scheduled',
type: 'event',
sourceModule: 'events',
sourceId: eventId,
title: input.title,
description: input.description ?? null,
color: input.color ?? null,
});
const newLocal: LocalSocialEvent = {
id: eventId,
timeBlockId,
title: input.title,
description: input.description ?? null,
location: input.location ?? null,
locationUrl: input.locationUrl ?? null,
hostContactId: input.hostContactId ?? null,
coverImage: input.coverImage ?? null,
color: input.color ?? null,
capacity: input.capacity ?? null,
isPublished: false,
publicToken: null,
status: input.status ?? 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalSocialEvent>('socialEvents').add(newLocal);
return { success: true as const, id: eventId };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create event';
return { success: false as const, error };
}
},
async updateEvent(
id: string,
input: {
title?: string;
description?: string | null;
location?: string | null;
locationUrl?: string | null;
startTime?: string;
endTime?: string;
isAllDay?: boolean;
color?: string | null;
capacity?: number | null;
status?: EventStatus;
coverImage?: string | null;
}
) {
error = null;
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event) return { success: false as const, error: 'Event not found' };
const blockUpdates: Record<string, unknown> = {};
if (input.startTime !== undefined) blockUpdates.startDate = input.startTime;
if (input.endTime !== undefined) blockUpdates.endDate = input.endTime;
if (input.isAllDay !== undefined) blockUpdates.allDay = input.isAllDay;
if (input.title !== undefined) blockUpdates.title = input.title;
if (input.description !== undefined) blockUpdates.description = input.description;
if (input.color !== undefined) blockUpdates.color = input.color;
if (Object.keys(blockUpdates).length > 0) {
await updateBlock(event.timeBlockId, blockUpdates);
}
const localData: Partial<LocalSocialEvent> = {
updatedAt: new Date().toISOString(),
};
if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location;
if (input.locationUrl !== undefined) localData.locationUrl = input.locationUrl;
if (input.color !== undefined) localData.color = input.color;
if (input.capacity !== undefined) localData.capacity = input.capacity;
if (input.status !== undefined) localData.status = input.status;
if (input.coverImage !== undefined) localData.coverImage = input.coverImage;
await db.table('socialEvents').update(id, localData);
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update event';
return { success: false as const, error };
}
},
async deleteEvent(id: string) {
error = null;
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (event?.timeBlockId) {
await deleteBlock(event.timeBlockId);
}
await db.table('socialEvents').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete event';
return { success: false as const, error };
}
},
/**
* Local-only "publish" stub for Phase 1a.
* Just flips isPublished + assigns a placeholder token. Phase 1b will
* push the snapshot to mana-events and use a real server-issued token.
*/
async publishEvent(id: string) {
error = null;
try {
const token =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '').slice(0, 24)
: Math.random().toString(36).slice(2, 26);
await db.table('socialEvents').update(id, {
isPublished: true,
publicToken: token,
status: 'published' satisfies EventStatus,
updatedAt: new Date().toISOString(),
});
return { success: true as const, token };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to publish event';
return { success: false as const, error };
}
},
async unpublishEvent(id: string) {
error = null;
try {
await db.table('socialEvents').update(id, {
isPublished: false,
publicToken: null,
status: 'draft' satisfies EventStatus,
updatedAt: new Date().toISOString(),
});
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to unpublish event';
return { success: false as const, error };
}
},
};

View file

@ -0,0 +1,96 @@
/**
* Event guests store mutation-only service for the guest list of an event.
*/
import { db } from '$lib/data/database';
import type { LocalEventGuest, RsvpStatus } from '../types';
let error = $state<string | null>(null);
export const eventGuestsStore = {
get error() {
return error;
},
async addGuest(input: {
eventId: string;
name: string;
email?: string | null;
phone?: string | null;
contactId?: string | null;
rsvpStatus?: RsvpStatus;
plusOnes?: number;
note?: string | null;
}) {
error = null;
try {
const id = crypto.randomUUID();
const newGuest: LocalEventGuest = {
id,
eventId: input.eventId,
contactId: input.contactId ?? null,
name: input.name,
email: input.email ?? null,
phone: input.phone ?? null,
rsvpStatus: input.rsvpStatus ?? 'pending',
rsvpAt: null,
plusOnes: input.plusOnes ?? 0,
note: input.note ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalEventGuest>('eventGuests').add(newGuest);
return { success: true as const, id };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add guest';
return { success: false as const, error };
}
},
async updateGuest(
id: string,
input: Partial<{
name: string;
email: string | null;
phone: string | null;
contactId: string | null;
rsvpStatus: RsvpStatus;
plusOnes: number;
note: string | null;
}>
) {
error = null;
try {
const data: Partial<LocalEventGuest> = {
...input,
updatedAt: new Date().toISOString(),
};
if (input.rsvpStatus !== undefined) {
data.rsvpAt = new Date().toISOString();
}
await db.table('eventGuests').update(id, data);
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update guest';
return { success: false as const, error };
}
},
async setRsvp(id: string, status: RsvpStatus) {
return this.updateGuest(id, { rsvpStatus: status });
},
async deleteGuest(id: string) {
error = null;
try {
await db.table('eventGuests').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete guest';
return { success: false as const, error };
}
},
};

View file

@ -0,0 +1,99 @@
/**
* Events module social gatherings (parties, dinners, workshops).
*
* Distinct from the `calendar` module's events table: these have guest lists,
* RSVPs, and shareable invitation tokens. The time dimension lives on a
* TimeBlock (sourceModule: 'events') so the event surfaces in the calendar.
*/
import type { BaseRecord } from '@mana/local-store';
export type EventStatus = 'draft' | 'published' | 'cancelled' | 'past';
export type RsvpStatus = 'pending' | 'yes' | 'no' | 'maybe';
export type InvitationChannel = 'link' | 'manual';
// ─── Local Records (Dexie) ─────────────────────────────────
export interface LocalSocialEvent extends BaseRecord {
timeBlockId: string;
title: string;
description?: string | null;
location?: string | null;
locationUrl?: string | null;
hostContactId?: string | null;
coverImage?: string | null;
color?: string | null;
capacity?: number | null;
isPublished: boolean;
publicToken?: string | null;
status: EventStatus;
}
export interface LocalEventGuest extends BaseRecord {
eventId: string;
contactId?: string | null;
name: string;
email?: string | null;
phone?: string | null;
rsvpStatus: RsvpStatus;
rsvpAt?: string | null;
plusOnes: number;
note?: string | null;
}
export interface LocalEventInvitation extends BaseRecord {
eventId: string;
guestId: string;
channel: InvitationChannel;
sentAt?: string | null;
openedAt?: string | null;
token: string;
}
// ─── Domain (UI-facing) ────────────────────────────────────
export interface SocialEvent {
id: string;
title: string;
description: string | null;
location: string | null;
locationUrl: string | null;
hostContactId: string | null;
coverImage: string | null;
color: string | null;
capacity: number | null;
isPublished: boolean;
publicToken: string | null;
status: EventStatus;
timeBlockId: string;
startTime: string;
endTime: string;
isAllDay: boolean;
createdAt: string;
updatedAt: string;
}
export interface EventGuest {
id: string;
eventId: string;
contactId: string | null;
name: string;
email: string | null;
phone: string | null;
rsvpStatus: RsvpStatus;
rsvpAt: string | null;
plusOnes: number;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface RsvpSummary {
yes: number;
no: number;
maybe: number;
pending: number;
totalAttending: number; // yes + plusOnes
}

View file

@ -0,0 +1,347 @@
<script lang="ts">
import { useEvent, useEventGuests, summarizeRsvps } from '../queries';
import { eventsStore } from '../stores/events.svelte';
import GuestListEditor from '../components/GuestListEditor.svelte';
import RsvpSummaryView from '../components/RsvpSummary.svelte';
interface Props {
eventId: string;
onBack?: () => void;
}
let { eventId, onBack }: Props = $props();
const eventQuery = useEvent(() => eventId);
const guests = useEventGuests(() => eventId);
const summary = $derived(summarizeRsvps(guests.value ?? []));
const event = $derived(eventQuery.value);
let editing = $state(false);
let titleDraft = $state('');
let descDraft = $state('');
let locationDraft = $state('');
let startDraft = $state('');
let endDraft = $state('');
let allDayDraft = $state(false);
function startEdit() {
if (!event) return;
titleDraft = event.title;
descDraft = event.description ?? '';
locationDraft = event.location ?? '';
startDraft = toLocalDatetime(event.startTime);
endDraft = toLocalDatetime(event.endTime);
allDayDraft = event.isAllDay;
editing = true;
}
async function saveEdit() {
if (!event) return;
await eventsStore.updateEvent(event.id, {
title: titleDraft,
description: descDraft || null,
location: locationDraft || null,
startTime: fromLocalDatetime(startDraft),
endTime: fromLocalDatetime(endDraft),
isAllDay: allDayDraft,
});
editing = false;
}
function toLocalDatetime(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalDatetime(local: string): string {
return new Date(local).toISOString();
}
async function handlePublish() {
if (!event) return;
if (event.isPublished) {
await eventsStore.unpublishEvent(event.id);
} else {
await eventsStore.publishEvent(event.id);
}
}
async function handleDelete() {
if (!event) return;
if (!confirm(`Event "${event.title}" wirklich löschen?`)) return;
await eventsStore.deleteEvent(event.id);
onBack?.();
}
function copyShareLink() {
if (!event?.publicToken) return;
const url = `${window.location.origin}/rsvp/${event.publicToken}`;
navigator.clipboard.writeText(url);
}
</script>
{#if !event}
<div class="loading">Lade Event...</div>
{:else}
<div class="detail">
<header class="detail-header">
{#if onBack}
<button class="back-btn" onclick={onBack}> Zurück</button>
{/if}
<div class="header-actions">
<button class="action-btn" onclick={startEdit}>Bearbeiten</button>
<button class="action-btn danger" onclick={handleDelete}>Löschen</button>
</div>
</header>
{#if editing}
<div class="edit-form">
<input class="title-input" bind:value={titleDraft} placeholder="Event-Titel" />
<textarea class="desc-input" bind:value={descDraft} rows="3" placeholder="Beschreibung"
></textarea>
<input class="loc-input" bind:value={locationDraft} placeholder="Ort" />
<div class="time-row">
<label>
<span>Start</span>
<input type="datetime-local" bind:value={startDraft} />
</label>
<label>
<span>Ende</span>
<input type="datetime-local" bind:value={endDraft} />
</label>
<label class="all-day">
<input type="checkbox" bind:checked={allDayDraft} />
<span>Ganztägig</span>
</label>
</div>
<div class="form-actions">
<button class="action-btn" onclick={() => (editing = false)}>Abbrechen</button>
<button class="action-btn primary" onclick={saveEdit}>Speichern</button>
</div>
</div>
{:else}
<div class="event-meta">
<h1 class="title">{event.title}</h1>
<div class="meta-row">
<span class="when">
{new Date(event.startTime).toLocaleString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: event.isAllDay ? undefined : '2-digit',
minute: event.isAllDay ? undefined : '2-digit',
})}
</span>
{#if event.location}
<span class="where">📍 {event.location}</span>
{/if}
</div>
{#if event.description}
<p class="description">{event.description}</p>
{/if}
</div>
{/if}
<section class="section">
<h2>RSVPs</h2>
<RsvpSummaryView {summary} capacity={event.capacity} />
</section>
<section class="section">
<h2>Gäste</h2>
<GuestListEditor eventId={event.id} />
</section>
<section class="section">
<h2>Teilen</h2>
{#if event.isPublished && event.publicToken}
<div class="share-row">
<code class="share-link">{window.location.origin}/rsvp/{event.publicToken}</code>
<button class="action-btn" onclick={copyShareLink}>Kopieren</button>
<button class="action-btn" onclick={handlePublish}>Privat machen</button>
</div>
<p class="share-hint">
Phase 1a: Link funktioniert noch nicht öffentlich — Gäste-Antworten musst du noch manuell
oben in der Gästeliste eintragen.
</p>
{:else}
<button class="action-btn primary" onclick={handlePublish}>
Event veröffentlichen & Link generieren
</button>
{/if}
</section>
</div>
{/if}
<style>
.detail {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.back-btn {
background: none;
border: none;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.25rem 0;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.action-btn:hover {
background: hsl(var(--color-muted));
}
.action-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.action-btn.danger {
color: rgb(220, 38, 38);
}
.event-meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.title {
margin: 0;
font-size: 1.875rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.description {
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
white-space: pre-wrap;
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section h2 {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.edit-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.title-input,
.desc-input,
.loc-input {
padding: 0.625rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.title-input {
font-size: 1.25rem;
font-weight: 600;
}
.time-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.time-row label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.time-row input[type='datetime-local'] {
padding: 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
}
.all-day {
flex-direction: row !important;
align-items: center;
gap: 0.375rem !important;
padding-bottom: 0.5rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.share-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.share-link {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.8125rem;
font-family: ui-monospace, monospace;
color: hsl(var(--color-foreground));
overflow-x: auto;
white-space: nowrap;
}
.share-hint {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.loading {
padding: 2rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ListView from '$lib/modules/events/ListView.svelte';
function handleOpen(id: string) {
goto(`/events/${id}`);
}
</script>
<ListView onOpenEvent={handleOpen} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import DetailView from '$lib/modules/events/views/DetailView.svelte';
const eventId = $derived($page.params.id ?? '');
function handleBack() {
goto('/events');
}
</script>
<DetailView {eventId} onBack={handleBack} />

View file

@ -145,6 +145,9 @@ export const APP_ICONS = {
arcade: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
events: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ev" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f43f5e"/><stop offset="100%" style="stop-color:#be123c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ev)"/><path d="M22 78l14-44 30 30-44 14z" fill="white"/><path d="M36 34c4-6 12-8 18-4M50 22c4-2 10 0 12 6M62 28c6-2 12 2 12 10" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/><circle cx="74" cy="46" r="2.5" fill="white"/><circle cx="80" cy="58" r="2" fill="white" fill-opacity="0.8"/><circle cx="68" cy="62" r="2" fill="white" fill-opacity="0.7"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'founder',
},
{
id: 'events',
name: 'Events',
description: {
de: 'Veranstaltungen mit Gästeliste',
en: 'Gatherings with guest lists',
},
longDescription: {
de: 'Plane Geburtstage, Dinner und Workshops mit Gästeliste, RSVPs und teilbaren Einladungslinks. Events erscheinen automatisch in deinem Kalender.',
en: 'Plan birthdays, dinners, and workshops with guest lists, RSVPs, and shareable invite links. Events appear automatically in your calendar.',
},
icon: APP_ICONS.events,
color: '#f43f5e',
comingSoon: false,
status: 'development',
requiredTier: 'founder',
},
{
id: 'finance',
name: 'Finance',
@ -795,6 +812,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
dreams: { dev: 'http://localhost:5173/dreams', prod: 'https://mana.how/dreams' },
events: { dev: 'http://localhost:5173/events', prod: 'https://mana.how/events' },
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },