diff --git a/apps/manacore/apps/web/src/lib/app-registry/apps.ts b/apps/manacore/apps/web/src/lib/app-registry/apps.ts index 61acf8312..dfcbb6ab5 100644 --- a/apps/manacore/apps/web/src/lib/app-registry/apps.ts +++ b/apps/manacore/apps/web/src/lib/app-registry/apps.ts @@ -229,6 +229,40 @@ registerApp({ }, }); +registerApp({ + id: 'places', + name: 'Places', + color: '#0EA5E9', + 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; + }, +}); + registerApp({ id: 'chat', name: 'Chat', diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 3f043b8c2..91772bee8 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -188,6 +188,11 @@ db.version(1).stores({ financeCategories: 'id, type, order', budgets: 'id, categoryId, month, [month+categoryId]', + // ─── Places (appId: 'places') ─── + places: 'id, name, category, isFavorite, isArchived, latitude, longitude', + locationLogs: 'id, placeId, timestamp, [placeId+timestamp]', + placeTags: 'id, placeId, tagId, [placeId+tagId]', + // ─── Shared: Global Tags (appId: 'tags') ─── globalTags: 'id, name, groupId', tagGroups: 'id', @@ -239,6 +244,7 @@ export const SYNC_APP_MAP: Record = { habits: ['habits', 'habitLogs'], notes: ['notes', 'noteTags'], finance: ['transactions', 'financeCategories', 'budgets'], + places: ['places', 'locationLogs', 'placeTags'], tags: ['globalTags', 'tagGroups'], links: ['manaLinks'], }; diff --git a/apps/manacore/apps/web/src/lib/modules/places/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/places/ListView.svelte new file mode 100644 index 000000000..9ee0066e5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/ListView.svelte @@ -0,0 +1,481 @@ + + + +
+ +
+
+ + {#if trackingStore.currentPosition} + + {formatCoords( + trackingStore.currentPosition.coords.latitude, + trackingStore.currentPosition.coords.longitude + )} + + {/if} +
+ {#if trackingStore.error} + {trackingStore.error} + {/if} +
+ + +
+ +
+ + +
+ + +
+ + +
+ {#each filtered() as place (place.id)} + {@const tags = getTagsByIds(allTags, place.tagIds ?? [])} + + {/each} +
+ + {#if filtered().length === 0 && !search} +
+

Noch keine Orte gespeichert.

+

Starte das Tracking oder erstelle einen Ort manuell.

+
+ {/if} + + {#if filtered().length === 0 && search} +
+

Keine Orte gefunden.

+
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/places/collections.ts b/apps/manacore/apps/web/src/lib/modules/places/collections.ts new file mode 100644 index 000000000..698fe9c57 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/collections.ts @@ -0,0 +1,40 @@ +/** + * 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('places'); +export const locationLogTable = db.table('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[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/places/index.ts b/apps/manacore/apps/web/src/lib/modules/places/index.ts new file mode 100644 index 000000000..698457e9f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/index.ts @@ -0,0 +1,19 @@ +/** + * 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'; +export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/places/queries.ts b/apps/manacore/apps/web/src/lib/modules/places/queries.ts new file mode 100644 index 000000000..5f0bc09e8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/queries.ts @@ -0,0 +1,115 @@ +/** + * Reactive queries & pure helpers for Places — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +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 ?? [], + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +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 liveQuery(async () => { + const locals = await db.table('places').toArray(); + return locals.filter((p) => !p.deletedAt).map(toPlace); + }); +} + +export function useLocationLogs(placeId?: string) { + return liveQuery(async () => { + let query = db.table('locationLogs').orderBy('timestamp').reverse(); + const locals = await query.toArray(); + const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals; + return filtered.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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/manacore/apps/web/src/lib/modules/places/stores/places.svelte.ts new file mode 100644 index 000000000..719d83653 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -0,0 +1,92 @@ +/** + * Places Store — Mutation-Only + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only exposes mutations that write to IndexedDB. + */ + +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, + createdAt: now, + updatedAt: now, + }; + + await placeTable.add(newLocal); + return toPlace(newLocal); + }, + + async updatePlace(id: string, data: Partial & Record) { + const updateData: Partial = {}; + 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; + + await placeTable.update(id, { + ...updateData, + updatedAt: new Date().toISOString(), + }); + }, + + async deletePlace(id: string) { + await placeTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string) { + const local = await placeTable.get(id); + if (!local) return; + + await placeTable.update(id, { + isFavorite: !local.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async updateTagIds(id: string, tagIds: string[]) { + await placeTable.update(id, { + tagIds, + updatedAt: new Date().toISOString(), + }); + }, + + async recordVisit(id: string) { + const local = await placeTable.get(id); + if (!local) return; + + await placeTable.update(id, { + visitCount: (local.visitCount ?? 0) + 1, + lastVisitedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/manacore/apps/web/src/lib/modules/places/stores/tracking.svelte.ts new file mode 100644 index 000000000..f8883f98d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -0,0 +1,178 @@ +/** + * 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 { locationLogTable, placeTable } from '../collections'; +import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; +import type { LocalLocationLog, LocalPlace } from '../types'; + +// ─── State ────────────────────────────────────────────── + +let isTracking = $state(false); +let currentPosition = $state(null); +let error = $state(null); +let permissionState = $state('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 { + 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; + + _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; +} + +async function getCurrentPosition(): Promise { + 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 + const allLocals = await placeTable.toArray(); + const places = allLocals.filter((p) => !p.deletedAt).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(), + updatedAt: new Date().toISOString(), + }; + + await locationLogTable.add(log); + + // Update visit count on the matched place + if (nearest) { + const local = await placeTable.get(nearest.id); + if (local) { + await placeTable.update(nearest.id, { + visitCount: (local.visitCount ?? 0) + 1, + lastVisitedAt: log.timestamp, + updatedAt: new Date().toISOString(), + }); + } + } +} + +// ─── 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, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/places/types.ts b/apps/manacore/apps/web/src/lib/modules/places/types.ts new file mode 100644 index 000000000..f7330a90b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/types.ts @@ -0,0 +1,63 @@ +/** + * Places module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export type PlaceCategory = 'home' | 'work' | 'food' | '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[]; +} + +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[]; + 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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/places/views/DetailView.svelte new file mode 100644 index 000000000..9a51171a4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -0,0 +1,603 @@ + + + +
+ {#if !place} +

Ort nicht gefunden

+ {:else} + +
+
+ +
+
+ (focused = true)} + onblur={saveField} + placeholder="Name" + /> +
+ +
+ + + {#if mapUrl} +
+ +
+ {/if} + + +
+
+ Kategorie + +
+ +
+ Adresse + (focused = true)} + onblur={saveField} + placeholder="Adresse eingeben..." + /> +
+ +
+ Koordinaten +
+ (focused = true)} + onblur={saveField} + placeholder="Lat" + type="number" + step="any" + /> + (focused = true)} + onblur={saveField} + placeholder="Lng" + type="number" + step="any" + /> +
+
+ +
+ Beschreibung + +
+
+ + + {#if placeTags.length > 0} +
+ +
+ {#each placeTags as tag (tag.id)} + + {/each} +
+
+ {/if} + + + + + + {#if logs.length > 0} +
+ +
+ {#each logs as log (log.id)} +
+ {formatDate(log.timestamp)} + {#if log.accuracy} + ±{Math.round(log.accuracy)}m + {/if} +
+ {/each} +
+
+ {/if} + + +
+ {#if (place.visitCount ?? 0) > 0} + Besuche: {place.visitCount} + {/if} + {#if place.lastVisitedAt} + Letzter Besuch: {formatDate(place.lastVisitedAt)} + {/if} + {#if place.createdAt} + Erstellt: {new Date(place.createdAt).toLocaleDateString('de')} + {/if} + {#if place.updatedAt} + Bearbeitet: {new Date(place.updatedAt).toLocaleDateString('de')} + {/if} +
+ + +
+ {#if confirmDelete} +

Ort wirklich loeschen?

+
+ + +
+ {:else} + + {/if} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/splitscreen/registry.ts b/apps/manacore/apps/web/src/lib/splitscreen/registry.ts index ddabbd640..db56ea4b7 100644 --- a/apps/manacore/apps/web/src/lib/splitscreen/registry.ts +++ b/apps/manacore/apps/web/src/lib/splitscreen/registry.ts @@ -30,6 +30,7 @@ const SPLIT_APP_ID_LIST = [ 'calc', 'moodlit', 'memoro', + 'places', 'playground', ] as const; diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 582d16f6a..19d3e5d18 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -140,6 +140,9 @@ export const APP_ICONS = { finance: svgToDataUrl( `` ), + places: svgToDataUrl( + `` + ), arcade: svgToDataUrl( `` ), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 2bb1745a9..f82cca187 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'founder', }, + { + 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: 'founder', + }, { id: 'arcade', name: 'Arcade', @@ -763,6 +780,7 @@ export const APP_URLS: Record = { habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' }, notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' }, finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' }, + places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' }, wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' }, news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' }, mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' }, diff --git a/packages/shared-ui/src/dnd/types.ts b/packages/shared-ui/src/dnd/types.ts index 926156bce..76ba6fae2 100644 --- a/packages/shared-ui/src/dnd/types.ts +++ b/packages/shared-ui/src/dnd/types.ts @@ -22,7 +22,8 @@ export type DragType = | 'contact' | 'habit' | 'note' - | 'transaction'; + | 'transaction' + | 'place'; export interface DragPayload> { type: DragType; diff --git a/packages/shared-utils/src/security-headers.ts b/packages/shared-utils/src/security-headers.ts index 898a663c4..256702793 100644 --- a/packages/shared-utils/src/security-headers.ts +++ b/packages/shared-utils/src/security-headers.ts @@ -49,7 +49,7 @@ export function setSecurityHeaders(response: Response, options: SecurityHeadersO response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)'); // Content Security Policy const cspDirectives = [