feat(visibility): pilot extended to habits + quiz + events (M5 finish)

Extends the unified visibility system (@mana/shared-privacy) to three
of the four remaining open modules from the M5 rollout list. Each
module now exposes a private/space/public picker — `unlisted` is
hidden via `disabledLevels={['unlisted']}` because none of the three
have a server-publish-snapshot path yet (M8 territory).

Per-module:
- habits: visibility on LocalHabit + Habit; defaultVisibilityFor on
  createHabit; setVisibility emits VisibilityChanged. Picker in
  HabitDetail right under the header.
- quiz: same pattern on LocalQuiz + Quiz; Picker in EditView meta
  section so quiz-authors flip visibility while editing metadata.
- events (socialEvents): visibility coexists with the legacy
  `isPublished` + `publicToken` flags until M6 consolidation. The
  Picker writes the unified field; publish/unpublish still drives the
  RSVP snapshot. Picker as its own section above RSVPs.

Invoices skipped — `invoiceClients` has no write path yet (the
ClientPicker only reads), and the Invoice document itself is too
sensitive to ever go public. Will land alongside the future
client-portal feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 13:46:50 +02:00
parent dff02d24a9
commit e0c0791bb5
12 changed files with 223 additions and 0 deletions

View file

@ -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,

View file

@ -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<LocalSocialEvent>('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.

View file

@ -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;

View file

@ -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);
}
</script>
{#if !event}
@ -261,6 +267,17 @@
</div>
{/if}
<section class="section">
<div class="visibility-row">
<span class="visibility-label">Sichtbarkeit</span>
<VisibilityPicker
level={event.visibility ?? 'space'}
onChange={handleVisibilityChange}
disabledLevels={['unlisted']}
/>
</div>
</section>
<section class="section">
<h2>RSVPs</h2>
<RsvpSummaryView {summary} capacity={event.capacity} />
@ -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;

View file

@ -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);
}
</script>
<div class="habit-detail">
@ -104,6 +109,16 @@
<HabitForm {habit} onDone={() => (showEdit = false)} onCancel={() => (showEdit = false)} />
{/if}
<!-- Visibility -->
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={habit.visibility ?? 'space'}
onChange={handleVisibilityChange}
disabledLevels={['unlisted']}
/>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
@ -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;

View file

@ -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(),
};

View file

@ -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, {

View file

@ -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;
}

View file

@ -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<string | null>(null);
let newType = $state<QuestionType>('single');
@ -223,6 +229,14 @@
placeholder="Tags (Komma-getrennt)"
/>
</div>
<div class="visibility-row">
<span class="visibility-label">Sichtbarkeit</span>
<VisibilityPicker
level={quiz.visibility ?? 'space'}
onChange={handleVisibilityChange}
disabledLevels={['unlisted']}
/>
</div>
</section>
<section class="questions-section">
@ -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;
}

View file

@ -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(),
};

View file

@ -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(

View file

@ -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;
}