mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
ac44d51363
commit
015a2c18ee
6 changed files with 119 additions and 2 deletions
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ export const EmbedResolvedSchema = z.object({
|
|||
* Supported embed sources. Add new sources here + a matching provider
|
||||
* in the editor's publish resolver.
|
||||
*/
|
||||
export const EmbedSourceSchema = z.enum(['picture.board', 'library.entries', 'calendar.events']);
|
||||
export const EmbedSourceSchema = z.enum([
|
||||
'picture.board',
|
||||
'library.entries',
|
||||
'calendar.events',
|
||||
'todo.tasks',
|
||||
]);
|
||||
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
|
||||
|
||||
export const ModuleEmbedSchema = z.object({
|
||||
|
|
@ -42,8 +47,11 @@ export const ModuleEmbedSchema = z.object({
|
|||
* library.entries: { isFavorite?, status?, kind? }
|
||||
* picture.board: ignored (board is the source)
|
||||
* calendar.events: { upcomingDays?, tagIds? } — omit upcomingDays
|
||||
* to include past events; tagIds AND-filter on
|
||||
* to include past events; tagIds OR-filter on
|
||||
* event tag assignments
|
||||
* todo.tasks: { status?, tagIds? } — typical public-roadmap
|
||||
* shape: status='completed' filters to shipped
|
||||
* items; tagIds restricts to a "public" label
|
||||
*/
|
||||
filter: z
|
||||
.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue