feat(places): M5.a — places adopt the unified visibility system

Sixth consumer of @mana/shared-privacy. Places now carry a VisibilityLevel
flipped via <VisibilityPicker> in the Places DetailView; the new
places.places embed powers "my favourite cafes" / "rehearsal rooms" /
"gyms I train at" sections on the owner's website.

Changes:
- places/types: visibility + unlistedToken + visibilityChangedAt +
  visibilityChangedBy on LocalPlace; Place (UI type) requires visibility
- places/queries: toPlace forwards visibility with 'space' fallback for
  legacy rows
- places/stores/places: createPlace stamps
  defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
  mints/clears the unlisted token on the transition boundary and emits
  cross-module VisibilityChanged
- places/views/DetailView: <VisibilityPicker> as the first field-row,
  above Kategorie

website embed:
- website-blocks/moduleEmbed/schema: 'places.places' added to
  EmbedSourceSchema; filter docstring describes the places-specific
  reuse of existing kind/isFavorite/tagIds filter fields
- website/embeds: resolvePlaces gates hard on canEmbedOnWebsite,
  applies optional kind (→ PlaceCategory) / isFavorite / tagIds
  filters, sorts favourites-first then alphabetical.

Privacy: Whitelist (title + address only). Latitude/longitude are
explicitly NOT inlined — 10m precision of a home or workplace can
identify someone, and silently publishing coords on a visibility flip
would be the classic leak the design was built to prevent (plan §2).

Verified:
- pnpm check (web): 7450 files, 0 errors

Next: M5.b — Events (socialEvents), Recipes, Wardrobe-Outfits, Habits,
Quiz, Invoices-Clients. Same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 13:59:15 +02:00
parent 0cebb2411e
commit 2af2a4d5c0
6 changed files with 109 additions and 0 deletions

View file

@ -24,6 +24,7 @@ export function toPlace(local: LocalPlace): Place {
visitCount: local.visitCount ?? 0,
lastVisitedAt: local.lastVisitedAt || null,
tagIds: local.tagIds ?? [],
visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -7,6 +7,13 @@
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 { createBlock } from '$lib/data/time-blocks/service';
import { placeTable } from '../collections';
import { toPlace } from '../queries';
@ -33,6 +40,7 @@ export const placesStore = {
isFavorite: false,
isArchived: false,
visitCount: 0,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: now,
updatedAt: now,
};
@ -134,4 +142,37 @@ export const placesStore = {
visitCount: (local.visitCount ?? 0) + 1,
});
},
/**
* Flip a place's visibility. Typical use: mark favourite cafes /
* running routes 'public' so a website-embed can list them. Emits
* cross-module VisibilityChanged.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await placeTable.get(id);
if (!existing) throw new Error(`Place ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const now = new Date().toISOString();
const patch: Partial<LocalPlace> = {
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 placeTable.update(id, patch);
emitDomainEvent('VisibilityChanged', 'places', 'places', id, {
recordId: id,
collection: 'places',
before,
after: next,
});
},
};

View file

@ -3,6 +3,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
@ -18,6 +19,10 @@ export interface LocalPlace extends BaseRecord {
visitCount?: number;
lastVisitedAt?: string;
tagIds?: string[];
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
}
export interface LocalLocationLog extends BaseRecord {
@ -46,6 +51,7 @@ export interface Place {
visitCount: number;
lastVisitedAt: string | null;
tagIds: string[];
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
}

View file

@ -15,6 +15,7 @@
type GeocodingResult,
} from '$lib/geocoding';
import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
@ -153,6 +154,10 @@
await saveField();
}
async function handleVisibilityChange(next: VisibilityLevel) {
await placesStore.setVisibility(placeId, next);
}
async function toggleFavorite() {
await placesStore.toggleFavorite(placeId);
}
@ -225,6 +230,11 @@
{/if}
<div class="fields">
<div class="field-row">
<span class="field-label">Sichtbarkeit</span>
<VisibilityPicker level={place.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
<div class="field-row">
<span class="field-label">Kategorie</span>
<select class="field-select" value={editCategory} onchange={onCategoryChange}>

View file

@ -26,6 +26,7 @@ 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 { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export interface ResolvedEmbed {
@ -55,6 +56,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'goals.goals':
items = await resolveGoals(props);
break;
case 'places.places':
items = await resolvePlaces(props);
break;
default:
return {
items: [],
@ -352,3 +356,46 @@ function formatGoalProgress(g: LocalGoal): string {
g.target.period === 'day' ? 'Tag' : g.target.period === 'week' ? 'Woche' : 'Monat';
return `${g.currentValue} / ${g.target.value} · ${periodLabel}`;
}
/**
* Places: "my favourite cafes" / "rehearsal rooms" / "gyms I train at".
* Hard-gated on canEmbedOnWebsite.
*
* Whitelist (plan §2): title (place name) + subtitle (address only).
* Latitude/longitude are NOT inlined 10m precision of a home or
* workplace can identify someone, and publishing coords by default on
* a visibility flip would be the classic leak the design explicitly
* guards against.
*/
async function resolvePlaces(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let places = await db.table<LocalPlace>('places').toArray();
places = places.filter(
(p) => !p.deletedAt && !p.isArchived && canEmbedOnWebsite(p.visibility ?? 'private')
);
if (props.filter?.kind) {
places = places.filter((p) => p.category === props.filter?.kind);
}
if (props.filter?.isFavorite === true) {
places = places.filter((p) => p.isFavorite === true);
}
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
places = places.filter((p) => (p.tagIds ?? []).some((t) => wanted.has(t)));
}
const decrypted = (await decryptRecords('places', places)) as LocalPlace[];
// Favourites first, then alphabetical for a stable order.
decrypted.sort((a, b) => {
const favA = a.isFavorite ? 0 : 1;
const favB = b.isFavorite ? 0 : 1;
if (favA !== favB) return favA - favB;
return a.name.localeCompare(b.name);
});
return decrypted.map((p) => ({
title: p.name,
subtitle: p.address ?? undefined,
}));
}

View file

@ -32,6 +32,7 @@ export const EmbedSourceSchema = z.enum([
'calendar.events',
'todo.tasks',
'goals.goals',
'places.places',
]);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
@ -56,6 +57,9 @@ export const ModuleEmbedSchema = z.object({
* goals.goals: { status? } 'active' | 'completed' filter;
* useful for "currently working on" vs "things
* I've hit" progress sections
* places.places: { kind? (mapped to PlaceCategory),
* isFavorite?, tagIds? } "my favourite cafes",
* "rehearsal rooms I use", etc.
*/
filter: z
.object({