feat(todo): M4.b — tasks adopt the unified visibility system

Fourth consumer of @mana/shared-privacy. Tasks now carry a
VisibilityLevel flipped via <VisibilityPicker> 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: <VisibilityPicker> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 02:37:32 +02:00
parent ac44d51363
commit 015a2c18ee
6 changed files with 119 additions and 2 deletions

View file

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

View file

@ -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<LocalTask> = {
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,
});
},
};

View file

@ -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<string, unknown>;
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<string, unknown> | null;
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
}

View file

@ -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 @@
</div>
<div class="properties">
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker level={task.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
<div class="prop-row">
<span class="prop-label">Priorität</span>
<select

View file

@ -23,6 +23,8 @@ import type { EmbedItem, EmbedSource, ModuleEmbedProps } from '@mana/website-blo
import type { LocalBoard, LocalBoardItem, LocalImage } from '$lib/modules/picture/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalTaskTag } from '$lib/modules/todo/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export interface ResolvedEmbed {
@ -46,6 +48,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'calendar.events':
items = await resolveCalendarEvents(props);
break;
case 'todo.tasks':
items = await resolveTodoTasks(props);
break;
default:
return {
items: [],
@ -260,3 +265,48 @@ function formatEventSubtitle(
const locPart = loc ? ` · ${loc}` : '';
return `${dateParts}${timePart}${locPart}`;
}
/**
* Todo-tasks: public-roadmap use case. Returns tasks flipped to
* 'public' via the VisibilityPicker on the Todo DetailView. Filters
* (status, tagIds) are optional and stack on top of the hard gate.
*
* Whitelist (plan §2): only title and a compact status label land in
* the snapshot. Description, subtasks, LLM-labels, dueDate, and
* project-membership stay out they frequently carry private context
* the user didn't intend to publish by flipping a single flag.
*
* `tagIds` filter: tasks are tagged through the N:N `taskTags` table;
* the resolver joins tag assignments inline rather than asking each
* task to carry a denormalised tagIds array.
*/
async function resolveTodoTasks(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let tasks = await db.table<LocalTask>('tasks').toArray();
tasks = tasks.filter((t) => !t.deletedAt && canEmbedOnWebsite(t.visibility ?? 'private'));
if (props.filter?.status) {
const wantCompleted = props.filter.status === 'completed';
tasks = tasks.filter((t) => t.isCompleted === wantCompleted);
}
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
const taskTags = await db.table<LocalTaskTag>('taskTags').toArray();
const hitTaskIds = new Set(
taskTags.filter((tt) => wanted.has(tt.tagId)).map((tt) => tt.taskId)
);
tasks = tasks.filter((t) => hitTaskIds.has(t.id));
}
const decrypted = (await decryptRecords('tasks', tasks)) as LocalTask[];
// Newest public-items first (by updatedAt); id as stable tiebreaker.
decrypted.sort(
(a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || a.id.localeCompare(b.id)
);
return decrypted.map((t) => ({
title: t.title,
subtitle: t.isCompleted ? 'Erledigt' : 'In Arbeit',
}));
}