mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
015a2c18ee
commit
95e85bdffd
5 changed files with 105 additions and 0 deletions
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue