diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 753b6a4bb..3c3035bb2 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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): 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; - 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 = {}; @@ -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, diff --git a/apps/mana/apps/web/src/lib/data/time-blocks/types.ts b/apps/mana/apps/web/src/lib/data/time-blocks/types.ts index d2358387f..bf756d6c4 100644 --- a/apps/mana/apps/web/src/lib/data/time-blocks/types.ts +++ b/apps/mana/apps/web/src/lib/data/time-blocks/types.ts @@ -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) ────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/events/ListView.svelte b/apps/mana/apps/web/src/lib/modules/events/ListView.svelte new file mode 100644 index 000000000..af30c53d6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/ListView.svelte @@ -0,0 +1,214 @@ + + + + Events - Mana + + +
+
+
+

Events

+

+ {(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen +

+
+ +
+ + {#if showCreate} +
+ +
+ + + +
+ +
+ {/if} + +
+

Bevorstehend

+ {#if (upcoming.value ?? []).length === 0} +

Keine bevorstehenden Events. Zeit für eine Party?

+ {:else} +
+ {#each upcoming.value ?? [] as event (event.id)} + {@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])} + open(event)} /> + {/each} +
+ {/if} +
+ + {#if (past.value ?? []).length > 0} +
+

Vergangen

+
+ {#each past.value ?? [] as event (event.id)} + open(event)} /> + {/each} +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/events/collections.ts b/apps/mana/apps/web/src/lib/modules/events/collections.ts new file mode 100644 index 000000000..48ce7401e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/collections.ts @@ -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('socialEvents'); +export const eventGuestTable = db.table('eventGuests'); +export const eventInvitationTable = db.table('eventInvitations'); diff --git a/apps/mana/apps/web/src/lib/modules/events/components/EventCard.svelte b/apps/mana/apps/web/src/lib/modules/events/components/EventCard.svelte new file mode 100644 index 000000000..8bb1a22f5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/components/EventCard.svelte @@ -0,0 +1,153 @@ + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/events/components/GuestListEditor.svelte b/apps/mana/apps/web/src/lib/modules/events/components/GuestListEditor.svelte new file mode 100644 index 000000000..f17f2fb8b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/components/GuestListEditor.svelte @@ -0,0 +1,214 @@ + + +
+
+ + + +
+ +
    + {#each guests.value ?? [] as guest (guest.id)} +
  • +
    +
    {guest.name}
    + {#if guest.email} +
    {guest.email}
    + {/if} +
    + +
    + + + + + +
    +
  • + {/each} + + {#if (guests.value ?? []).length === 0} +
  • Noch keine Gäste hinzugefügt.
  • + {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/events/components/RsvpSummary.svelte b/apps/mana/apps/web/src/lib/modules/events/components/RsvpSummary.svelte new file mode 100644 index 000000000..cb9282175 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/components/RsvpSummary.svelte @@ -0,0 +1,82 @@ + + +
+
+ {summary.yes} + Ja +
+
+ {summary.maybe} + Vielleicht +
+
+ {summary.no} + Nein +
+
+ {summary.pending} + Offen +
+
+ {summary.totalAttending} + {#if capacity} + / {capacity} + {/if} + kommen +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/events/index.ts b/apps/mana/apps/web/src/lib/modules/events/index.ts new file mode 100644 index 000000000..61ee0506b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/events/queries.ts b/apps/mana/apps/web/src/lib/modules/events/queries.ts new file mode 100644 index 000000000..5bc4f493c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/queries.ts @@ -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('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('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('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('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('eventGuests').toArray(); + const map = new Map(); + 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 + ); +} + +/** Guests for a single event. */ +export function useEventGuests(eventId: () => string) { + return useLiveQueryWithDefault(async () => { + const id = eventId(); + if (!id) return []; + const guests = await db + .table('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); +} diff --git a/apps/mana/apps/web/src/lib/modules/events/quick-input-adapter.ts b/apps/mana/apps/web/src/lib/modules/events/quick-input-adapter.ts new file mode 100644 index 000000000..5ea3a4052 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/quick-input-adapter.ts @@ -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('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(), + }); + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts new file mode 100644 index 000000000..05536f1f8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts @@ -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(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('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('socialEvents').get(id); + if (!event) return { success: false as const, error: 'Event not found' }; + + const blockUpdates: Record = {}; + 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 = { + 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('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 }; + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts new file mode 100644 index 000000000..545ca880f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts @@ -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(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('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 = { + ...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 }; + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/events/types.ts b/apps/mana/apps/web/src/lib/modules/events/types.ts new file mode 100644 index 000000000..f968a50d4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/types.ts @@ -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 +} diff --git a/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte new file mode 100644 index 000000000..3a089ca53 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte @@ -0,0 +1,347 @@ + + +{#if !event} +
Lade Event...
+{:else} +
+
+ {#if onBack} + + {/if} +
+ + +
+
+ + {#if editing} +
+ + + +
+ + + +
+
+ + +
+
+ {:else} +
+

{event.title}

+
+ + {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', + })} + + {#if event.location} + 📍 {event.location} + {/if} +
+ {#if event.description} +

{event.description}

+ {/if} +
+ {/if} + +
+

RSVPs

+ +
+ +
+

Gäste

+ +
+ +
+

Teilen

+ {#if event.isPublished && event.publicToken} + + + {:else} + + {/if} +
+
+{/if} + + diff --git a/apps/mana/apps/web/src/routes/(app)/events/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/events/+layout.svelte new file mode 100644 index 000000000..ae9c9d035 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/events/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/events/+page.svelte b/apps/mana/apps/web/src/routes/(app)/events/+page.svelte new file mode 100644 index 000000000..5df10220c --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/events/+page.svelte @@ -0,0 +1,10 @@ + + + diff --git a/apps/mana/apps/web/src/routes/(app)/events/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/events/[id]/+page.svelte new file mode 100644 index 000000000..4b558dbfe --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/events/[id]/+page.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index c5369a7bb..b81802d9c 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -145,6 +145,9 @@ export const APP_ICONS = { arcade: svgToDataUrl( `` ), + events: svgToDataUrl( + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 08c0fd46b..a429029e0 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -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 = { 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' },