chore(mana): places + locationLogs aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

viadocu ist als Standalone-App live (viadocu-api.mana.how) und deckt
GPS-Tracking + Cities-Aggregation ab. Till bestätigt: keine User-Daten,
5 places-exklusive Features (Kategorien home/work/shopping/transit/
leisure, Heart-Favoriten, Tags, Visit-Counter pro Place, Place-Sharing
via Unlisted-Snapshot) werden bewusst aufgegeben.

Entfernt:
- apps/mana/apps/web/src/routes/(app)/places/ (1 Route)
- apps/mana/apps/web/src/lib/modules/places/ (Stores, Queries,
  Collections, Types, Tools, Views, SharedPlaceView, tracking-store
  mit Geolocation-Permission-Flow)
- apps/mana/apps/web/src/lib/i18n/locales/places/ (DE/EN/ES/FR/IT)
- apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts
  (places war einzige aktive Inference-Source; SOURCES-Array jetzt
  leer, habits/contacts-Sources sind M3.b geplant)

Cross-Module-Konsumenten aufgeräumt:
- modules/website/embeds.ts: resolvePlaces + 'places.places' embed-Case
- modules/myday/tools.ts: allPlaces-Read + visitedToday-Aggregat raus
- data/projections/day-snapshot.ts: places-Section + trackingStore-
  Import raus
- data/projections/types.ts: DaySnapshot.places-Feld raus
- data/projections/context-document.ts: "X Orte besucht" + "Standort-
  Tracking aktiv" Zeilen raus
- data/unlisted/resolvers.ts: buildPlaceBlob + 'places'-Case raus
- data/privacy/exposed-records.ts: places-Eintrag raus
- data/ai/revert/inverse-operations.ts: PlaceCreated-Inverse raus
- routes/share/[token]/+page.svelte: SharedPlaceView-Mount raus

Cross-Refs raus:
- module-registry.ts (placesModuleConfig)
- module-registry.test.ts (places-Tabellen)
- data/tools/init.ts (placesTools)
- data/crypto/registry.ts (places + locationLogs entry)
- data/crypto/plaintext-allowlist.ts (placeTags)
- app-registry/apps.ts (registerApp 'places' + MapPin-Icon-Import)
- packages/shared-branding/src/mana-apps.ts (places-Eintrag)

NICHT angefasst (mit Absicht):
- data/database.ts db.version()-Stores — Schema-Snapshots sind frozen.
  Tabellen places, locationLogs, placeTags bleiben im IndexedDB-Schema,
  werden aber nicht mehr beschrieben.
- packages/shared-branding/src/app-icons.ts APP_ICONS.places.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-19 17:22:38 +02:00
parent 0112161e78
commit 7b842cabaf
35 changed files with 7 additions and 3374 deletions

View file

@ -18,7 +18,6 @@ import {
Moon,
Drop,
MoneyWavy,
MapPin,
ChatCircle,
Clock,
Image,
@ -516,40 +515,8 @@ registerApp({
},
});
registerApp({
id: 'places',
name: 'Places',
color: '#0EA5E9',
icon: MapPin,
views: {
list: { load: () => import('$lib/modules/places/ListView.svelte') },
detail: { load: () => import('$lib/modules/places/views/DetailView.svelte') },
},
collection: 'places',
paramKey: 'placeId',
dragType: 'place',
acceptsDropFrom: ['contact'],
transformIncoming: {
contact: (source) => ({
name: `Treffen mit ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`,
latitude: 0,
longitude: 0,
}),
},
getDisplayData: (item) => ({
title: (item.name as string) || 'Ort',
subtitle: (item.address as string) ?? undefined,
}),
createItem: async (data) => {
const { placesStore } = await import('$lib/modules/places/stores/places.svelte');
const place = await placesStore.createPlace({
name: (data.name as string) ?? 'Neuer Ort',
latitude: (data.latitude as number) ?? 0,
longitude: (data.longitude as number) ?? 0,
});
return place.id;
},
});
// Places-Modul: dekommissioniert 2026-05-19, lebt als viadocu standalone
// auf viadocu-api.mana.how (GPS-Reise-Tracker + Cities/Countries-Stats).
registerApp({
id: 'chat',

View file

@ -15,7 +15,6 @@
import { tasksStore } from '$lib/modules/todo/stores/tasks.svelte';
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import { placesStore } from '$lib/modules/places/stores/places.svelte';
import { drinkStore } from '$lib/modules/drink/stores/drink.svelte';
export type InverseResult = { readonly ok: true } | { readonly ok: false; readonly reason: string };
@ -61,13 +60,6 @@ registerInverseOperation('CalendarEventCreated', async (payload) => {
return { ok: true };
});
registerInverseOperation('PlaceCreated', async (payload) => {
const placeId = payload.placeId;
if (typeof placeId !== 'string') return { ok: false, reason: 'missing placeId' };
await placesStore.deletePlace(placeId);
return { ok: true };
});
registerInverseOperation('DrinkLogged', async (payload) => {
const drinkId = payload.drinkId;
if (typeof drinkId !== 'string') return { ok: false, reason: 'missing drinkId' };

View file

@ -68,7 +68,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'periodSymptoms', // TODO: audit
'photoFavorites', // TODO: audit
'photoMediaTags', // TODO: audit
'placeTags', // TODO: audit
'presiDeckTags', // TODO: audit
'qCollections', // TODO: audit
'questionTags', // TODO: audit

View file

@ -290,29 +290,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// different storage layout.
invItems: { enabled: true, fields: ['description'] },
// ─── Places ──────────────────────────────────────────────
// Location data is GDPR-sensitive PII. The split between the two tables:
// - `places` holds user-named POIs. We encrypt the user-typed text
// (name/description/address) but leave lat/lng plaintext so the
// proximity matcher in tracking.svelte.ts can run without a vault
// unlock during background geolocation logging. lat/lng on a
// handful of saved POIs is far less sensitive than the full
// movement trail in locationLogs below.
// - `locationLogs` IS the movement trail — every coordinate gets
// encrypted. Indexed columns (timestamp, placeId, [placeId+timestamp])
// stay plaintext for the time-range scans in the log view.
// `name` on `places` IS schema-indexed but no .where('name') call site
// exists (search filters in JS over the decrypted DTO array) — same
// rationale as files.name and plants.name above.
places: { enabled: true, fields: ['name', 'description', 'address'] },
locationLogs: {
enabled: true,
fields: ['latitude', 'longitude', 'accuracy', 'altitude', 'speed', 'heading'],
},
// `placeTags` is intentionally NOT in the registry — pure foreign-key
// join table (placeId / tagId), zero user-typed content. Same pattern
// as manaLinks.
// ─── Playground ──────────────────────────────────────────
// Saved system-prompt snippets. `name` is the user's label and
// `systemPrompt` is the actual prompt body — both are user-typed

View file

@ -225,7 +225,6 @@ describe('module-registry — snapshot', () => {
period: ['periods', 'periodDayLogs', 'periodSymptoms'],
events: ['socialEvents', 'eventGuests', 'eventInvitations', 'eventItems'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],
playground: ['playgroundSnippets', 'playgroundConversations', 'playgroundMessages'],
body: [
'bodyExercises',

View file

@ -72,7 +72,6 @@ import { dreamsModuleConfig } from '$lib/modules/dreams/module.config';
import { periodModuleConfig } from '$lib/modules/period/module.config';
import { eventsModuleConfig } from '$lib/modules/events/module.config';
import { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
@ -122,7 +121,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
periodModuleConfig,
eventsModuleConfig,
financeModuleConfig,
placesModuleConfig,
playgroundModuleConfig,
bodyModuleConfig,
firstsModuleConfig,

View file

@ -115,18 +115,6 @@ const TABLES: TableConfig[] = [
return goalStore.setVisibility(id, next);
},
},
{
module: 'places',
collection: 'places',
moduleLabel: 'Orte',
encrypted: true,
title: (r) => asString(r.name),
href: (id) => `/places/place/${id}`,
setVisibility: async (id, next) => {
const { placesStore } = await import('$lib/modules/places/stores/places.svelte');
return placesStore.setVisibility(id, next);
},
},
{
module: 'recipes',
collection: 'recipes',

View file

@ -75,14 +75,6 @@ export function generateContextDocument(
lines.push(`- Kaffee: ${day.drinks.coffee.count}x (${day.drinks.coffee.ml}ml)`);
}
// Places
if (day.places.visitedToday > 0) {
lines.push(`- ${day.places.visitedToday} Orte besucht`);
}
if (day.places.tracking) {
lines.push('- Standort-Tracking aktiv');
}
// ── Streaks ─────────────────────────────────────
const activeStreaks = streaks.filter((s) => s.status === 'active');
const atRisk = streaks.filter((s) => s.status === 'at_risk');

View file

@ -14,11 +14,8 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '../database';
import { decryptRecords } from '../crypto';
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
import { trackingStore } from '$lib/modules/places/stores/tracking.svelte';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '../time-blocks/types';
import type { DaySnapshot, TaskSummary, EventSummary } from './types';
@ -36,7 +33,6 @@ function emptySnapshot(date: string): DaySnapshot {
coffee: { ml: 0, count: 0 },
total: { ml: 0, count: 0 },
},
places: { visitedToday: 0, tracking: false },
};
}
@ -47,7 +43,7 @@ async function buildSnapshot(): Promise<DaySnapshot> {
const todayEnd = `${today}T23:59:59`;
// ── Parallel queries — all modules at once ──────
const [allTasks, blocks, allDrinks, allPlaces] = await Promise.all([
const [allTasks, blocks, allDrinks] = await Promise.all([
db.table<LocalTask>('tasks').toArray(),
db
.table<LocalTimeBlock>('timeBlocks')
@ -55,7 +51,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
.between(todayStart, todayEnd + '\uffff')
.toArray(),
db.table<LocalDrinkEntry>('drinkEntries').toArray(),
db.table<LocalPlace>('places').toArray(),
]);
// ── Parallel decryption ─────────────────────────
@ -115,11 +110,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
}
}
// ── Places ──────────────────────────────────────
const visitedToday = allPlaces.filter(
(p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today)
).length;
return {
date: today,
tasks: {
@ -142,10 +132,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
coffee: { ml: coffeeMl, count: coffeeCount },
total: { ml: totalMl, count: totalCount },
},
places: {
visitedToday,
tracking: trackingStore.isTracking,
},
};
}

View file

@ -46,11 +46,6 @@ export interface DaySnapshot {
coffee: { ml: number; count: number };
total: { ml: number; count: number };
};
places: {
visitedToday: number;
tracking: boolean;
};
}
// ── Streaks ─────────────────────────────────────────

View file

@ -7,7 +7,6 @@ import { registerTools } from './registry';
import { todoTools } from '$lib/modules/todo/tools';
import { calendarTools } from '$lib/modules/calendar/tools';
import { drinkTools } from '$lib/modules/drink/tools';
import { placesTools } from '$lib/modules/places/tools';
import { habitsTools } from '$lib/modules/habits/tools';
import { journalTools } from '$lib/modules/journal/tools';
import { notesTools } from '$lib/modules/notes/tools';
@ -51,7 +50,6 @@ export function initTools(): void {
registerTools(todoTools);
registerTools(calendarTools);
registerTools(drinkTools);
registerTools(placesTools);
registerTools(habitsTools);
registerTools(journalTools);
registerTools(notesTools);

View file

@ -19,7 +19,6 @@ import { decryptRecord } from '$lib/data/crypto';
import { mediaFileUrl } from '$lib/modules/website/upload';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalAugurEntry } from '$lib/modules/augur/types';
import type { LocalLast } from '$lib/modules/lasts/types';
@ -52,8 +51,6 @@ export async function buildUnlistedBlob(
return buildEventBlob(recordId);
case 'libraryEntries':
return buildLibraryEntryBlob(recordId);
case 'places':
return buildPlaceBlob(recordId);
case 'augurEntries':
return buildAugurEntryBlob(recordId);
case 'lasts':
@ -158,35 +155,6 @@ async function buildLibraryEntryBlob(recordId: string): Promise<Record<string, u
};
}
/**
* Place snapshot blob.
*
* Whitelist: name, address, category.
*
* EXPLICITLY NOT inlined:
* - latitude, longitude (10m-precision identifies homes/workplaces;
* the v1 share page renders no map. v1.1
* will add an opt-in toggle if there is
* real demand for embedded maps.)
* - description (free-text, may carry private notes)
* - tagIds (internal organisation)
* - visitCount, lastVisitedAt, isFavorite (visit habits)
*/
async function buildPlaceBlob(recordId: string): Promise<Record<string, unknown>> {
const raw = await db.table<LocalPlace>('places').get(recordId);
if (!raw || raw.deletedAt) {
throw new RecordNotFoundError('places', recordId);
}
const decrypted = (await decryptRecord('places', { ...raw })) as LocalPlace;
return {
name: decrypted.name,
address: decrypted.address ?? null,
category: decrypted.category ?? 'other',
};
}
/**
* Augur entry snapshot blob.
*

View file

@ -1,41 +0,0 @@
{
"categories": {
"home": "Zuhause",
"work": "Arbeit",
"shopping": "Einkauf",
"transit": "Transit",
"leisure": "Freizeit",
"other": "Sonstiges"
},
"detail_view": {
"not_found": "Ort nicht gefunden",
"confirm_delete": "Ort wirklich löschen?",
"untitled": "Unbenannt",
"name_placeholder": "Name",
"map_title": "Kartenvorschau",
"label_visibility": "Sichtbarkeit",
"label_share_link": "Link",
"label_category": "Kategorie",
"label_address": "Adresse",
"placeholder_address": "Adresse eingeben...",
"placeholder_address_search": "Adresse suchen...",
"label_coordinates": "Koordinaten",
"placeholder_lat": "Lat",
"placeholder_lng": "Lng",
"resolve_address_title": "Adresse aus Koordinaten ermitteln",
"label_description": "Beschreibung",
"placeholder_description": "Notizen zum Ort...",
"section_tags": "Tags",
"section_recent_visits": "Letzte Besuche",
"meta_visits": "Besuche: {n}",
"meta_last_visit": "Letzter Besuch: {date}",
"meta_created": "Erstellt: {date}",
"meta_updated": "Bearbeitet: {date}"
},
"geocoding_notice": {
"sensitive_local_unavailable_title": "Diese Suche bleibt bewusst lokal",
"sensitive_local_unavailable_body": "Sensible Suchbegriffe (Arzt, Klinik, …) werden nie an externe Dienste geschickt. Der lokale Index ist gerade nicht erreichbar — versuche es später nochmal oder formuliere allgemeiner.",
"fallback_used_badge": "≈ ungefähr",
"fallback_used_title": "Adress-Suche über öffentliches OSM"
}
}

View file

@ -1,41 +0,0 @@
{
"categories": {
"home": "Home",
"work": "Work",
"shopping": "Shopping",
"transit": "Transit",
"leisure": "Leisure",
"other": "Other"
},
"detail_view": {
"not_found": "Place not found",
"confirm_delete": "Really delete place?",
"untitled": "Untitled",
"name_placeholder": "Name",
"map_title": "Map preview",
"label_visibility": "Visibility",
"label_share_link": "Link",
"label_category": "Category",
"label_address": "Address",
"placeholder_address": "Enter address…",
"placeholder_address_search": "Search address…",
"label_coordinates": "Coordinates",
"placeholder_lat": "Lat",
"placeholder_lng": "Lng",
"resolve_address_title": "Resolve address from coordinates",
"label_description": "Description",
"placeholder_description": "Notes about this place…",
"section_tags": "Tags",
"section_recent_visits": "Recent visits",
"meta_visits": "Visits: {n}",
"meta_last_visit": "Last visit: {date}",
"meta_created": "Created: {date}",
"meta_updated": "Edited: {date}"
},
"geocoding_notice": {
"sensitive_local_unavailable_title": "This search stays local on purpose",
"sensitive_local_unavailable_body": "Sensitive terms (doctor, clinic, …) are never forwarded to public services. The local index is unavailable right now — try again later or use a more general query.",
"fallback_used_badge": "≈ approximate",
"fallback_used_title": "Address lookup via public OSM"
}
}

View file

@ -1,41 +0,0 @@
{
"categories": {
"home": "Casa",
"work": "Trabajo",
"shopping": "Compras",
"transit": "Tránsito",
"leisure": "Ocio",
"other": "Otro"
},
"detail_view": {
"not_found": "Lugar no encontrado",
"confirm_delete": "¿Eliminar realmente el lugar?",
"untitled": "Sin título",
"name_placeholder": "Nombre",
"map_title": "Vista previa del mapa",
"label_visibility": "Visibilidad",
"label_share_link": "Enlace",
"label_category": "Categoría",
"label_address": "Dirección",
"placeholder_address": "Introduce una dirección…",
"placeholder_address_search": "Buscar dirección…",
"label_coordinates": "Coordenadas",
"placeholder_lat": "Lat",
"placeholder_lng": "Lng",
"resolve_address_title": "Obtener dirección desde coordenadas",
"label_description": "Descripción",
"placeholder_description": "Notas sobre este lugar…",
"section_tags": "Etiquetas",
"section_recent_visits": "Últimas visitas",
"meta_visits": "Visitas: {n}",
"meta_last_visit": "Última visita: {date}",
"meta_created": "Creado: {date}",
"meta_updated": "Editado: {date}"
},
"geocoding_notice": {
"sensitive_local_unavailable_title": "Esta búsqueda permanece local a propósito",
"sensitive_local_unavailable_body": "Los términos sensibles (médico, clínica, …) nunca se envían a servicios externos. El índice local no está disponible — vuelve a intentarlo o usa una búsqueda más general.",
"fallback_used_badge": "≈ aprox.",
"fallback_used_title": "Búsqueda de dirección vía OSM público"
}
}

View file

@ -1,41 +0,0 @@
{
"categories": {
"home": "Maison",
"work": "Travail",
"shopping": "Achats",
"transit": "Transit",
"leisure": "Loisirs",
"other": "Autre"
},
"detail_view": {
"not_found": "Lieu introuvable",
"confirm_delete": "Vraiment supprimer le lieu ?",
"untitled": "Sans titre",
"name_placeholder": "Nom",
"map_title": "Aperçu de la carte",
"label_visibility": "Visibilité",
"label_share_link": "Lien",
"label_category": "Catégorie",
"label_address": "Adresse",
"placeholder_address": "Saisis une adresse…",
"placeholder_address_search": "Rechercher une adresse…",
"label_coordinates": "Coordonnées",
"placeholder_lat": "Lat",
"placeholder_lng": "Lng",
"resolve_address_title": "Trouver l'adresse à partir des coordonnées",
"label_description": "Description",
"placeholder_description": "Notes sur ce lieu…",
"section_tags": "Tags",
"section_recent_visits": "Dernières visites",
"meta_visits": "Visites : {n}",
"meta_last_visit": "Dernière visite : {date}",
"meta_created": "Créé : {date}",
"meta_updated": "Modifié : {date}"
},
"geocoding_notice": {
"sensitive_local_unavailable_title": "Cette recherche reste locale par principe",
"sensitive_local_unavailable_body": "Les termes sensibles (médecin, clinique, …) ne sont jamais transmis à des services externes. L'index local est indisponible — réessaie plus tard ou utilise une formulation plus générale.",
"fallback_used_badge": "≈ approx.",
"fallback_used_title": "Recherche d'adresse via OSM public"
}
}

View file

@ -1,41 +0,0 @@
{
"categories": {
"home": "Casa",
"work": "Lavoro",
"shopping": "Shopping",
"transit": "Transito",
"leisure": "Tempo libero",
"other": "Altro"
},
"detail_view": {
"not_found": "Luogo non trovato",
"confirm_delete": "Eliminare davvero il luogo?",
"untitled": "Senza titolo",
"name_placeholder": "Nome",
"map_title": "Anteprima mappa",
"label_visibility": "Visibilità",
"label_share_link": "Link",
"label_category": "Categoria",
"label_address": "Indirizzo",
"placeholder_address": "Inserisci un indirizzo…",
"placeholder_address_search": "Cerca indirizzo…",
"label_coordinates": "Coordinate",
"placeholder_lat": "Lat",
"placeholder_lng": "Lng",
"resolve_address_title": "Trova l'indirizzo dalle coordinate",
"label_description": "Descrizione",
"placeholder_description": "Note sul luogo…",
"section_tags": "Tag",
"section_recent_visits": "Ultime visite",
"meta_visits": "Visite: {n}",
"meta_last_visit": "Ultima visita: {date}",
"meta_created": "Creato: {date}",
"meta_updated": "Modificato: {date}"
},
"geocoding_notice": {
"sensitive_local_unavailable_title": "Questa ricerca resta locale di proposito",
"sensitive_local_unavailable_body": "Termini sensibili (medico, clinica, …) non vengono mai inoltrati a servizi esterni. L'indice locale non è disponibile — riprova più tardi o usa una formulazione più generale.",
"fallback_used_badge": "≈ approssimativo",
"fallback_used_title": "Ricerca indirizzo tramite OSM pubblico"
}
}

View file

@ -20,10 +20,11 @@ import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import { lastsCooldownTable } from '../collections';
import type { LocalLast, LocalLastsCooldown } from '../types';
import { placesSource } from './sources/places';
import { INFERENCE_DEFAULTS, type InferenceCandidate, type InferenceSource } from './types';
const SOURCES: InferenceSource[] = [placesSource];
// `places` inference source dekommissioniert 2026-05-19 mit places-Modul.
// habits/contacts-Sources sind in M3.b geplant — kein aktiver Scanner aktuell.
const SOURCES: InferenceSource[] = [];
/** Read all lasts in the active Space (decrypted). */
async function loadExistingLasts(): Promise<LocalLast[]> {

View file

@ -1,89 +0,0 @@
/**
* Places inference source.
*
* Heuristic: a Place with `visitCount >= MIN_PRIOR_OCCURRENCES` whose
* `lastVisitedAt` is older than `MIN_SILENCE_DAYS` is a candidate. We
* don't have direct access to per-visit history (would need to scan
* `locationLogs`), so the visit-count + last-visit pair is the proxy
* for "was a regular thing, has stopped".
*
* Category mapping: Place.category LastCategory by best-effort. Most
* places land in `other` if their PlaceCategory has no clean milestone
* equivalent.
*/
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule } from '$lib/data/scope';
import type { LocalPlace, PlaceCategory } from '$lib/modules/places/types';
import { INFERENCE_DEFAULTS, type InferenceCandidate, type InferenceSource } from '../types';
import type { LastCategory } from '../../types';
const PLACE_CATEGORY_MAP: Record<PlaceCategory, LastCategory> = {
home: 'other',
work: 'career',
shopping: 'other',
transit: 'travel',
leisure: 'culture',
other: 'other',
};
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24));
}
function silenceLabel(days: number): string {
if (days >= 730) return `${Math.floor(days / 365)} Jahren`;
if (days >= 365) return '1 Jahr';
const months = Math.floor(days / 30);
return `${months} Monaten`;
}
export const placesSource: InferenceSource = {
id: 'places',
async scan(now) {
const visible = (
await scopedForModule<LocalPlace, string>('places', 'places').toArray()
).filter((p) => !p.deletedAt && !p.isArchived);
// Place names are encrypted in the registry — decrypt before use.
const decrypted = await decryptRecords<LocalPlace>('places', visible);
const candidates: InferenceCandidate[] = [];
for (const place of decrypted) {
const visitCount = place.visitCount ?? 0;
if (visitCount < INFERENCE_DEFAULTS.MIN_PRIOR_OCCURRENCES) continue;
if (!place.lastVisitedAt) continue;
const lastVisit = new Date(place.lastVisitedAt);
if (Number.isNaN(lastVisit.getTime())) continue;
const silenceDays = daysBetween(now, lastVisit);
if (silenceDays < INFERENCE_DEFAULTS.MIN_SILENCE_DAYS) continue;
// Span check: createdAt → lastVisitedAt should cover at least
// MIN_PRIOR_SPAN_DAYS so we know it was a sustained habit, not a
// short burst (e.g. a one-week conference visited 5 days running).
if (place.createdAt) {
const created = new Date(place.createdAt);
const spanDays = daysBetween(lastVisit, created);
if (spanDays < INFERENCE_DEFAULTS.MIN_PRIOR_SPAN_DAYS) continue;
}
const category = PLACE_CATEGORY_MAP[place.category ?? 'other'];
candidates.push({
refTable: 'places',
refId: place.id,
title: `Letztes Mal ${place.name}`,
category,
frequencyHint: `${visitCount}× besucht — seit ${silenceLabel(silenceDays)} nicht mehr`,
suggestedDate: place.lastVisitedAt.slice(0, 10),
});
}
// Sort by silence desc (longest gap = oldest lastVisitedAt first) and cap.
candidates.sort((a, b) => (a.suggestedDate ?? '').localeCompare(b.suggestedDate ?? ''));
return candidates.slice(0, INFERENCE_DEFAULTS.MAX_CANDIDATES_PER_SOURCE);
},
};

View file

@ -11,7 +11,6 @@ import { decryptRecords } from '$lib/data/crypto';
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalGoal } from '$lib/companion/goals/types';
@ -33,7 +32,7 @@ export const mydayTools: ModuleTool[] = [
const todayEnd = `${today}T23:59:59`;
// ── Parallel queries ────────────────────────
const [allTasks, blocks, allDrinks, allPlaces, streakStates, goals] = await Promise.all([
const [allTasks, blocks, allDrinks, streakStates, goals] = await Promise.all([
db.table<LocalTask>('tasks').toArray(),
db
.table<LocalTimeBlock>('timeBlocks')
@ -41,7 +40,6 @@ export const mydayTools: ModuleTool[] = [
.between(todayStart, todayEnd + '\uffff')
.toArray(),
db.table<LocalDrinkEntry>('drinkEntries').toArray(),
db.table<LocalPlace>('places').toArray(),
db.table('_streakState').toArray(),
db.table<LocalGoal>('companionGoals').toArray(),
]);
@ -91,11 +89,6 @@ export const mydayTools: ModuleTool[] = [
}
}
// ── Places ──────────────────────────────────
const visitedToday = allPlaces.filter(
(p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today)
).length;
// ── Streaks ─────────────────────────────────
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
@ -148,7 +141,6 @@ export const mydayTools: ModuleTool[] = [
coffee: { count: coffeeCount },
total: { ml: totalMl, count: decDrinks.length },
},
places: { visitedToday },
streaks,
goals: activeGoals,
};

File diff suppressed because it is too large Load diff

View file

@ -1,137 +0,0 @@
<!--
Shared-Place view — public render of a place behind an unlisted
share link.
Whitelist (set by buildPlaceBlob): name, address, category. Lat/lng
intentionally NOT inlined — the v1 share page renders no map.
v1.1 may add an opt-in toggle.
-->
<script lang="ts">
type PlaceCategory = 'home' | 'work' | 'shopping' | 'transit' | 'leisure' | 'other';
interface PlaceBlob {
name: string;
address: string | null;
category: PlaceCategory;
}
let {
blob,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const place = $derived(blob as unknown as PlaceBlob);
const CATEGORY_LABELS: Record<PlaceCategory, string> = {
home: 'Zuhause',
work: 'Arbeit',
shopping: 'Einkaufen',
transit: 'Transit',
leisure: 'Freizeit',
other: 'Ort',
};
const CATEGORY_EMOJI: Record<PlaceCategory, string> = {
home: '🏠',
work: '🏢',
shopping: '🛍️',
transit: '🚆',
leisure: '🎨',
other: '📍',
};
const ogDescription = $derived(place.address ?? CATEGORY_LABELS[place.category]);
// Map-search link — generic geo-URL, browser opens whatever map app
// the user has set as default. Falls back to OpenStreetMap search.
const mapUrl = $derived.by(() => {
const q = encodeURIComponent(`${place.name}${place.address ? ` ${place.address}` : ''}`);
return `https://www.openstreetmap.org/search?query=${q}`;
});
</script>
<svelte:head>
<title>{place.name} · Mana</title>
<meta name="robots" content="noindex, nofollow" />
<meta property="og:title" content={place.name} />
<meta property="og:description" content={ogDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<article class="place">
<span class="place__kind">
{CATEGORY_EMOJI[place.category]}
{CATEGORY_LABELS[place.category]}
</span>
<h1 class="place__title">{place.name}</h1>
{#if place.address}
<p class="place__address">{place.address}</p>
{/if}
<a class="place__map" href={mapUrl} target="_blank" rel="noopener noreferrer">
🗺️ Auf OpenStreetMap suchen
</a>
</article>
<style>
.place {
display: flex;
flex-direction: column;
gap: 1rem;
}
.place__kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
font-weight: 600;
}
.place__title {
margin: 0;
font-size: 2rem;
line-height: 1.15;
font-weight: 700;
}
.place__address {
margin: 0;
font-size: 1.0625rem;
color: #374151;
white-space: pre-line;
}
.place__map {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
background: #4f46e5;
color: white;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
align-self: flex-start;
}
.place__map:hover {
background: #4338ca;
}
@media (prefers-color-scheme: dark) {
.place__kind {
color: #9ca3af;
}
.place__address {
color: #d1d5db;
}
.place__map {
background: #818cf8;
}
.place__map:hover {
background: #6366f1;
}
}
</style>

View file

@ -1,40 +0,0 @@
/**
* Places module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalPlace, LocalLocationLog } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const placeTable = db.table<LocalPlace>('places');
export const locationLogTable = db.table<LocalLocationLog>('locationLogs');
// ─── Guest Seed ────────────────────────────────────────────
export const PLACES_GUEST_SEED = {
places: [
{
id: 'guest-place-home',
name: 'Zuhause',
latitude: 47.6603,
longitude: 9.1751,
category: 'home' as const,
isFavorite: true,
isArchived: false,
visitCount: 12,
lastVisitedAt: new Date().toISOString(),
},
{
id: 'guest-place-work',
name: 'Buero',
latitude: 47.6588,
longitude: 9.1753,
category: 'work' as const,
isFavorite: false,
isArchived: false,
visitCount: 8,
},
] satisfies LocalPlace[],
locationLogs: [] satisfies LocalLocationLog[],
};

View file

@ -1,21 +0,0 @@
/**
* Places module barrel exports.
*/
export { placesStore } from './stores/places.svelte';
export { trackingStore } from './stores/tracking.svelte';
export {
useAllPlaces,
useLocationLogs,
toPlace,
toLocationLog,
searchPlaces,
filterFavorites,
filterActive,
getDistanceKm,
findNearestPlace,
} from './queries';
export { placeTable, locationLogTable, PLACES_GUEST_SEED } from './collections';
// Geocoding moved to $lib/geocoding (shared across modules).
// Import directly from $lib/geocoding instead of from this barrel.
export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types';

View file

@ -1,6 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const placesModuleConfig: ModuleConfig = {
appId: 'places',
tables: [{ name: 'places' }, { name: 'locationLogs' }, { name: 'placeTags' }],
};

View file

@ -1,124 +0,0 @@
/**
* Reactive queries & pure helpers for Places uses Dexie liveQuery on the unified DB.
*/
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalPlace, LocalLocationLog, Place, LocationLog } from './types';
// ─── Type Converters ─────────────────────────────────────
export function toPlace(local: LocalPlace): Place {
return {
id: local.id,
name: local.name,
description: local.description || null,
latitude: local.latitude,
longitude: local.longitude,
address: local.address || null,
category: local.category ?? 'other',
isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false,
visitCount: local.visitCount ?? 0,
lastVisitedAt: local.lastVisitedAt || null,
tagIds: local.tagIds ?? [],
visibility: local.visibility ?? 'space',
unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toLocationLog(local: LocalLocationLog): LocationLog {
return {
id: local.id,
latitude: local.latitude,
longitude: local.longitude,
accuracy: local.accuracy ?? null,
altitude: local.altitude ?? null,
speed: local.speed ?? null,
heading: local.heading ?? null,
timestamp: local.timestamp,
placeId: local.placeId || null,
};
}
// ─── Live Queries ────────────────────────────────────────
export function useAllPlaces() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalPlace, string>('places', 'places').toArray();
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords<LocalPlace>('places', visible);
return decrypted.map(toPlace);
}, []);
}
export function useLocationLogs(placeId?: string) {
return useScopedLiveQuery(async () => {
let query = db.table<LocalLocationLog>('locationLogs').orderBy('timestamp').reverse();
const locals = await query.toArray();
const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals;
const decrypted = await decryptRecords<LocalLocationLog>('locationLogs', filtered);
return decrypted.map(toLocationLog);
}, []);
}
// ─── Pure Filter / Search ────────────────────────────────
export function searchPlaces(places: Place[], query: string): Place[] {
if (!query.trim()) return places;
const q = query.toLowerCase().trim();
return places.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address?.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q)
);
}
export function filterFavorites(places: Place[]): Place[] {
return places.filter((p) => p.isFavorite);
}
export function filterActive(places: Place[]): Place[] {
return places.filter((p) => !p.isArchived);
}
/**
* Haversine distance between two coordinates in kilometers.
*/
export function getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Find the nearest known place within a given radius (km).
*/
export function findNearestPlace(
places: Place[],
lat: number,
lng: number,
radiusKm = 0.1
): Place | null {
let nearest: Place | null = null;
let minDist = radiusKm;
for (const p of places) {
const d = getDistanceKm(lat, lng, p.latitude, p.longitude);
if (d < minDist) {
minDist = d;
nearest = p;
}
}
return nearest;
}

View file

@ -1,304 +0,0 @@
/**
* Places Store Mutation-Only
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only exposes mutations that write to IndexedDB.
*/
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,
publishUnlistedSnapshot,
revokeUnlistedSnapshot,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers';
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
import { createBlock } from '$lib/data/time-blocks/service';
import { placeTable } from '../collections';
import { toPlace } from '../queries';
import type { LocalPlace, Place, PlaceCategory } from '../types';
export const placesStore = {
async createPlace(data: {
name: string;
latitude: number;
longitude: number;
description?: string;
address?: string;
category?: PlaceCategory;
}) {
const now = new Date().toISOString();
const newLocal: LocalPlace = {
id: crypto.randomUUID(),
name: data.name,
latitude: data.latitude,
longitude: data.longitude,
description: data.description,
address: data.address,
category: data.category ?? 'other',
isFavorite: false,
isArchived: false,
visitCount: 0,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: now,
};
// Snapshot the plaintext DTO before encryption mutates the record
// in place — same pattern as the notes/dreams/contacts stores.
const plaintextSnapshot = toPlace({ ...newLocal });
await encryptRecord('places', newLocal);
await placeTable.add(newLocal);
emitDomainEvent('PlaceCreated', 'places', 'places', newLocal.id, {
placeId: newLocal.id,
name: data.name,
category: data.category,
lat: data.latitude,
lng: data.longitude,
});
return plaintextSnapshot;
},
async updatePlace(id: string, data: Partial<Place> & Record<string, unknown>) {
const updateData: Partial<LocalPlace> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description ?? undefined;
if (data.latitude !== undefined) updateData.latitude = data.latitude;
if (data.longitude !== undefined) updateData.longitude = data.longitude;
if (data.address !== undefined) updateData.address = data.address ?? undefined;
if (data.category !== undefined) updateData.category = data.category;
if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite;
if (data.isArchived !== undefined) updateData.isArchived = data.isArchived;
const diff = {
...updateData,
};
// encryptRecord mutates the diff in place. Fields not in the
// places allowlist (lat/lng, isFavorite, isArchived, …) pass
// through untouched.
await encryptRecord('places', diff);
await placeTable.update(id, diff);
// Refresh share-snapshot if this place is unlisted.
void this.refreshUnlistedSnapshot(id);
},
async deletePlace(id: string) {
const local = await placeTable.get(id);
const decrypted = local ? await decryptRecord('places', { ...local }) : null;
// Revoke active share-link before tombstone.
if (local?.visibility === 'unlisted' && local.unlistedToken) {
const jwt = await authStore.getValidToken();
if (jwt) {
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
});
} catch (e) {
console.error('[places] revoke on delete failed', e);
}
}
}
await placeTable.update(id, {
deletedAt: new Date().toISOString(),
});
emitDomainEvent('PlaceDeleted', 'places', 'places', id, {
placeId: id,
name: (decrypted?.name as string) ?? '',
});
},
async toggleFavorite(id: string) {
const local = await placeTable.get(id);
if (!local) return;
await placeTable.update(id, {
isFavorite: !local.isFavorite,
});
},
async updateTagIds(id: string, tagIds: string[]) {
await placeTable.update(id, {
tagIds,
});
},
async recordVisit(id: string) {
const local = await placeTable.get(id);
if (!local) return;
const now = new Date().toISOString();
const decrypted = await decryptRecord('places', { ...local });
const placeName = decrypted?.name ?? 'Ort';
await placeTable.update(id, {
visitCount: (local.visitCount ?? 0) + 1,
lastVisitedAt: now,
});
await createBlock({
startDate: now,
endDate: now,
kind: 'logged',
type: 'visit',
sourceModule: 'places',
sourceId: id,
title: placeName,
color: '#a855f7',
});
emitDomainEvent('PlaceVisited', 'places', 'places', id, {
placeId: id,
name: placeName,
visitCount: (local.visitCount ?? 0) + 1,
});
},
/**
* Flip a place's visibility. Coordinates with the server-side
* unlisted-snapshots table see calendar/eventsStore.setVisibility
* for the full pattern. Server is authoritative for the token.
*/
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(),
};
if (next === 'unlisted') {
const blob = await buildUnlistedBlob('places', id);
const jwt = await authStore.getValidToken();
if (!jwt) throw new Error('Nicht eingeloggt');
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
spaceId,
blob,
});
patch.unlistedToken = token;
patch.unlistedExpiresAt = undefined;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
});
}
patch.unlistedToken = undefined;
patch.unlistedExpiresAt = undefined;
}
await placeTable.update(id, patch);
emitDomainEvent('VisibilityChanged', 'places', 'places', id, {
recordId: id,
collection: 'places',
before,
after: next,
});
},
async regenerateUnlistedToken(id: string) {
const existing = await placeTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return null;
const jwt = await authStore.getValidToken();
if (!jwt) return null;
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
});
const blob = await buildUnlistedBlob('places', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
await placeTable.update(id, {
unlistedToken: token,
});
return token;
} catch (e) {
console.error('[places] regenerate failed', e);
return null;
}
},
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await placeTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
try {
const blob = await buildUnlistedBlob('places', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
spaceId,
blob,
expiresAt,
});
await placeTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
});
} catch (e) {
console.error('[places] setUnlistedExpiry failed', e);
}
},
async refreshUnlistedSnapshot(id: string) {
const existing = await placeTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
try {
const blob = await buildUnlistedBlob('places', id);
const jwt = await authStore.getValidToken();
if (!jwt) return;
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
} catch (e) {
console.error('[places] refreshUnlistedSnapshot failed', e);
}
},
};

View file

@ -1,229 +0,0 @@
/**
* Tracking Store Browser Geolocation API wrapper with Svelte 5 runes.
*
* Tracks the user's position via watchPosition and periodically logs
* entries to IndexedDB. Also detects proximity to known places.
*/
import { decryptRecords, encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { locationLogTable, placeTable } from '../collections';
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
import { reverseGeocode, formatAddress } from '$lib/geocoding';
import type { LocalLocationLog, LocalPlace } from '../types';
// ─── State ──────────────────────────────────────────────
let isTracking = $state(false);
let currentPosition = $state<GeolocationPosition | null>(null);
let error = $state<string | null>(null);
let permissionState = $state<string>('unknown');
let _watchId: number | null = null;
let _lastLogTime = 0;
/** Minimum seconds between log entries (default: 5 minutes). */
const LOG_INTERVAL_MS = 5 * 60 * 1000;
// ─── Permission Check ───────────────────────────────────
async function checkPermission(): Promise<string> {
try {
const result = await navigator.permissions.query({ name: 'geolocation' });
permissionState = result.state;
result.addEventListener('change', () => {
permissionState = result.state;
});
return result.state;
} catch {
permissionState = 'unknown';
return 'unknown';
}
}
// ─── Core Tracking ──────────────────────────────────────
function startTracking() {
if (isTracking || !navigator.geolocation) return;
error = null;
isTracking = true;
emitDomainEvent('TrackingStarted', 'places', 'locationLogs', '', {
timestamp: new Date().toISOString(),
});
_watchId = navigator.geolocation.watchPosition(
async (pos) => {
currentPosition = pos;
error = null;
const now = Date.now();
if (now - _lastLogTime >= LOG_INTERVAL_MS) {
_lastLogTime = now;
await logPosition(pos);
}
},
(err) => {
error = err.message;
},
{
enableHighAccuracy: false,
maximumAge: 60_000,
timeout: 30_000,
}
);
checkPermission();
}
function stopTracking() {
if (_watchId !== null) {
navigator.geolocation.clearWatch(_watchId);
_watchId = null;
}
isTracking = false;
emitDomainEvent('TrackingStopped', 'places', 'locationLogs', '', {
durationMs: 0,
logCount: 0,
});
}
async function getCurrentPosition(): Promise<GeolocationPosition | null> {
if (!navigator.geolocation) {
error = 'Geolocation wird nicht unterstuetzt';
return null;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => {
currentPosition = pos;
error = null;
resolve(pos);
},
(err) => {
error = err.message;
resolve(null);
},
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 15_000 }
);
});
}
// ─── Log to IndexedDB ───────────────────────────────────
async function logPosition(pos: GeolocationPosition) {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
// Check proximity to known places. lat/lng on `places` stay plaintext
// (see registry.ts) so the proximity matcher works during background
// geolocation logging even before the vault is unlocked. We still
// decrypt so that nearest.name etc. is usable downstream.
const allLocals = await placeTable.toArray();
const visible = allLocals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords<LocalPlace>('places', visible);
const places = decrypted.map(toPlace);
const nearest = findNearestPlace(places, lat, lng);
const log: LocalLocationLog = {
id: crypto.randomUUID(),
latitude: lat,
longitude: lng,
accuracy: pos.coords.accuracy ?? undefined,
altitude: pos.coords.altitude ?? undefined,
speed: pos.coords.speed ?? undefined,
heading: pos.coords.heading ?? undefined,
timestamp: new Date(pos.timestamp).toISOString(),
placeId: nearest?.id,
createdAt: new Date().toISOString(),
};
await encryptRecord('locationLogs', log);
await locationLogTable.add(log);
emitDomainEvent('LocationLogged', 'places', 'locationLogs', log.id, {
logId: log.id,
lat,
lng,
placeId: nearest?.id,
accuracy: pos.coords.accuracy,
});
// Update visit count on the matched place + create TimeBlock
if (nearest) {
const local = await placeTable.get(nearest.id);
if (local) {
const updates: Partial<LocalPlace> = {
visitCount: (local.visitCount ?? 0) + 1,
lastVisitedAt: log.timestamp,
};
// Auto-fill address via reverse geocoding if the place has none
if (!local.address) {
reverseGeocode(lat, lng).then(async (result) => {
if (result) {
const addr = formatAddress(result.address);
if (addr) {
const rec: Partial<LocalPlace> = { address: addr };
await encryptRecord('places', rec);
await placeTable.update(nearest.id, {
address: rec.address,
});
}
}
});
}
await placeTable.update(nearest.id, updates);
await createBlock({
startDate: log.timestamp,
endDate: log.timestamp,
kind: 'logged',
type: 'visit',
sourceModule: 'places',
sourceId: nearest.id,
title: nearest.name,
color: '#a855f7',
});
}
}
}
// ─── Force-Log (ignores interval) ───────────────────────
async function logNow() {
if (!currentPosition) {
const pos = await getCurrentPosition();
if (pos) {
_lastLogTime = Date.now();
await logPosition(pos);
}
return;
}
_lastLogTime = Date.now();
await logPosition(currentPosition);
}
// ─── Exports ────────────────────────────────────────────
export const trackingStore = {
get isTracking() {
return isTracking;
},
get currentPosition() {
return currentPosition;
},
get error() {
return error;
},
get permissionState() {
return permissionState;
},
startTracking,
stopTracking,
getCurrentPosition,
checkPermission,
logNow,
};

View file

@ -1,107 +0,0 @@
/**
* Places Tools LLM-accessible operations for location tracking.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { placesStore } from './stores/places.svelte';
import { trackingStore } from './stores/tracking.svelte';
import { placeTable } from './collections';
import { decryptRecords } from '$lib/data/crypto';
import { toPlace } from './queries';
import type { LocalPlace, PlaceCategory } from './types';
export const placesTools: ModuleTool[] = [
{
name: 'create_place',
module: 'places',
description: 'Erstellt einen neuen Ort',
parameters: [
{ name: 'name', type: 'string', description: 'Name des Ortes', required: true },
{ name: 'latitude', type: 'number', description: 'Breitengrad', required: true },
{ name: 'longitude', type: 'number', description: 'Laengengrad', required: true },
{
name: 'category',
type: 'string',
description: 'Kategorie',
required: false,
enum: [
'home',
'work',
'shopping',
'sport',
'culture',
'nature',
'transport',
'health',
'education',
'nightlife',
'other',
],
},
{ name: 'address', type: 'string', description: 'Adresse', required: false },
],
async execute(params) {
const place = await placesStore.createPlace({
name: params.name as string,
latitude: params.latitude as number,
longitude: params.longitude as number,
category: params.category as PlaceCategory | undefined,
address: params.address as string | undefined,
});
return { success: true, data: place, message: `Ort "${params.name}" erstellt` };
},
},
{
name: 'visit_place',
module: 'places',
description: 'Vermerkt einen Besuch an einem bereits erfassten Ort',
parameters: [{ name: 'placeId', type: 'string', description: 'ID des Ortes', required: true }],
async execute(params) {
await placesStore.recordVisit(params.placeId as string);
return { success: true, message: 'Besuch registriert' };
},
},
{
name: 'get_places',
module: 'places',
description: 'Gibt alle gespeicherten Orte zurueck',
parameters: [],
async execute() {
const all = await placeTable.toArray();
const active = all.filter((p) => !p.deletedAt && !p.isArchived);
const decrypted = await decryptRecords<LocalPlace>('places', active);
const places = decrypted.map(toPlace);
return {
success: true,
data: places.map((p) => ({
id: p.id,
name: p.name,
category: p.category,
visitCount: p.visitCount,
})),
message: `${places.length} Orte gespeichert`,
};
},
},
{
name: 'get_current_location',
module: 'places',
description: 'Gibt die aktuelle GPS-Position zurueck (erfordert Standort-Berechtigung)',
parameters: [],
async execute() {
const pos = await trackingStore.getCurrentPosition();
if (!pos) {
return { success: false, message: 'Standort nicht verfuegbar' };
}
return {
success: true,
data: {
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
},
message: `Standort: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`,
};
},
},
];

View file

@ -1,75 +0,0 @@
/**
* Places module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export type PlaceCategory = 'home' | 'work' | 'shopping' | 'transit' | 'leisure' | 'other';
export interface LocalPlace extends BaseRecord {
name: string;
description?: string;
latitude: number;
longitude: number;
address?: string;
category?: PlaceCategory;
isFavorite?: boolean;
isArchived?: boolean;
visitCount?: number;
lastVisitedAt?: string;
tagIds?: string[];
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
/** ISO timestamp when the unlisted snapshot expires; absent = never. */
unlistedExpiresAt?: string;
}
export interface LocalLocationLog extends BaseRecord {
latitude: number;
longitude: number;
accuracy?: number;
altitude?: number;
speed?: number;
heading?: number;
timestamp: string;
placeId?: string;
}
// ─── Shared Place Type ──────────────────────────────────
export interface Place {
id: string;
name: string;
description: string | null;
latitude: number;
longitude: number;
address: string | null;
category: PlaceCategory;
isFavorite: boolean;
isArchived: boolean;
visitCount: number;
lastVisitedAt: string | null;
tagIds: string[];
visibility: VisibilityLevel;
/** Server-issued share token. Empty when not 'unlisted'. */
unlistedToken: string;
/** ISO timestamp when the unlisted snapshot expires, or null = never. */
unlistedExpiresAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface LocationLog {
id: string;
latitude: number;
longitude: number;
accuracy: number | null;
altitude: number | null;
speed: number | null;
heading: number | null;
timestamp: string;
placeId: string | null;
}

View file

@ -1,695 +0,0 @@
<!--
Places — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { db } from '$lib/data/database';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { placesStore } from '../stores/places.svelte';
import {
reverseGeocode,
formatAddress,
searchAddress,
type GeocodingResult,
} from '$lib/geocoding';
import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
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';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { removeTagIdWithUndo } from '$lib/data/tag-mutations';
import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
let { navigate, params, goBack }: ViewProps = $props();
let placeId = $derived(params.placeId as string);
let editName = $state('');
let editDescription = $state('');
let editAddress = $state('');
let editCategory = $state<PlaceCategory>('other');
let editLatitude = $state('');
let editLongitude = $state('');
const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []);
const detail = useDetailEntity<LocalPlace>({
id: () => placeId,
table: 'places',
onLoad: (p) => {
editName = p.name ?? '';
editDescription = p.description ?? '';
editAddress = p.address ?? '';
editCategory = p.category ?? 'other';
editLatitude = p.latitude?.toString() ?? '';
editLongitude = p.longitude?.toString() ?? '';
},
});
const logsQuery = useLiveQueryWithDefault(async () => {
const all = await db
.table<LocalLocationLog>('locationLogs')
.where('placeId')
.equals(placeId)
.reverse()
.sortBy('timestamp');
return all.slice(0, 20);
}, [] as LocalLocationLog[]);
const logs = $derived(logsQuery.value);
let placeTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
const CATEGORY_VALUES: PlaceCategory[] = [
'home',
'work',
'shopping',
'transit',
'leisure',
'other',
];
// --- Reverse geocoding (coords → address) ---
let isResolving = $state(false);
async function resolveAddress() {
const lat = parseFloat(editLatitude);
const lng = parseFloat(editLongitude);
if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return;
isResolving = true;
const result = await reverseGeocode(lat, lng);
isResolving = false;
if (result) {
editAddress = formatAddress(result.address);
if (editCategory === 'other' && result.category !== 'other') {
editCategory = result.category;
}
await saveField();
}
}
// --- Address search in detail view ---
let addressSearch = $state('');
let addressSuggestions = $state<GeocodingResult[]>([]);
let showAddressSuggestions = $state(false);
let addressDebounce: ReturnType<typeof setTimeout> | undefined;
function onAddressSearchInput() {
clearTimeout(addressDebounce);
if (addressSearch.trim().length < 2) {
addressSuggestions = [];
showAddressSuggestions = false;
return;
}
addressDebounce = setTimeout(async () => {
addressSuggestions = await searchAddress(addressSearch, { limit: 5 });
showAddressSuggestions = addressSuggestions.length > 0;
}, 300);
}
async function applyAddressResult(result: GeocodingResult) {
showAddressSuggestions = false;
addressSearch = '';
editAddress = formatAddress(result.address);
editLatitude = String(result.latitude);
editLongitude = String(result.longitude);
if (result.category !== 'other') {
editCategory = result.category;
}
await saveField();
}
function onAddressSearchBlur() {
setTimeout(() => {
showAddressSuggestions = false;
}, 200);
}
async function removeTag(tagId: string) {
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
placesStore.updateTagIds(placeId, next)
);
}
async function saveField() {
detail.blur();
const lat = parseFloat(editLatitude);
const lng = parseFloat(editLongitude);
await placesStore.updatePlace(placeId, {
name: editName.trim() || $_('places.detail_view.untitled'),
description: editDescription.trim() || null,
address: editAddress.trim() || null,
category: editCategory,
latitude: isNaN(lat) ? 0 : lat,
longitude: isNaN(lng) ? 0 : lng,
} as Record<string, unknown>);
}
async function onCategoryChange(e: Event) {
editCategory = (e.target as HTMLSelectElement).value as PlaceCategory;
await saveField();
}
async function handleVisibilityChange(next: VisibilityLevel) {
await placesStore.setVisibility(placeId, next);
}
async function handleRegenerate() {
await placesStore.regenerateUnlistedToken(placeId);
}
async function handleRevoke() {
await placesStore.setVisibility(placeId, 'space');
}
async function handleExpiryChange(expiresAt: Date | null) {
await placesStore.setUnlistedExpiry(placeId, expiresAt);
}
const shareUrl = $derived.by(() => {
const token = detail.entity?.unlistedToken;
if (!token) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, token);
});
async function toggleFavorite() {
await placesStore.toggleFavorite(placeId);
}
async function deletePlace() {
await placesStore.deletePlace(placeId);
goBack();
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(get(locale) ?? 'de', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
let mapUrl = $derived.by(() => {
const place = detail.entity;
if (!place || !place.latitude || !place.longitude) return '';
const lat = place.latitude;
const lng = place.longitude;
const bbox = `${lng - 0.005},${lat - 0.003},${lng + 0.005},${lat + 0.003}`;
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`;
});
</script>
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel={$_('places.detail_view.not_found')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('places.detail_view.confirm_delete')}
onConfirmDelete={deletePlace}
>
{#snippet body(place)}
<div class="profile-header">
<div class="place-avatar">
<MapPin size={20} />
</div>
<div class="name-fields">
<input
class="name-input"
bind:value={editName}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('places.detail_view.name_placeholder')}
/>
</div>
<button class="fav-btn" class:active={place.isFavorite} onclick={toggleFavorite}>
<Star size={18} weight={place.isFavorite ? 'fill' : 'regular'} />
</button>
</div>
{#if mapUrl}
<div class="map-container">
<iframe
title={$_('places.detail_view.map_title')}
src={mapUrl}
width="100%"
height="160"
frameborder="0"
loading="lazy"
></iframe>
</div>
{/if}
<div class="fields">
<div class="field-row">
<span class="field-label">{$_('places.detail_view.label_visibility')}</span>
<VisibilityPicker level={place.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
{#if place.visibility === 'unlisted' && place.unlistedToken && shareUrl}
<div class="field-row field-row--share">
<span class="field-label">{$_('places.detail_view.label_share_link')}</span>
<SharedLinkControls
token={place.unlistedToken}
url={shareUrl}
expiresAt={place.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
{/if}
<div class="field-row">
<span class="field-label">{$_('places.detail_view.label_category')}</span>
<select class="field-select" value={editCategory} onchange={onCategoryChange}>
{#each CATEGORY_VALUES as v}
<option value={v}>{$_('places.categories.' + v)}</option>
{/each}
</select>
</div>
<div class="field-row">
<span class="field-label">{$_('places.detail_view.label_address')}</span>
<input
class="field-input"
bind:value={editAddress}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('places.detail_view.placeholder_address')}
/>
</div>
<!-- Address Search -->
<div class="field-row address-search-row">
<span class="field-label"></span>
<div class="address-search-wrapper">
<div class="address-search-input-row">
<MagnifyingGlass size={12} />
<input
class="address-search-input"
type="text"
placeholder={$_('places.detail_view.placeholder_address_search')}
bind:value={addressSearch}
oninput={onAddressSearchInput}
onblur={onAddressSearchBlur}
onfocus={() => {
if (addressSuggestions.length > 0) showAddressSuggestions = true;
}}
/>
</div>
{#if showAddressSuggestions}
<div class="address-suggestions">
{#each addressSuggestions as result}
<button class="address-suggestion" onclick={() => applyAddressResult(result)}>
<span class="address-suggestion-name">{result.name || result.label}</span>
<span class="address-suggestion-detail">{formatAddress(result.address)}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="field-row">
<span class="field-label">{$_('places.detail_view.label_coordinates')}</span>
<div class="coords-row">
<input
class="field-input small"
bind:value={editLatitude}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('places.detail_view.placeholder_lat')}
type="number"
step="any"
/>
<input
class="field-input small"
bind:value={editLongitude}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('places.detail_view.placeholder_lng')}
type="number"
step="any"
/>
<button
class="resolve-btn"
onclick={resolveAddress}
disabled={isResolving}
title={$_('places.detail_view.resolve_address_title')}
>
<ArrowsClockwise size={14} class={isResolving ? 'spinning' : ''} />
</button>
</div>
</div>
<div class="field-row">
<span class="field-label">{$_('places.detail_view.label_description')}</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('places.detail_view.placeholder_description')}
rows={2}
></textarea>
</div>
</div>
{#if placeTags.length > 0}
<div class="section">
<span class="section-label">{$_('places.detail_view.section_tags')}</span>
<div class="tags-list">
{#each placeTags as tag (tag.id)}
<button
class="tag-pill"
style="--tag-color: {tag.color}"
onclick={() => removeTag(tag.id)}
>
<span class="tag-dot" style="background: {tag.color}"></span>
{tag.name}
<X size={10} />
</button>
{/each}
</div>
</div>
{/if}
<LinkedItems recordRef={{ app: 'places', collection: 'places', id: placeId }} {navigate} />
{#if logs.length > 0}
<div class="section">
<span class="section-label">{$_('places.detail_view.section_recent_visits')}</span>
<div class="log-list">
{#each logs as log (log.id)}
<div class="log-row">
<span class="log-time">{formatDate(log.timestamp)}</span>
{#if log.accuracy}
<span class="log-accuracy">±{Math.round(log.accuracy)}m</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="meta">
{#if (place.visitCount ?? 0) > 0}
<span>{$_('places.detail_view.meta_visits', { values: { n: place.visitCount } })}</span>
{/if}
{#if place.lastVisitedAt}
<span
>{$_('places.detail_view.meta_last_visit', {
values: { date: formatDate(place.lastVisitedAt) },
})}</span
>
{/if}
{#if place.createdAt}
<span
>{$_('places.detail_view.meta_created', {
values: { date: new Date(place.createdAt).toLocaleDateString(get(locale) ?? 'de') },
})}</span
>
{/if}
{#if place.updatedAt}
<span
>{$_('places.detail_view.meta_updated', {
values: { date: new Date(place.updatedAt).toLocaleDateString(get(locale) ?? 'de') },
})}</span
>
{/if}
</div>
{/snippet}
</DetailViewShell>
<style>
.profile-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.place-avatar {
width: 48px;
height: 48px;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.name-fields {
flex: 1;
min-width: 0;
}
.name-input {
width: 100%;
font-size: 1rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
outline: none;
color: hsl(var(--color-foreground));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
.name-input:hover,
.name-input:focus {
border-color: hsl(var(--color-border));
}
.map-container {
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid hsl(var(--color-border));
}
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.field-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
min-width: 5.5rem;
padding-top: 0.375rem;
}
.field-input,
.field-select {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: hsl(var(--color-foreground));
outline: none;
text-align: right;
transition: border-color 0.15s;
}
.field-input:hover,
.field-input:focus,
.field-select:hover,
.field-select:focus {
border-color: hsl(var(--color-border));
}
.field-input.small {
max-width: 6rem;
}
.coords-row {
display: flex;
gap: 0.25rem;
flex: 1;
justify-content: flex-end;
align-items: center;
}
.resolve-btn {
padding: 0.25rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.15s;
flex-shrink: 0;
}
.resolve-btn:hover:not(:disabled) {
color: #0ea5e9;
border-color: hsl(var(--color-border));
}
.resolve-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.resolve-btn :global(.spinning) {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── Address Search (Detail) ─────────────── */
.address-search-row {
align-items: flex-start;
}
.address-search-wrapper {
flex: 1;
position: relative;
min-width: 0;
}
.address-search-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
color: hsl(var(--color-muted-foreground));
transition: border-color 0.15s;
}
.address-search-input-row:focus-within {
border-color: hsl(var(--color-border));
}
.address-search-input {
flex: 1;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
outline: none;
text-align: right;
}
.address-search-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.address-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.125rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
overflow: hidden;
}
.address-suggestion {
display: flex;
flex-direction: column;
gap: 0.0625rem;
padding: 0.375rem 0.5rem;
width: 100%;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
cursor: pointer;
text-align: left;
font-size: 0.75rem;
}
.address-suggestion:hover {
background: hsl(var(--color-muted));
}
.address-suggestion + .address-suggestion {
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
.address-suggestion-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-suggestion-detail {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--tag-color) 12%, transparent);
border: none;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.tag-pill:hover {
opacity: 0.8;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
}
.log-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
}
.log-row {
display: flex;
justify-content: space-between;
color: hsl(var(--color-muted-foreground));
}
.log-accuracy {
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -27,7 +27,6 @@ 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 { LocalRecipe } from '$lib/modules/recipes/types';
import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types';
import type { LocalQuiz } from '$lib/modules/quiz/types';
@ -63,9 +62,6 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'goals.goals':
items = await resolveGoals(props);
break;
case 'places.places':
items = await resolvePlaces(props);
break;
case 'recipes.recipes':
items = await resolveRecipes(props);
break;
@ -385,49 +381,6 @@ function formatGoalProgress(g: LocalGoal): string {
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,
}));
}
/**
* Recipes: "my tested recipes" / "cookbook". Hard-gated on
* canEmbedOnWebsite.

View file

@ -1,12 +0,0 @@
<script lang="ts">
import ListView from '$lib/modules/places/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Places - Mana</title>
</svelte:head>
<RoutePage appId="places">
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
</RoutePage>

View file

@ -6,7 +6,6 @@
<script lang="ts">
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
import SharedLibraryEntryView from '$lib/modules/library/SharedLibraryEntryView.svelte';
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte';
import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte';
import SharedFormView from '$lib/modules/forms/SharedFormView.svelte';
@ -31,8 +30,6 @@
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'libraryEntries'}
<SharedLibraryEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'places'}
<SharedPlaceView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'augurEntries'}
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'lasts'}

View file

@ -634,23 +634,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'places',
name: 'Places',
description: {
de: 'Standort-Tracking',
en: 'Location Tracking',
},
longDescription: {
de: 'Tracke deinen Standort, erstelle Orte und sieh deine Bewegungshistorie.',
en: 'Track your location, create places, and view your movement history.',
},
icon: APP_ICONS.places,
color: '#0ea5e9',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'drink',
name: 'Drink',