mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
0cebb2411e
commit
2af2a4d5c0
6 changed files with 109 additions and 0 deletions
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue