From 95e85bdffd91f88acef0b8c8a341a6936ba92306 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 02:41:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(goals):=20M4.c=20=E2=80=94=20goals=20adopt?= =?UTF-8?q?=20the=20unified=20visibility=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth consumer of @mana/shared-privacy, completing the M4 trio (Calendar + Todo + Goals). Goals live under $lib/companion/goals/ (legacy path, pre-rename to 'ai') instead of the standard /modules/ tree, so the adoption lands in its own commit. Enables the "public progress page" use case — a fitness / learning / build-in-public goal with its current-period progress inlined on the owner's website, rendered as "4 / 5 · Woche". Changes: - companion/goals/types: visibility + unlistedToken + visibilityChangedAt + visibilityChangedBy on LocalGoal (LocalGoal doubles as the UI type here, no separate plaintext variant) - companion/goals/store: createFromTemplate and create both stamp defaultVisibilityFor(activeSpace.type) at insert; new setVisibility(id, level) mints/clears the unlisted token on the transition boundary and emits cross-module VisibilityChanged - modules/goals/ListView: on each active goal card, sitting between the title and the pause button (goals have no dedicated detail view — list-inline is the natural spot) website embed: - website-blocks/moduleEmbed/schema: 'goals.goals' added to EmbedSourceSchema; filter docstring describes the active-vs- completed split that power users can use to section their progress page - website/embeds: resolveGoals gates hard on canEmbedOnWebsite, filters by optional status ('active' | 'completed' | 'paused' | 'abandoned'), sorts active-first then by target descending so milestone goals land on top. Inlined EmbedItem is whitelist-only — title + compact progress line like "4 / 5 · Woche". Description, metric configuration (event types, filter fields), and internal tracking state stay out of the snapshot; the goal's implementation detail leaks what the user is measuring, not just the milestone Verified: - pnpm check (web): 7450 files, 0 errors - pnpm test goals + website: 29/29 - pnpm run validate:all green M4 is done. Next: M5 — Places + Events + Recipes + Habits + Quiz + Wardrobe + Invoices-Clients. Same pattern, one module at a time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/companion/goals/store.ts | 42 +++++++++++++++++++ .../apps/web/src/lib/companion/goals/types.ts | 7 ++++ .../web/src/lib/modules/goals/ListView.svelte | 10 +++++ .../web/src/lib/modules/website/embeds.ts | 42 +++++++++++++++++++ .../website-blocks/src/moduleEmbed/schema.ts | 4 ++ 5 files changed, 105 insertions(+) diff --git a/apps/mana/apps/web/src/lib/companion/goals/store.ts b/apps/mana/apps/web/src/lib/companion/goals/store.ts index 16d03d8eb..976c3f735 100644 --- a/apps/mana/apps/web/src/lib/companion/goals/store.ts +++ b/apps/mana/apps/web/src/lib/companion/goals/store.ts @@ -9,6 +9,13 @@ import { db } from '$lib/data/database'; import { eventBus } from '$lib/data/events/event-bus'; import { emitDomainEvent } from '$lib/data/events/emit'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import type { DomainEvent } from '$lib/data/events/types'; import type { LocalGoal, GoalTemplate } from './types'; @@ -108,6 +115,7 @@ export const goalStore = { status: 'active', currentValue: 0, currentPeriodStart: periodStart(template.target.period), + visibility: defaultVisibilityFor(getActiveSpace()?.type), createdAt: now, updatedAt: now, }; @@ -133,6 +141,7 @@ export const goalStore = { status: 'active', currentValue: 0, currentPeriodStart: periodStart(input.target.period), + visibility: defaultVisibilityFor(getActiveSpace()?.type), createdAt: now, updatedAt: now, }; @@ -174,4 +183,37 @@ export const goalStore = { updatedAt: new Date().toISOString(), }); }, + + /** + * Flip a goal's visibility. Enables the "public progress page" use + * case — user marks a fitness/learning goal 'public' so it appears + * in the goals.goals embed on their website. + */ + async setVisibility(id: string, next: VisibilityLevel): Promise { + const existing = await db.table(TABLE).get(id); + if (!existing) throw new Error(`Goal ${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 db.table(TABLE).update(id, patch); + + emitDomainEvent('VisibilityChanged', 'goals', TABLE, id, { + recordId: id, + collection: TABLE, + before, + after: next, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/companion/goals/types.ts b/apps/mana/apps/web/src/lib/companion/goals/types.ts index a79c61f72..9ce7025a5 100644 --- a/apps/mana/apps/web/src/lib/companion/goals/types.ts +++ b/apps/mana/apps/web/src/lib/companion/goals/types.ts @@ -5,6 +5,8 @@ * Wasser/Tag"). Progress is tracked by subscribing to domain events. */ +import type { VisibilityLevel } from '@mana/shared-privacy'; + export interface LocalGoal { id: string; title: string; @@ -24,6 +26,11 @@ export interface LocalGoal { currentValue: number; currentPeriodStart: string; // ISO date + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; + createdAt: string; updatedAt: string; deletedAt?: string; diff --git a/apps/mana/apps/web/src/lib/modules/goals/ListView.svelte b/apps/mana/apps/web/src/lib/modules/goals/ListView.svelte index c4dfc4067..da586a73e 100644 --- a/apps/mana/apps/web/src/lib/modules/goals/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/goals/ListView.svelte @@ -3,6 +3,7 @@ -->
@@ -55,6 +60,11 @@
{goal.title} + handleVisibilityChange(goal.id, next)} + compact + /> diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index 3045a2d76..e1afcb7bb 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -25,6 +25,7 @@ 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 { LocalGoal } from '$lib/companion/goals/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export interface ResolvedEmbed { @@ -51,6 +52,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { subtitle: t.isCompleted ? 'Erledigt' : 'In Arbeit', })); } + +/** + * Goals: the "public progress page" use case — marked-public goals with + * their current-period progress inlined so a visitor can see "4/5 + * Workouts diese Woche" at a glance. Gates hard on canEmbedOnWebsite. + * + * The inlined snapshot carries only title + formatted progress + * ("currentValue / target period"). Description is dropped — users + * often keep it as an internal "why this matters" note. Metric + * configuration (which event type, filter fields) also stays private — + * it leaks implementation detail of what the user tracks. + */ +async function resolveGoals(props: ModuleEmbedProps): Promise { + let goals = await db.table('companionGoals').toArray(); + goals = goals.filter((g) => !g.deletedAt && canEmbedOnWebsite(g.visibility ?? 'private')); + + if (props.filter?.status) { + goals = goals.filter((g) => g.status === props.filter?.status); + } + + // Active goals first, then by target value descending so the + // chunkier milestones land at the top. + goals.sort((a, b) => { + if (a.status !== b.status) return a.status === 'active' ? -1 : 1; + return b.target.value - a.target.value || a.id.localeCompare(b.id); + }); + + return goals.map((g) => ({ + title: g.title, + subtitle: formatGoalProgress(g), + })); +} + +function formatGoalProgress(g: LocalGoal): string { + const periodLabel = + g.target.period === 'day' ? 'Tag' : g.target.period === 'week' ? 'Woche' : 'Monat'; + return `${g.currentValue} / ${g.target.value} · ${periodLabel}`; +} diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index f7e47dd7c..ae5e1b3d6 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -31,6 +31,7 @@ export const EmbedSourceSchema = z.enum([ 'library.entries', 'calendar.events', 'todo.tasks', + 'goals.goals', ]); export type EmbedSource = z.infer; @@ -52,6 +53,9 @@ export const ModuleEmbedSchema = z.object({ * todo.tasks: { status?, tagIds? } — typical public-roadmap * shape: status='completed' filters to shipped * items; tagIds restricts to a "public" label + * goals.goals: { status? } — 'active' | 'completed' filter; + * useful for "currently working on" vs "things + * I've hit" progress sections */ filter: z .object({