diff --git a/apps/mana/apps/web/src/lib/modules/events/queries.ts b/apps/mana/apps/web/src/lib/modules/events/queries.ts index 07e11a2a7..ccddbc8c3 100644 --- a/apps/mana/apps/web/src/lib/modules/events/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/events/queries.ts @@ -39,6 +39,9 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n isPublished: local.isPublished ?? false, publicToken: local.publicToken ?? null, status: local.status, + // Coexists with isPublished until M6 consolidation. Default + // 'space' for legacy rows; the Picker writes the unified field. + visibility: local.visibility ?? 'space', timeBlockId: local.timeBlockId, startTime: block?.startDate ?? now, endTime: block?.endDate ?? block?.startDate ?? now, 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 index 40797bbe3..db207abb5 100644 --- 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 @@ -10,6 +10,9 @@ import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/ser import { timeBlockTable } from '$lib/data/time-blocks/collections'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy'; import type { LocalSocialEvent, LocalEventItem, EventStatus } from '../types'; import { eventsApi } from '../api'; import { recordTombstone } from '../tombstones'; @@ -66,6 +69,7 @@ export const eventsStore = { isPublished: false, publicToken: null, status: input.status ?? 'draft', + visibility: defaultVisibilityFor(getActiveSpace()?.type), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -176,6 +180,36 @@ export const eventsStore = { } }, + /** + * Flip a social-event's visibility. Coexists with the legacy + * `isPublished`/`publicToken` flow until M6 (Konsolidierung): + * publishEvent/unpublishEvent still drive the public RSVP snapshot; + * this Picker only writes the unified `visibility` field. v1 supports + * private/space/public — unlisted is reserved for the future when + * the share-snapshot flow gets ported here. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await db.table('socialEvents').get(id); + if (!existing) throw new Error(`Event ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + await db.table('socialEvents').update(id, { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }); + + emitDomainEvent('VisibilityChanged', 'events', 'socialEvents', id, { + recordId: id, + collection: 'socialEvents', + before, + after: next, + }); + }, + /** * Publish event — pushes a snapshot to mana-events and stores the * server-issued token locally. Public RSVP page will read the snapshot. diff --git a/apps/mana/apps/web/src/lib/modules/events/types.ts b/apps/mana/apps/web/src/lib/modules/events/types.ts index 86b867ee2..6f700b51e 100644 --- a/apps/mana/apps/web/src/lib/modules/events/types.ts +++ b/apps/mana/apps/web/src/lib/modules/events/types.ts @@ -7,6 +7,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export type EventStatus = 'draft' | 'published' | 'cancelled' | 'past'; @@ -33,6 +34,16 @@ export interface LocalSocialEvent extends BaseRecord { isPublished: boolean; publicToken?: string | null; status: EventStatus; + /** + * Unified visibility (private/space/unlisted/public). Lives alongside + * the legacy `isPublished` + `publicToken` flags until M6 + * (Konsolidierung der Legacy-Flags). Until then, treat both: the + * embed/public surface still keys off `isPublished`, but the + * Picker writes to `visibility` so the unified system can take over. + */ + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; } export interface LocalEventGuest extends BaseRecord { @@ -86,6 +97,7 @@ export interface SocialEvent { isPublished: boolean; publicToken: string | null; status: EventStatus; + visibility: VisibilityLevel; timeBlockId: string; startTime: string; endTime: string; 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 index e2dc5e331..2d0c851c9 100644 --- a/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte @@ -9,6 +9,7 @@ import type { ViewProps } from '$lib/app-registry'; import { searchAddress, formatAddress, type GeocodingResult } from '$lib/geocoding'; import { MapPin } from '@mana/shared-icons'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; let { navigate, goBack, params }: ViewProps = $props(); @@ -145,6 +146,11 @@ const url = `${window.location.origin}/rsvp/${event.publicToken}`; navigator.clipboard.writeText(url); } + + async function handleVisibilityChange(next: VisibilityLevel) { + if (!event) return; + await eventsStore.setVisibility(event.id, next); + } {#if !event} @@ -261,6 +267,17 @@ {/if} +
+
+ Sichtbarkeit + +
+
+

RSVPs

@@ -379,6 +396,17 @@ flex-direction: column; gap: 0.75rem; } + .visibility-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .visibility-label { + font-size: 0.875rem; + font-weight: 500; + opacity: 0.85; + } .section h2 { margin: 0; font-size: 0.75rem; diff --git a/apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte b/apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte index af3bd96a6..7cb4d4b2e 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte +++ b/apps/mana/apps/web/src/lib/modules/habits/components/HabitDetail.svelte @@ -10,6 +10,7 @@ import HabitForm from './HabitForm.svelte'; import { DynamicIcon } from '@mana/shared-ui/atoms'; import { CaretLeft, PencilSimple, X } from '@mana/shared-icons'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; let { habit, @@ -80,6 +81,10 @@ async function handleDeleteLog(logId: string) { await habitsStore.deleteLog(logId); } + + async function handleVisibilityChange(next: VisibilityLevel) { + await habitsStore.setVisibility(habit.id, next); + }
@@ -104,6 +109,16 @@ (showEdit = false)} onCancel={() => (showEdit = false)} /> {/if} + +
+ Sichtbarkeit + +
+
@@ -201,6 +216,22 @@ gap: 0.75rem; } + .prop-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 0.5rem; + font-size: 0.875rem; + } + .prop-label { + font-weight: 500; + opacity: 0.8; + } + .back-btn, .edit-btn { display: flex; diff --git a/apps/mana/apps/web/src/lib/modules/habits/queries.ts b/apps/mana/apps/web/src/lib/modules/habits/queries.ts index 0b6e772a8..5e5df9a8d 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/habits/queries.ts @@ -23,6 +23,10 @@ export function toHabit(local: LocalHabit): Habit { schedule: local.schedule ?? null, order: local.order, isArchived: local.isArchived, + // Legacy rows pre-dating the visibility pilot default to 'space' + // (the structural default). New rows get the space-type-aware + // default at create time in habits.svelte.ts. + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts index 8582de7c7..33873aaa1 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts @@ -6,6 +6,9 @@ */ import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy'; import { habitTable, habitLogTable } from '../collections'; import { toHabit } from '../queries'; import { @@ -99,6 +102,9 @@ export const habitsStore = { defaultDuration: data.defaultDuration ?? null, order: count, isArchived: false, + // Pre-populate visibility so the Dexie hook's generic 'space' + // fallback doesn't fire for personal-space habits. + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; await habitTable.add(newLocal); @@ -124,6 +130,35 @@ export const habitsStore = { }); }, + /** + * Flip a habit's visibility. v1 supports private/space/public + * only — the unlisted-share flow is not wired for habits because + * a per-day-tracker snapshot would be more confusing than useful. + * Public habits will surface in the owner's website embed when the + * habits resolver lands. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await habitTable.get(id); + if (!existing) throw new Error(`Habit ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + await habitTable.update(id, { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }); + + emitDomainEvent('VisibilityChanged', 'habits', 'habits', id, { + recordId: id, + collection: 'habits', + before, + after: next, + }); + }, + async deleteHabit(id: string) { const habit = await habitTable.get(id); await habitTable.update(id, { diff --git a/apps/mana/apps/web/src/lib/modules/habits/types.ts b/apps/mana/apps/web/src/lib/modules/habits/types.ts index b6d3fdd47..05fabd8d6 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/types.ts +++ b/apps/mana/apps/web/src/lib/modules/habits/types.ts @@ -6,6 +6,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; // ─── Local Record Types (Dexie) ─────────────────────────── @@ -23,6 +24,15 @@ export interface LocalHabit extends BaseRecord { schedule?: HabitSchedule | null; // optional recurring schedule order: number; isArchived: boolean; + /** + * Visibility level — pilot of the unified privacy system. Optional + * on the local record because legacy rows pre-date the field; the + * Dexie hook stamps 'space' as the structural default. `toHabit` + * narrows to a non-optional VisibilityLevel for callers. + */ + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; } export interface LocalHabitLog extends BaseRecord { @@ -43,6 +53,7 @@ export interface Habit { schedule: HabitSchedule | null; order: number; isArchived: boolean; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte b/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte index b1e74e94e..a53145d6a 100644 --- a/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte +++ b/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte @@ -9,6 +9,7 @@ import { QUESTION_TYPE_LABELS } from './types'; import type { QuestionType, QuestionOption, QuizQuestion } from './types'; import { ArrowLeft, Plus, Trash, Check, Play, PencilSimple, X } from '@mana/shared-icons'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; interface Props { quizId: string; @@ -51,6 +52,11 @@ }); } + async function handleVisibilityChange(next: VisibilityLevel) { + if (!quiz) return; + await quizzesStore.setVisibility(quiz.id, next); + } + // ── Question form (new OR edit) ───────────────────── let editingId = $state(null); let newType = $state('single'); @@ -223,6 +229,14 @@ placeholder="Tags (Komma-getrennt)" />
+
+ Sichtbarkeit + +
@@ -450,6 +464,18 @@ display: flex; gap: 0.5rem; } + .visibility-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.25rem; + } + .visibility-label { + font-size: 0.8125rem; + font-weight: 500; + opacity: 0.8; + } .small-input { flex: 1; } diff --git a/apps/mana/apps/web/src/lib/modules/quiz/queries.ts b/apps/mana/apps/web/src/lib/modules/quiz/queries.ts index d3fdd332e..b8eee02d8 100644 --- a/apps/mana/apps/web/src/lib/modules/quiz/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/quiz/queries.ts @@ -34,6 +34,7 @@ export function toQuiz(local: LocalQuiz): Quiz { questionCount: local.questionCount ?? 0, isPinned: local.isPinned ?? false, isArchived: local.isArchived ?? false, + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts b/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts index fc4aad42a..d12e7efbc 100644 --- a/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts @@ -8,6 +8,10 @@ import { quizTable, quizQuestionTable } from '../collections'; import { toQuiz } from '../queries'; import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy'; import type { LocalQuiz, LocalQuizQuestion, Quiz, QuestionOption, QuestionType } from '../types'; function now() { @@ -30,6 +34,7 @@ export const quizzesStore = { questionCount: 0, isPinned: false, isArchived: false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const snapshot = toQuiz(newLocal); await encryptRecord('quizzes', newLocal); @@ -62,6 +67,33 @@ export const quizzesStore = { await quizTable.update(id, { isPinned: !quiz.isPinned, updatedAt: now() }); }, + /** + * Flip a quiz's visibility. v1 supports private/space/public only — + * unlisted-share for quizzes is a candidate for a future milestone + * (share a single quiz with a friend) but not wired yet. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await quizTable.get(id); + if (!existing) throw new Error(`Quiz ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const stamp = now(); + await quizTable.update(id, { + visibility: next, + visibilityChangedAt: stamp, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: stamp, + }); + + emitDomainEvent('VisibilityChanged', 'quiz', 'quizzes', id, { + recordId: id, + collection: 'quizzes', + before, + after: next, + }); + }, + // ── Questions ────────────────────────────────────────── async addQuestion( diff --git a/apps/mana/apps/web/src/lib/modules/quiz/types.ts b/apps/mana/apps/web/src/lib/modules/quiz/types.ts index a58acb0a0..cb41992c5 100644 --- a/apps/mana/apps/web/src/lib/modules/quiz/types.ts +++ b/apps/mana/apps/web/src/lib/modules/quiz/types.ts @@ -6,6 +6,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export type QuestionType = 'single' | 'multi' | 'truefalse' | 'text'; @@ -25,6 +26,10 @@ export interface LocalQuiz extends BaseRecord { questionCount: number; isPinned: boolean; isArchived: boolean; + /** Visibility level — pilot of the unified privacy system. */ + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; } export interface LocalQuizQuestion extends BaseRecord { @@ -64,6 +69,7 @@ export interface Quiz { questionCount: number; isPinned: boolean; isArchived: boolean; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; }