mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 00:21:26 +02:00
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
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:
parent
0112161e78
commit
7b842cabaf
35 changed files with 7 additions and 3374 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,6 @@ export interface DaySnapshot {
|
|||
coffee: { ml: number; count: number };
|
||||
total: { ml: number; count: number };
|
||||
};
|
||||
|
||||
places: {
|
||||
visitedToday: number;
|
||||
tracking: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Streaks ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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[],
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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)}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue