feat(goals): M4.c — goals adopt the unified visibility system

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: <VisibilityPicker compact> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 02:41:27 +02:00
parent 015a2c18ee
commit 95e85bdffd
5 changed files with 105 additions and 0 deletions

View file

@ -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<void> {
const existing = await db.table<LocalGoal>(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<LocalGoal> = {
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,
});
},
};

View file

@ -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;

View file

@ -3,6 +3,7 @@
-->
<script lang="ts">
import { Target, Plus, Play, Pause, Trash, PencilSimple } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { goalStore, useAllGoals, GOAL_TEMPLATES } from '$lib/companion/goals';
import type { LocalGoal } from '$lib/companion/goals/types';
import GoalEditor from './GoalEditor.svelte';
@ -25,6 +26,10 @@
if (tpl) await goalStore.createFromTemplate(tpl);
showTemplates = false;
}
async function handleVisibilityChange(goalId: string, next: VisibilityLevel) {
await goalStore.setVisibility(goalId, next);
}
</script>
<div class="goals-page">
@ -55,6 +60,11 @@
<div class="goal-header">
<Target size={16} weight="bold" />
<span class="goal-title">{goal.title}</span>
<VisibilityPicker
level={goal.visibility ?? 'private'}
onChange={(next) => handleVisibilityChange(goal.id, next)}
compact
/>
<button class="goal-action" onclick={() => goalStore.pause(goal.id)} title="Pausieren">
<Pause size={12} />
</button>

View file

@ -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<ResolvedEmb
case 'todo.tasks':
items = await resolveTodoTasks(props);
break;
case 'goals.goals':
items = await resolveGoals(props);
break;
default:
return {
items: [],
@ -310,3 +314,41 @@ async function resolveTodoTasks(props: ModuleEmbedProps): Promise<EmbedItem[]> {
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<EmbedItem[]> {
let goals = await db.table<LocalGoal>('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}`;
}

View file

@ -31,6 +31,7 @@ export const EmbedSourceSchema = z.enum([
'library.entries',
'calendar.events',
'todo.tasks',
'goals.goals',
]);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
@ -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({