mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
dff02d24a9
commit
e0c0791bb5
12 changed files with 223 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue