From 015a2c18ee98679b5717da39932f28ff6eb601be Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 02:37:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(todo):=20M4.b=20=E2=80=94=20tasks=20adopt?= =?UTF-8?q?=20the=20unified=20visibility=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth consumer of @mana/shared-privacy. Tasks now carry a VisibilityLevel flipped via in the Todo DetailView; a new todo.tasks embed source powers the "public roadmap" use-case (mark a handful of tasks public, drop the embed on the Website). Changes: - todo/types: visibility + unlistedToken + visibilityChangedAt + visibilityChangedBy on LocalTask; Task (UI type) requires visibility - todo/queries: toTask forwards visibility with 'space' fallback for legacy rows (pre-M4.b records have no field set; Dexie hook stamped 'space' since spaces-foundation v28) - todo/stores/tasks: createTask stamps defaultVisibilityFor(activeSpace.type); new setVisibility(id, level) mints/clears the unlisted token on the transition boundary and emits cross-module VisibilityChanged - todo/views/DetailView: dropped in as the first prop-row above Priorität so the user sees exposure state at a glance whenever they open a task website embed: - website-blocks/moduleEmbed/schema: 'todo.tasks' added to EmbedSourceSchema; filter docstring explains the todo-specific shape (status + tagIds for the typical "shipped items with #public" filter) - website/embeds: resolveTodoTasks gates hard on canEmbedOnWebsite, maps the optional status filter ('completed' → isCompleted=true), joins the N:N taskTags table for the optional tagIds filter, sorts newest-first with id as stable tiebreaker. Inlined EmbedItem is whitelist-only — title + status label ('Erledigt' / 'In Arbeit'). Description, subtasks, LLM-labels, due-dates, and project memberships stay out of the public snapshot (per plan §2 redaction policy) Verified: - pnpm check (web): 7450 files, 0 errors - pnpm test todo + website: 38/38 Next: M4.c — Goals. Lives under $lib/companion/goals/ (not in the standard /modules/ tree), so the adoption path is slightly different and gets its own commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/modules/todo/queries.ts | 1 + .../lib/modules/todo/stores/tasks.svelte.ts | 42 ++++++++++++++++ .../apps/web/src/lib/modules/todo/types.ts | 6 +++ .../lib/modules/todo/views/DetailView.svelte | 10 ++++ .../web/src/lib/modules/website/embeds.ts | 50 +++++++++++++++++++ .../website-blocks/src/moduleEmbed/schema.ts | 12 ++++- 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/todo/queries.ts b/apps/mana/apps/web/src/lib/modules/todo/queries.ts index b5254cb1f..ebaaaf45d 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/queries.ts @@ -36,6 +36,7 @@ export function toTask(local: LocalTask): Task { subtasks: local.subtasks ?? null, transcriptModel: local.transcriptModel ?? null, metadata: local.metadata ?? null, + 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/todo/stores/tasks.svelte.ts b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts index c97944859..a8fd362d3 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts @@ -11,6 +11,13 @@ import type { LocalTask, TaskPriority, Subtask } from '../types'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; 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, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import { transcribeAudio } from '$lib/voice/transcribe'; import { TodoEvents } from '@mana/shared-utils/analytics'; import { tagCollection, type LocalTag } from '@mana/shared-stores'; @@ -142,6 +149,7 @@ export const tasksStore = { order: count, subtasks: data.subtasks, metadata: data.labelIds && data.labelIds.length > 0 ? { labelIds: data.labelIds } : undefined, + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; if (data.projectId !== undefined) { @@ -468,4 +476,38 @@ export const tasksStore = { }); } }, + + /** + * Flip a task's visibility. The typical use-case is "public roadmap": + * a user marks selected tasks 'public' so they appear in the + * todo.tasks embed on their website. Emits cross-module + * VisibilityChanged. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await taskTable.get(id); + if (!existing) throw new Error(`Task ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await taskTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'todo', 'tasks', id, { + recordId: id, + collection: 'tasks', + before, + after: next, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/todo/types.ts b/apps/mana/apps/web/src/lib/modules/todo/types.ts index 84fae210a..a44c0457c 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/types.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/types.ts @@ -4,6 +4,7 @@ import type { BaseRecord } from '@mana/local-store'; import type { Tag } from '@mana/shared-tags'; +import type { VisibilityLevel } from '@mana/shared-privacy'; /** * A tag attached to a task. Structurally identical to the shared `Tag` @@ -41,6 +42,10 @@ export interface LocalTask extends BaseRecord { /** STT backend/model identifier (e.g. "whisperx-large-v3"). Set when task created via voice. */ transcriptModel?: string | null; metadata?: Record; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; } export interface LocalTaskTag extends BaseRecord { @@ -119,6 +124,7 @@ export interface Task { subtasks?: Subtask[] | null; transcriptModel: string | null; metadata?: Record | null; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/todo/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/todo/views/DetailView.svelte index 61153f1c0..5aa7126e7 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/todo/views/DetailView.svelte @@ -11,6 +11,7 @@ import { getBlock, decryptBlock } from '$lib/data/time-blocks/service'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import { Check, X, CalendarBlank } from '@mana/shared-icons'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte'; import type { ViewProps } from '$lib/app-registry'; import type { LocalTask, TaskPriority } from '../types'; @@ -106,6 +107,10 @@ await saveField(); } + async function handleVisibilityChange(next: VisibilityLevel) { + await tasksStore.setVisibility(taskId, next); + } + async function handlePriorityChange() { await tasksStore.updateTask(taskId, { priority: editPriority }); } @@ -181,6 +186,11 @@
+
+ Sichtbarkeit + +
+
Priorität