feat(manacore/web): add places module with GPS location tracking

New local-first places module for the workbench: browser Geolocation API
tracking, place management (CRUD, favorites, tags, categories), OSM map
preview in detail view, and proximity-based visit detection.

Also allows geolocation in Permissions-Policy header (self only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 14:33:56 +02:00
parent e17d6228e4
commit 8f5727fd51
15 changed files with 1656 additions and 2 deletions

View file

@ -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',

View file

@ -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<string, string[]> = {
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
};

View file

@ -0,0 +1,481 @@
<!--
Places — Workbench ListView
Location tracking toggle, place list with search + quick create.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalPlace } from './types';
import { placesStore } from './stores/places.svelte';
import { trackingStore } from './stores/tracking.svelte';
import { Star, MapPin, Plus } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
let { navigate, goBack, params }: ViewProps = $props();
const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []);
function handleTagDrop(placeId: string, tagData: TagDragData) {
const place = places.find((p) => p.id === placeId);
if (!place) return;
const current = place.tagIds ?? [];
if (!current.includes(tagData.id)) {
placesStore.updateTagIds(placeId, [...current, tagData.id]);
}
}
let places = $state<LocalPlace[]>([]);
let search = $state('');
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalPlace>('places')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt && !p.isArchived));
}).subscribe((val) => {
places = val ?? [];
});
return () => sub.unsubscribe();
});
const filtered = $derived(() => {
if (!search.trim()) return places;
const q = search.toLowerCase();
return places.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.address?.toLowerCase().includes(q) ||
p.category?.toLowerCase().includes(q)
);
});
const CATEGORY_LABELS: Record<string, string> = {
home: 'Zuhause',
work: 'Arbeit',
food: 'Essen',
shopping: 'Einkauf',
transit: 'Transit',
leisure: 'Freizeit',
other: 'Sonstiges',
};
// Quick create
let newName = $state('');
async function createPlace() {
const name = newName.trim();
if (!name) return;
// Use current position if tracking, otherwise default to 0,0
const lat = trackingStore.currentPosition?.coords.latitude ?? 0;
const lng = trackingStore.currentPosition?.coords.longitude ?? 0;
const place = await placesStore.createPlace({ name, latitude: lat, longitude: lng });
newName = '';
navigate('detail', { placeId: place.id });
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
createPlace();
}
}
function openDetail(placeId: string) {
const ids = filtered().map((p) => p.id);
navigate('detail', { placeId, _siblingIds: ids, _siblingKey: 'placeId' });
}
function formatCoords(lat: number, lng: number): string {
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
</script>
<div class="places-list-view">
<!-- Tracking Section -->
<div class="tracking-section">
<div class="tracking-row">
<button
class="tracking-toggle"
class:active={trackingStore.isTracking}
onclick={() =>
trackingStore.isTracking ? trackingStore.stopTracking() : trackingStore.startTracking()}
>
<span class="tracking-dot" class:pulse={trackingStore.isTracking}></span>
{trackingStore.isTracking ? 'Tracking aktiv' : 'Tracking starten'}
</button>
{#if trackingStore.currentPosition}
<span class="tracking-coords">
{formatCoords(
trackingStore.currentPosition.coords.latitude,
trackingStore.currentPosition.coords.longitude
)}
</span>
{/if}
</div>
{#if trackingStore.error}
<span class="tracking-error">{trackingStore.error}</span>
{/if}
</div>
<!-- Search -->
<div class="search-row">
<input class="search-input" type="text" placeholder="Orte suchen..." bind:value={search} />
</div>
<!-- Quick Create -->
<div class="create-row">
<input
class="create-input"
type="text"
placeholder="Neuen Ort erstellen..."
bind:value={newName}
onkeydown={handleKeydown}
/>
<button class="create-btn" onclick={createPlace} disabled={!newName.trim()}>
<Plus size={14} />
</button>
</div>
<!-- Place List -->
<div class="place-list">
{#each filtered() as place (place.id)}
{@const tags = getTagsByIds(allTags, place.tagIds ?? [])}
<button
class="place-item"
onclick={() => openDetail(place.id)}
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(place.id, payload.data as unknown as TagDragData),
}}
use:dragSource={{
type: 'place',
data: () => ({ ...place }),
}}
>
<div class="place-icon">
<MapPin size={16} />
</div>
<div class="place-info">
<span class="place-name">
{place.name}
{#if place.isFavorite}
<Star size={10} weight="fill" class="fav-star" />
{/if}
</span>
<span class="place-meta">
{#if place.category}
<span class="category-badge">{CATEGORY_LABELS[place.category] ?? place.category}</span
>
{/if}
{#if place.latitude && place.longitude}
<span class="coords">{formatCoords(place.latitude, place.longitude)}</span>
{/if}
</span>
{#if tags.length > 0}
<div class="place-tags">
{#each tags as tag (tag.id)}
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
{/each}
</div>
{/if}
</div>
<div class="place-stats">
{#if (place.visitCount ?? 0) > 0}
<span class="visit-count">{place.visitCount}x</span>
{/if}
</div>
</button>
{/each}
</div>
{#if filtered().length === 0 && !search}
<div class="empty">
<p>Noch keine Orte gespeichert.</p>
<p class="empty-hint">Starte das Tracking oder erstelle einen Ort manuell.</p>
</div>
{/if}
{#if filtered().length === 0 && search}
<div class="empty">
<p>Keine Orte gefunden.</p>
</div>
{/if}
</div>
<style>
.places-list-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
}
/* ── Tracking ──────────────────────────────── */
.tracking-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.tracking-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tracking-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.15));
background: transparent;
color: var(--color-foreground);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.tracking-toggle.active {
background: rgba(14, 165, 233, 0.1);
border-color: rgba(14, 165, 233, 0.3);
color: #0ea5e9;
}
.tracking-toggle:hover {
border-color: #0ea5e9;
}
.tracking-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #6b7280;
flex-shrink: 0;
}
.tracking-dot.pulse {
background: #0ea5e9;
animation: dot-pulse 2s ease-in-out infinite;
}
.tracking-coords {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
font-variant-numeric: tabular-nums;
}
.tracking-error {
font-size: 0.6875rem;
color: #ef4444;
}
/* ── Search ───────────────────────────────── */
.search-row {
display: flex;
}
.search-input {
flex: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: transparent;
color: var(--color-foreground);
font-size: 0.8125rem;
outline: none;
}
.search-input:focus {
border-color: #0ea5e9;
}
.search-input::placeholder {
color: var(--color-muted-foreground);
}
/* ── Quick Create ─────────────────────────── */
.create-row {
display: flex;
gap: 0.375rem;
}
.create-input {
flex: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: transparent;
color: var(--color-foreground);
font-size: 0.8125rem;
outline: none;
}
.create-input:focus {
border-color: #0ea5e9;
}
.create-input::placeholder {
color: var(--color-muted-foreground);
}
.create-btn {
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: transparent;
color: var(--color-foreground);
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.15s;
}
.create-btn:hover:not(:disabled) {
background: rgba(14, 165, 233, 0.1);
border-color: #0ea5e9;
color: #0ea5e9;
}
.create-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Place List ───────────────────────────── */
.place-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.place-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.15s;
width: 100%;
}
.place-item:hover {
background: var(--color-surface, rgba(255, 255, 255, 0.04));
}
.place-icon {
color: #0ea5e9;
display: flex;
flex-shrink: 0;
}
.place-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.place-name {
font-size: 0.8125rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.place-name :global(.fav-star) {
color: #f59e0b;
flex-shrink: 0;
}
.place-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
color: var(--color-muted-foreground);
}
.category-badge {
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
font-size: 0.625rem;
font-weight: 500;
}
.coords {
font-variant-numeric: tabular-nums;
}
.place-tags {
display: flex;
gap: 0.25rem;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.place-stats {
flex-shrink: 0;
}
.visit-count {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-muted-foreground);
font-variant-numeric: tabular-nums;
}
/* ── Empty ────────────────────────────────── */
.empty {
text-align: center;
color: var(--color-muted-foreground);
font-size: 0.8125rem;
padding: 2rem 0;
}
.empty-hint {
font-size: 0.75rem;
opacity: 0.7;
margin-top: 0.25rem;
}
@keyframes dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
</style>

View file

@ -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<LocalPlace>('places');
export const locationLogTable = db.table<LocalLocationLog>('locationLogs');
// ─── Guest Seed ────────────────────────────────────────────
export const PLACES_GUEST_SEED = {
places: [
{
id: 'guest-place-home',
name: 'Zuhause',
latitude: 47.6603,
longitude: 9.1751,
category: 'home' as const,
isFavorite: true,
isArchived: false,
visitCount: 12,
lastVisitedAt: new Date().toISOString(),
},
{
id: 'guest-place-work',
name: 'Buero',
latitude: 47.6588,
longitude: 9.1753,
category: 'work' as const,
isFavorite: false,
isArchived: false,
visitCount: 8,
},
] satisfies LocalPlace[],
locationLogs: [] satisfies LocalLocationLog[],
};

View file

@ -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';

View file

@ -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<LocalPlace>('places').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlace);
});
}
export function useLocationLogs(placeId?: string) {
return liveQuery(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;
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;
}

View file

@ -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<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;
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(),
});
},
};

View file

@ -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<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;
_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<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
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,
};

View file

@ -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;
}

View file

@ -0,0 +1,603 @@
<!--
Places — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { placesStore } from '../stores/places.svelte';
import { Trash, Star, MapPin, X } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let placeId = $derived(params.placeId as string);
let place = $state<LocalPlace | null>(null);
let logs = $state<LocalLocationLog[]>([]);
let confirmDelete = $state(false);
let focused = $state(false);
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 ?? []);
let placeTags = $derived(getTagsByIds(allTags, place?.tagIds ?? []));
const CATEGORIES: { value: PlaceCategory; label: string }[] = [
{ value: 'home', label: 'Zuhause' },
{ value: 'work', label: 'Arbeit' },
{ value: 'food', label: 'Essen' },
{ value: 'shopping', label: 'Einkauf' },
{ value: 'transit', label: 'Transit' },
{ value: 'leisure', label: 'Freizeit' },
{ value: 'other', label: 'Sonstiges' },
];
async function removeTag(tagId: string) {
const current = place?.tagIds ?? [];
await placesStore.updateTagIds(
placeId,
current.filter((id) => id !== tagId)
);
}
$effect(() => {
placeId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalPlace>('places').get(placeId)).subscribe((val) => {
place = val ?? null;
if (val && !focused) syncFields(val);
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(() =>
db
.table<LocalLocationLog>('locationLogs')
.where('placeId')
.equals(placeId)
.reverse()
.sortBy('timestamp')
).subscribe((val) => {
logs = (val ?? []).slice(0, 20);
});
return () => sub.unsubscribe();
});
function syncFields(p: LocalPlace) {
editName = p.name ?? '';
editDescription = p.description ?? '';
editAddress = p.address ?? '';
editCategory = p.category ?? 'other';
editLatitude = p.latitude?.toString() ?? '';
editLongitude = p.longitude?.toString() ?? '';
}
async function saveField() {
focused = false;
const lat = parseFloat(editLatitude);
const lng = parseFloat(editLongitude);
await placesStore.updatePlace(placeId, {
name: editName.trim() || 'Unbenannt',
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 toggleFavorite() {
await placesStore.toggleFavorite(placeId);
}
async function deletePlace() {
await placesStore.deletePlace(placeId);
goBack();
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
let mapUrl = $derived.by(() => {
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>
<div class="detail-view">
{#if !place}
<p class="empty">Ort nicht gefunden</p>
{:else}
<!-- Header -->
<div class="profile-header">
<div class="place-avatar">
<MapPin size={20} />
</div>
<div class="name-fields">
<input
class="name-input"
bind:value={editName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Name"
/>
</div>
<button class="fav-btn" class:active={place.isFavorite} onclick={toggleFavorite}>
<Star size={18} weight={place.isFavorite ? 'fill' : 'regular'} />
</button>
</div>
<!-- Map Preview -->
{#if mapUrl}
<div class="map-container">
<iframe
title="Kartenvorschau"
src={mapUrl}
width="100%"
height="160"
frameborder="0"
loading="lazy"
></iframe>
</div>
{/if}
<!-- Fields -->
<div class="fields">
<div class="field-row">
<span class="field-label">Kategorie</span>
<select class="field-select" value={editCategory} onchange={onCategoryChange}>
{#each CATEGORIES as cat}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="field-row">
<span class="field-label">Adresse</span>
<input
class="field-input"
bind:value={editAddress}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Adresse eingeben..."
/>
</div>
<div class="field-row">
<span class="field-label">Koordinaten</span>
<div class="coords-row">
<input
class="field-input small"
bind:value={editLatitude}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Lat"
type="number"
step="any"
/>
<input
class="field-input small"
bind:value={editLongitude}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Lng"
type="number"
step="any"
/>
</div>
</div>
<div class="field-row">
<span class="field-label">Beschreibung</span>
<textarea
class="field-textarea"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Notizen zum Ort..."
rows={2}
></textarea>
</div>
</div>
<!-- Tags -->
{#if placeTags.length > 0}
<div class="section">
<span class="section-label">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}
<!-- Links -->
<LinkedItems recordRef={{ app: 'places', collection: 'places', id: placeId }} {navigate} />
<!-- Visit Log -->
{#if logs.length > 0}
<div class="section">
<span class="section-label">Letzte Besuche</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">&pm;{Math.round(log.accuracy)}m</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Stats & Meta -->
<div class="meta">
{#if (place.visitCount ?? 0) > 0}
<span>Besuche: {place.visitCount}</span>
{/if}
{#if place.lastVisitedAt}
<span>Letzter Besuch: {formatDate(place.lastVisitedAt)}</span>
{/if}
{#if place.createdAt}
<span>Erstellt: {new Date(place.createdAt).toLocaleDateString('de')}</span>
{/if}
{#if place.updatedAt}
<span>Bearbeitet: {new Date(place.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Ort wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deletePlace}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Header */
.profile-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.place-avatar {
width: 48px;
height: 48px;
border-radius: 9999px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
}
.name-fields {
flex: 1;
min-width: 0;
}
.name-input {
font-size: 0.9375rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
width: 100%;
}
.name-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.name-input::placeholder {
color: #c0bfba;
font-weight: 400;
}
:global(.dark) .name-input {
color: #f3f4f6;
}
:global(.dark) .name-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .name-input::placeholder {
color: #4b5563;
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
color: #d1d5db;
padding: 0.25rem;
transition: color 0.15s;
flex-shrink: 0;
}
.fav-btn.active {
color: #f59e0b;
}
.fav-btn:hover {
color: #f59e0b;
}
/* Map */
.map-container {
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.map-container iframe {
display: block;
}
/* Fields */
.fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.field-input,
.field-textarea,
.field-select {
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
.field-input:hover,
.field-input:focus,
.field-textarea:hover,
.field-textarea:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.field-input::placeholder,
.field-textarea::placeholder {
color: #c0bfba;
}
.field-input.small {
flex: 1;
}
.field-select {
cursor: pointer;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.field-textarea {
resize: vertical;
}
:global(.dark) .field-input,
:global(.dark) .field-textarea,
:global(.dark) .field-select {
color: #e5e7eb;
}
:global(.dark) .field-input:hover,
:global(.dark) .field-input:focus,
:global(.dark) .field-textarea:hover,
:global(.dark) .field-textarea:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .field-input::placeholder,
:global(.dark) .field-textarea::placeholder {
color: #4b5563;
}
:global(.dark) .field-select {
border-color: rgba(255, 255, 255, 0.1);
background: transparent;
}
.coords-row {
display: flex;
gap: 0.375rem;
}
/* Tags */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.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;
border: none;
background: color-mix(in srgb, var(--tag-color) 12%, transparent);
font-size: 0.6875rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 20%, transparent);
color: #ef4444;
}
:global(.dark) .tag-pill {
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
color: #ef4444;
}
.tag-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
/* Visit Log */
.log-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.log-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0;
font-size: 0.75rem;
}
.log-time {
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.log-accuracy {
color: var(--color-muted-foreground);
font-size: 0.6875rem;
}
/* Meta */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
/* Delete */
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
</style>

View file

@ -30,6 +30,7 @@ const SPLIT_APP_ID_LIST = [
'calc',
'moodlit',
'memoro',
'places',
'playground',
] as const;

View file

@ -140,6 +140,9 @@ export const APP_ICONS = {
finance: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fn)"/><circle cx="50" cy="50" r="22" stroke="white" stroke-width="4" fill="none"/><path d="M50 34v32M42 42c0-4 3.5-6 8-6s8 2 8 6-3.5 5-8 5-8 2-8 6 3.5 6 8 6 8-2 8-6" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
),
places: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="plc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0ea5e9"/><stop offset="100%" style="stop-color:#0284c7"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#plc)"/><path d="M50 20c-14 0-25 11-25 25 0 20 25 38 25 38s25-18 25-38c0-14-11-25-25-25zm0 34a9 9 0 1 1 0-18 9 9 0 0 1 0 18z" fill="white"/></svg>`
),
arcade: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),

View file

@ -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<AppIconId, { dev: string; prod: string }> = {
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' },

View file

@ -22,7 +22,8 @@ export type DragType =
| 'contact'
| 'habit'
| 'note'
| 'transaction';
| 'transaction'
| 'place';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;

View file

@ -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 = [