From bd1178edf816cebd12cb90c46d8a6602c2813ae5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 15 Mar 2026 08:12:42 +0100 Subject: [PATCH] feat(traces): integrate traces app into monorepo with NestJS backend and AI city guides Restructure standalone traces app into monorepo pattern with mobile + backend + shared types. Add NestJS backend with Drizzle ORM schema for locations, cities, places, POIs, and AI guides. Add mobile sync layer, cities tab, and guide generation UI. Fix pre-existing type errors across mobile codebase, matrix-mana-bot (sendDirectMessage), llm-playground, and all web auth stores (signUp call signature). Co-Authored-By: Claude Opus 4.6 --- .env.development | 7 + .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- apps/traces/.gitignore | 25 + apps/traces/CLAUDE.md | 76 + apps/traces/apps/backend/.gitignore | 4 + apps/traces/apps/backend/drizzle.config.ts | 3 + apps/traces/apps/backend/nest-cli.json | 11 + apps/traces/apps/backend/package.json | 52 + apps/traces/apps/backend/src/app.module.ts | 41 + .../apps/backend/src/city/city.controller.ts | 20 + .../apps/backend/src/city/city.module.ts | 10 + .../apps/backend/src/city/city.service.ts | 147 + apps/traces/apps/backend/src/db/connection.ts | 36 + .../apps/backend/src/db/database.module.ts | 30 + apps/traces/apps/backend/src/db/schema.ts | 231 ++ .../backend/src/guide/guide.controller.ts | 31 + .../apps/backend/src/guide/guide.module.ts | 13 + .../apps/backend/src/guide/guide.service.ts | 402 +++ .../src/location/location.controller.ts | 21 + .../backend/src/location/location.module.ts | 12 + .../backend/src/location/location.service.ts | 100 + apps/traces/apps/backend/src/main.ts | 8 + .../backend/src/place/place.controller.ts | 35 + .../apps/backend/src/place/place.module.ts | 12 + .../apps/backend/src/place/place.service.ts | 73 + .../apps/backend/src/poi/poi.controller.ts | 20 + .../traces/apps/backend/src/poi/poi.module.ts | 10 + .../apps/backend/src/poi/poi.service.ts | 105 + apps/traces/apps/backend/tsconfig.json | 27 + apps/traces/apps/mobile/.gitignore | 25 + apps/traces/apps/mobile/README.md | 290 ++ apps/traces/apps/mobile/app-env.d.ts | 2 + apps/traces/apps/mobile/app.json | 90 + .../traces/apps/mobile/app/(tabs)/_layout.tsx | 32 + .../apps/mobile/app/(tabs)/cities/_layout.tsx | 5 + .../apps/mobile/app/(tabs)/cities/index.tsx | 140 + .../apps/mobile/app/(tabs)/guides/_layout.tsx | 5 + .../apps/mobile/app/(tabs)/guides/index.tsx | 137 + .../mobile/app/(tabs)/history/_layout.tsx | 28 + .../apps/mobile/app/(tabs)/history/index.tsx | 122 + apps/traces/apps/mobile/app/(tabs)/index.tsx | 243 ++ apps/traces/apps/mobile/app/(tabs)/logs.tsx | 80 + apps/traces/apps/mobile/app/(tabs)/map.tsx | 737 +++++ .../apps/mobile/app/(tabs)/places/_layout.tsx | 28 + .../apps/mobile/app/(tabs)/places/index.tsx | 182 ++ apps/traces/apps/mobile/app/+html.tsx | 57 + apps/traces/apps/mobile/app/+not-found.tsx | 23 + apps/traces/apps/mobile/app/_layout.tsx | 76 + apps/traces/apps/mobile/app/city-detail.tsx | 220 ++ apps/traces/apps/mobile/app/guide-detail.tsx | 178 ++ apps/traces/apps/mobile/app/modal.tsx | 172 ++ apps/traces/apps/mobile/app/place-details.tsx | 149 + apps/traces/apps/mobile/app/settings.tsx | 746 +++++ .../apps/mobile/assets/adaptive-icon.png | Bin 0 -> 17547 bytes apps/traces/apps/mobile/assets/favicon.png | Bin 0 -> 1466 bytes apps/traces/apps/mobile/assets/icon.png | Bin 0 -> 15268 bytes apps/traces/apps/mobile/assets/splash.png | Bin 0 -> 47346 bytes .../Assets/Traces App Icon 01.3-1.svg | 3 + .../Assets/Traces App Icon 01.3.svg | 3 + .../apps/mobile/assets/traces.icon/icon.json | 40 + apps/traces/apps/mobile/babel.config.js | 10 + apps/traces/apps/mobile/cesconfig.json | 40 + apps/traces/apps/mobile/components/Button.tsx | 39 + .../apps/mobile/components/CitiesList.tsx | 208 ++ .../components/ConsolidatedLocationList.tsx | 491 ++++ .../apps/mobile/components/CountriesList.tsx | 226 ++ .../apps/mobile/components/HeaderButton.tsx | 35 + .../mobile/components/LocationHistoryList.tsx | 473 +++ .../apps/mobile/components/LocationMap.tsx | 259 ++ .../apps/mobile/components/LogsList.tsx | 183 ++ .../mobile/components/PhotoImportModal.tsx | 489 +++ .../apps/mobile/components/PlaceDetail.tsx | 400 +++ .../apps/mobile/components/PlacesList.tsx | 256 ++ .../mobile/components/SegmentedControl.tsx | 127 + .../apps/mobile/components/SettingsButton.tsx | 18 + .../apps/mobile/components/ThemeToggle.tsx | 30 + .../mobile/components/ThemeVariantPicker.tsx | 149 + .../apps/mobile/components/ThemeWrapper.tsx | 21 + .../mobile/components/TrackingControls.tsx | 457 +++ apps/traces/apps/mobile/components/WebMap.tsx | 162 + apps/traces/apps/mobile/eas.json | 30 + apps/traces/apps/mobile/global.css | 3 + apps/traces/apps/mobile/metro.config.js | 25 + apps/traces/apps/mobile/nativewind-env.d.ts | 3 + apps/traces/apps/mobile/package.json | 64 + apps/traces/apps/mobile/prettier.config.js | 10 + .../mobile/readmes/STANDORT_BERECHTIGUNGEN.md | 242 ++ apps/traces/apps/mobile/tailwind.config.js | 23 + apps/traces/apps/mobile/tsconfig.json | 12 + apps/traces/apps/mobile/utils/apiClient.ts | 55 + .../mobile/utils/backgroundLocationTask.ts | 451 +++ .../apps/mobile/utils/locationHelper.ts | 261 ++ .../apps/mobile/utils/locationService.ts | 760 +++++ apps/traces/apps/mobile/utils/logService.ts | 91 + .../apps/mobile/utils/photoImportService.ts | 283 ++ apps/traces/apps/mobile/utils/placeService.ts | 387 +++ .../mobile/utils/registerBackgroundTasks.ts | 16 + apps/traces/apps/mobile/utils/syncService.ts | 141 + .../traces/apps/mobile/utils/themeContext.tsx | 194 ++ apps/traces/package.json | 9 + .../traces/packages/traces-types/package.json | 13 + .../traces/packages/traces-types/src/index.ts | 188 ++ .../packages/traces-types/tsconfig.json | 14 + .../apps/web/src/lib/stores/auth.svelte.ts | 2 +- package.json | 6 + pnpm-lock.yaml | 2618 ++++++++++++----- scripts/generate-env.mjs | 28 + scripts/setup-databases.sh | 9 +- .../src/lib/stores/auth.svelte.ts | 2 +- .../matrix-mana-bot/src/bot/matrix.service.ts | 37 +- 125 files changed, 14626 insertions(+), 831 deletions(-) create mode 100644 apps/traces/.gitignore create mode 100644 apps/traces/CLAUDE.md create mode 100644 apps/traces/apps/backend/.gitignore create mode 100644 apps/traces/apps/backend/drizzle.config.ts create mode 100644 apps/traces/apps/backend/nest-cli.json create mode 100644 apps/traces/apps/backend/package.json create mode 100644 apps/traces/apps/backend/src/app.module.ts create mode 100644 apps/traces/apps/backend/src/city/city.controller.ts create mode 100644 apps/traces/apps/backend/src/city/city.module.ts create mode 100644 apps/traces/apps/backend/src/city/city.service.ts create mode 100644 apps/traces/apps/backend/src/db/connection.ts create mode 100644 apps/traces/apps/backend/src/db/database.module.ts create mode 100644 apps/traces/apps/backend/src/db/schema.ts create mode 100644 apps/traces/apps/backend/src/guide/guide.controller.ts create mode 100644 apps/traces/apps/backend/src/guide/guide.module.ts create mode 100644 apps/traces/apps/backend/src/guide/guide.service.ts create mode 100644 apps/traces/apps/backend/src/location/location.controller.ts create mode 100644 apps/traces/apps/backend/src/location/location.module.ts create mode 100644 apps/traces/apps/backend/src/location/location.service.ts create mode 100644 apps/traces/apps/backend/src/main.ts create mode 100644 apps/traces/apps/backend/src/place/place.controller.ts create mode 100644 apps/traces/apps/backend/src/place/place.module.ts create mode 100644 apps/traces/apps/backend/src/place/place.service.ts create mode 100644 apps/traces/apps/backend/src/poi/poi.controller.ts create mode 100644 apps/traces/apps/backend/src/poi/poi.module.ts create mode 100644 apps/traces/apps/backend/src/poi/poi.service.ts create mode 100644 apps/traces/apps/backend/tsconfig.json create mode 100644 apps/traces/apps/mobile/.gitignore create mode 100644 apps/traces/apps/mobile/README.md create mode 100644 apps/traces/apps/mobile/app-env.d.ts create mode 100644 apps/traces/apps/mobile/app.json create mode 100644 apps/traces/apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/cities/index.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/guides/index.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/history/index.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/index.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/logs.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/map.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/(tabs)/places/index.tsx create mode 100644 apps/traces/apps/mobile/app/+html.tsx create mode 100644 apps/traces/apps/mobile/app/+not-found.tsx create mode 100644 apps/traces/apps/mobile/app/_layout.tsx create mode 100644 apps/traces/apps/mobile/app/city-detail.tsx create mode 100644 apps/traces/apps/mobile/app/guide-detail.tsx create mode 100644 apps/traces/apps/mobile/app/modal.tsx create mode 100644 apps/traces/apps/mobile/app/place-details.tsx create mode 100644 apps/traces/apps/mobile/app/settings.tsx create mode 100644 apps/traces/apps/mobile/assets/adaptive-icon.png create mode 100644 apps/traces/apps/mobile/assets/favicon.png create mode 100644 apps/traces/apps/mobile/assets/icon.png create mode 100644 apps/traces/apps/mobile/assets/splash.png create mode 100644 apps/traces/apps/mobile/assets/traces.icon/Assets/Traces App Icon 01.3-1.svg create mode 100644 apps/traces/apps/mobile/assets/traces.icon/Assets/Traces App Icon 01.3.svg create mode 100644 apps/traces/apps/mobile/assets/traces.icon/icon.json create mode 100644 apps/traces/apps/mobile/babel.config.js create mode 100644 apps/traces/apps/mobile/cesconfig.json create mode 100644 apps/traces/apps/mobile/components/Button.tsx create mode 100644 apps/traces/apps/mobile/components/CitiesList.tsx create mode 100644 apps/traces/apps/mobile/components/ConsolidatedLocationList.tsx create mode 100644 apps/traces/apps/mobile/components/CountriesList.tsx create mode 100644 apps/traces/apps/mobile/components/HeaderButton.tsx create mode 100644 apps/traces/apps/mobile/components/LocationHistoryList.tsx create mode 100644 apps/traces/apps/mobile/components/LocationMap.tsx create mode 100644 apps/traces/apps/mobile/components/LogsList.tsx create mode 100644 apps/traces/apps/mobile/components/PhotoImportModal.tsx create mode 100644 apps/traces/apps/mobile/components/PlaceDetail.tsx create mode 100644 apps/traces/apps/mobile/components/PlacesList.tsx create mode 100644 apps/traces/apps/mobile/components/SegmentedControl.tsx create mode 100644 apps/traces/apps/mobile/components/SettingsButton.tsx create mode 100644 apps/traces/apps/mobile/components/ThemeToggle.tsx create mode 100644 apps/traces/apps/mobile/components/ThemeVariantPicker.tsx create mode 100644 apps/traces/apps/mobile/components/ThemeWrapper.tsx create mode 100644 apps/traces/apps/mobile/components/TrackingControls.tsx create mode 100644 apps/traces/apps/mobile/components/WebMap.tsx create mode 100644 apps/traces/apps/mobile/eas.json create mode 100644 apps/traces/apps/mobile/global.css create mode 100644 apps/traces/apps/mobile/metro.config.js create mode 100644 apps/traces/apps/mobile/nativewind-env.d.ts create mode 100644 apps/traces/apps/mobile/package.json create mode 100644 apps/traces/apps/mobile/prettier.config.js create mode 100644 apps/traces/apps/mobile/readmes/STANDORT_BERECHTIGUNGEN.md create mode 100644 apps/traces/apps/mobile/tailwind.config.js create mode 100644 apps/traces/apps/mobile/tsconfig.json create mode 100644 apps/traces/apps/mobile/utils/apiClient.ts create mode 100644 apps/traces/apps/mobile/utils/backgroundLocationTask.ts create mode 100644 apps/traces/apps/mobile/utils/locationHelper.ts create mode 100644 apps/traces/apps/mobile/utils/locationService.ts create mode 100644 apps/traces/apps/mobile/utils/logService.ts create mode 100644 apps/traces/apps/mobile/utils/photoImportService.ts create mode 100644 apps/traces/apps/mobile/utils/placeService.ts create mode 100644 apps/traces/apps/mobile/utils/registerBackgroundTasks.ts create mode 100644 apps/traces/apps/mobile/utils/syncService.ts create mode 100644 apps/traces/apps/mobile/utils/themeContext.tsx create mode 100644 apps/traces/package.json create mode 100644 apps/traces/packages/traces-types/package.json create mode 100644 apps/traces/packages/traces-types/src/index.ts create mode 100644 apps/traces/packages/traces-types/tsconfig.json diff --git a/.env.development b/.env.development index c4d3c7aef..ffbaab667 100644 --- a/.env.development +++ b/.env.development @@ -345,6 +345,13 @@ PLANTA_S3_PUBLIC_URL=http://localhost:9000/planta-storage # Google Gemini API for plant vision analysis PLANTA_GEMINI_API_KEY=AIzaSyC_-hPWpVttTlqJdU4jbXR5H0OAnRi2LgI +# ============================================ +# TRACES PROJECT +# ============================================ + +TRACES_BACKEND_PORT=3026 +TRACES_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/traces + # ============================================ # SKILLTREE PROJECT # ============================================ diff --git a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts index d1a69f0d5..922b53f15 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -155,7 +155,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts index 3d8ebdf82..2d969ee6f 100644 --- a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts @@ -155,7 +155,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts index 48051c207..c5d223ad1 100644 --- a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts @@ -154,7 +154,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index a87d09ca9..cfe3c2c11 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -155,7 +155,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts index 89bced4d7..cdbd557c6 100644 --- a/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/lightwrite/apps/web/src/lib/stores/auth.svelte.ts @@ -134,7 +134,7 @@ export const authStore = { try { const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 87b9485d7..78b6f1f8e 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -153,7 +153,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts index 757363cda..67cd650f0 100644 --- a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts @@ -111,7 +111,7 @@ export const authStore = { async signUp(email: string, password: string) { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (result.success && !result.needsVerification) { const userData = await authService.getUserFromToken(); user = toManaUser(userData); diff --git a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts index 81cb3eaa9..2f47b0cf2 100644 --- a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts @@ -154,7 +154,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts index d0eef378a..3f79614eb 100644 --- a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts @@ -136,7 +136,7 @@ export const authStore = { try { const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts index cec72778e..eb3b66e4a 100644 --- a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts @@ -141,7 +141,7 @@ export const authStore = { loading = true; // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (result.success) { // Auto-login after signup diff --git a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts index 6d34dfaee..49d23ed19 100644 --- a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts @@ -143,7 +143,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts index d1789af66..2342c964a 100644 --- a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts @@ -130,7 +130,7 @@ export const auth = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts index 0e608e48f..f50d0b801 100644 --- a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts @@ -138,7 +138,7 @@ export const authStore = { try { const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts index b7c81c175..6c4d2229a 100644 --- a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts @@ -153,7 +153,7 @@ export const authStore = { try { const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts index c61494102..b4d25a8c7 100644 --- a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts @@ -113,7 +113,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts index ce4278b44..a3a1c64ba 100644 --- a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts @@ -174,7 +174,7 @@ export const authStore = { try { // Pass the current app URL for post-verification redirect const sourceAppUrl = browser ? window.location.origin : undefined; - const result = await authService.signUp(email, password, undefined, sourceAppUrl); + const result = await authService.signUp(email, password, sourceAppUrl); if (!result.success) { return { success: false, error: result.error || 'Signup failed', needsVerification: false }; diff --git a/apps/traces/.gitignore b/apps/traces/.gitignore new file mode 100644 index 000000000..1861e0868 --- /dev/null +++ b/apps/traces/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +# expo router +expo-env.d.ts + +# firebase/supabase/vexo +.env + +ios +android + +# macOS +.DS_Store + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* \ No newline at end of file diff --git a/apps/traces/CLAUDE.md b/apps/traces/CLAUDE.md new file mode 100644 index 000000000..bd403dc20 --- /dev/null +++ b/apps/traces/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md - Traces + +GPS tracking app with AI city guides. Location tracking runs locally via AsyncStorage, with optional backend sync. + +## Project Structure + +``` +apps/traces/ +├── package.json # Orchestrator (name: traces) +├── apps/ +│ ├── backend/ # @traces/backend (NestJS, Port 3026) +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── db/ # Drizzle schema + connection +│ │ ├── location/ # GPS sync endpoint +│ │ ├── city/ # City CRUD + visit stats +│ │ ├── place/ # Saved places CRUD +│ │ ├── poi/ # Points of Interest +│ │ └── guide/ # AI city guide pipeline +│ └── mobile/ # @traces/mobile (Expo SDK 54) +│ ├── app/ # Expo Router screens +│ ├── components/ # UI components +│ └── utils/ # Services (location, sync, api) +└── packages/ + └── traces-types/ # @traces/types (shared interfaces) +``` + +## Commands + +```bash +# Development +pnpm dev:traces:mobile # Start Expo app +pnpm dev:traces:backend # Start NestJS backend +pnpm dev:traces:full # Start auth + backend + mobile + +# Database +pnpm traces:db:push # Push Drizzle schema +pnpm traces:db:studio # Open Drizzle Studio +``` + +## Architecture + +- **Mobile**: Offline-first. All GPS data in AsyncStorage. Sync is additive. +- **Backend**: NestJS + Drizzle ORM + PostgreSQL. Auth via ManaCoreModule. +- **AI Guides**: Uses mana-search for POI discovery, mana-llm for narratives. +- **Credits**: 5 base + 2 per POI consumed via CreditClientService. + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/locations/sync` | POST | Batch sync from mobile | +| `/api/v1/locations` | GET | Query locations | +| `/api/v1/cities` | GET | User's visited cities | +| `/api/v1/cities/:id` | GET | City detail + stats | +| `/api/v1/places` | GET/POST | List/create places | +| `/api/v1/places/:id` | PUT/DELETE | Update/delete place | +| `/api/v1/pois` | GET | Nearby POIs | +| `/api/v1/pois/:id` | GET | POI detail | +| `/api/v1/guides/generate` | POST | Generate AI guide | +| `/api/v1/guides` | GET | User's guides | +| `/api/v1/guides/:id` | GET/DELETE | Guide detail/delete | + +## Environment Variables + +Backend: `PORT=3026`, `DATABASE_URL`, `MANA_CORE_AUTH_URL`, `MANA_LLM_URL`, `MANA_SEARCH_URL` +Mobile: `EXPO_PUBLIC_TRACES_BACKEND_URL`, `EXPO_PUBLIC_MANA_CORE_AUTH_URL` + +## Mobile Navigation (5 tabs) + +1. **Tracking** - Live GPS tracking + map +2. **Orte** - Saved places, cities, countries +3. **Karte** - Full-screen map view +4. **Städte** - Visited cities with stats +5. **Führungen** - AI-generated city guides diff --git a/apps/traces/apps/backend/.gitignore b/apps/traces/apps/backend/.gitignore new file mode 100644 index 000000000..9e49d1cf8 --- /dev/null +++ b/apps/traces/apps/backend/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +.env +*.tsbuildinfo diff --git a/apps/traces/apps/backend/drizzle.config.ts b/apps/traces/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..417779a20 --- /dev/null +++ b/apps/traces/apps/backend/drizzle.config.ts @@ -0,0 +1,3 @@ +import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; + +export default createDrizzleConfig({ dbName: 'traces' }); diff --git a/apps/traces/apps/backend/nest-cli.json b/apps/traces/apps/backend/nest-cli.json new file mode 100644 index 000000000..2dbc071ae --- /dev/null +++ b/apps/traces/apps/backend/nest-cli.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false, + "assets": [], + "watchAssets": false, + "webpack": true + } +} diff --git a/apps/traces/apps/backend/package.json b/apps/traces/apps/backend/package.json new file mode 100644 index 000000000..8a069b3da --- /dev/null +++ b/apps/traces/apps/backend/package.json @@ -0,0 +1,52 @@ +{ + "name": "@traces/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@manacore/nestjs-integration": "workspace:*", + "@manacore/shared-nestjs-auth": "workspace:*", + "@manacore/shared-nestjs-health": "workspace:*", + "@manacore/shared-nestjs-metrics": "workspace:*", + "@manacore/shared-nestjs-setup": "workspace:*", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@traces/types": "workspace:*", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@manacore/shared-drizzle-config": "workspace:*", + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/node": "^22.10.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/traces/apps/backend/src/app.module.ts b/apps/traces/apps/backend/src/app.module.ts new file mode 100644 index 000000000..f4d8168f6 --- /dev/null +++ b/apps/traces/apps/backend/src/app.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MetricsModule } from '@manacore/shared-nestjs-metrics'; +import { ManaCoreModule } from '@manacore/nestjs-integration'; +import { HealthModule } from '@manacore/shared-nestjs-health'; +import { DatabaseModule } from './db/database.module'; +import { LocationModule } from './location/location.module'; +import { CityModule } from './city/city.module'; +import { PlaceModule } from './place/place.module'; +import { PoiModule } from './poi/poi.module'; +import { GuideModule } from './guide/guide.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + ManaCoreModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + appId: configService.get('APP_ID', 'traces'), + serviceKey: configService.get('MANA_CORE_SERVICE_KEY', ''), + debug: configService.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), + MetricsModule.register({ + prefix: 'traces_', + excludePaths: ['/health'], + }), + DatabaseModule, + LocationModule, + CityModule, + PlaceModule, + PoiModule, + GuideModule, + HealthModule.forRoot({ serviceName: 'traces-backend' }), + ], +}) +export class AppModule {} diff --git a/apps/traces/apps/backend/src/city/city.controller.ts b/apps/traces/apps/backend/src/city/city.controller.ts new file mode 100644 index 000000000..f186ae6f3 --- /dev/null +++ b/apps/traces/apps/backend/src/city/city.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; +import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { CityService } from './city.service'; + +@Controller('cities') +@UseGuards(JwtAuthGuard) +export class CityController { + constructor(private readonly cityService: CityService) {} + + @Get() + async getCities(@CurrentUser() user: CurrentUserData) { + return this.cityService.getUserCities(user.userId); + } + + @Get(':id') + async getCityDetail(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.cityService.getCityDetail(user.userId, id); + } +} diff --git a/apps/traces/apps/backend/src/city/city.module.ts b/apps/traces/apps/backend/src/city/city.module.ts new file mode 100644 index 000000000..b82485db5 --- /dev/null +++ b/apps/traces/apps/backend/src/city/city.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CityController } from './city.controller'; +import { CityService } from './city.service'; + +@Module({ + controllers: [CityController], + providers: [CityService], + exports: [CityService], +}) +export class CityModule {} diff --git a/apps/traces/apps/backend/src/city/city.service.ts b/apps/traces/apps/backend/src/city/city.service.ts new file mode 100644 index 000000000..fed916136 --- /dev/null +++ b/apps/traces/apps/backend/src/city/city.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +import { cities, cityVisits } from '../db/schema'; +import type { NewCity } from '../db/schema'; + +@Injectable() +export class CityService { + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async findOrCreateCity(data: { + name: string; + country: string; + countryCode: string; + latitude: number; + longitude: number; + }) { + // Try to find existing city + const existing = await this.db + .select() + .from(cities) + .where(and(eq(cities.name, data.name), eq(cities.countryCode, data.countryCode))) + .limit(1); + + if (existing.length > 0) { + return existing[0]; + } + + // Create new city + const [city] = await this.db + .insert(cities) + .values({ + name: data.name, + country: data.country, + countryCode: data.countryCode, + latitude: data.latitude, + longitude: data.longitude, + }) + .onConflictDoNothing() + .returning(); + + // Handle race condition: if insert was no-op, re-fetch + if (!city) { + const [existing] = await this.db + .select() + .from(cities) + .where(and(eq(cities.name, data.name), eq(cities.countryCode, data.countryCode))) + .limit(1); + return existing; + } + + return city; + } + + async upsertCityVisit(userId: string, cityId: string, visitDate: Date) { + const existing = await this.db + .select() + .from(cityVisits) + .where(and(eq(cityVisits.userId, userId), eq(cityVisits.cityId, cityId))) + .limit(1); + + if (existing.length > 0) { + const visit = existing[0]; + await this.db + .update(cityVisits) + .set({ + lastVisitAt: visitDate > visit.lastVisitAt ? visitDate : visit.lastVisitAt, + firstVisitAt: visitDate < visit.firstVisitAt ? visitDate : visit.firstVisitAt, + visitCount: sql`${cityVisits.visitCount} + 1`, + updatedAt: new Date(), + }) + .where(eq(cityVisits.id, visit.id)); + } else { + await this.db.insert(cityVisits).values({ + userId, + cityId, + firstVisitAt: visitDate, + lastVisitAt: visitDate, + visitCount: 1, + }); + } + } + + async getUserCities(userId: string) { + const results = await this.db + .select({ + visitId: cityVisits.id, + firstVisitAt: cityVisits.firstVisitAt, + lastVisitAt: cityVisits.lastVisitAt, + totalDurationMs: cityVisits.totalDurationMs, + visitCount: cityVisits.visitCount, + city: { + id: cities.id, + name: cities.name, + country: cities.country, + countryCode: cities.countryCode, + latitude: cities.latitude, + longitude: cities.longitude, + }, + }) + .from(cityVisits) + .innerJoin(cities, eq(cityVisits.cityId, cities.id)) + .where(eq(cityVisits.userId, userId)) + .orderBy(cityVisits.lastVisitAt); + + return results.map((r) => ({ + id: r.visitId, + city: r.city, + firstVisitAt: r.firstVisitAt.toISOString(), + lastVisitAt: r.lastVisitAt.toISOString(), + totalDurationMs: r.totalDurationMs, + visitCount: r.visitCount, + })); + } + + async getCityDetail(userId: string, cityId: string) { + const [city] = await this.db.select().from(cities).where(eq(cities.id, cityId)).limit(1); + + if (!city) { + throw new NotFoundException('City not found'); + } + + const [visit] = await this.db + .select() + .from(cityVisits) + .where(and(eq(cityVisits.userId, userId), eq(cityVisits.cityId, cityId))) + .limit(1); + + return { + city, + visit: visit + ? { + firstVisitAt: visit.firstVisitAt.toISOString(), + lastVisitAt: visit.lastVisitAt.toISOString(), + totalDurationMs: visit.totalDurationMs, + visitCount: visit.visitCount, + } + : null, + }; + } + + async getCityById(id: string) { + const [city] = await this.db.select().from(cities).where(eq(cities.id, id)).limit(1); + return city || null; + } +} diff --git a/apps/traces/apps/backend/src/db/connection.ts b/apps/traces/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..9ecfb28bb --- /dev/null +++ b/apps/traces/apps/backend/src/db/connection.ts @@ -0,0 +1,36 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; + +const postgres = require('postgres'); + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; diff --git a/apps/traces/apps/backend/src/db/database.module.ts b/apps/traces/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..394b230c6 --- /dev/null +++ b/apps/traces/apps/backend/src/db/database.module.ts @@ -0,0 +1,30 @@ +import { Module, Global } from '@nestjs/common'; +import type { OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb, closeConnection } from './connection'; +import type { Database } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('DATABASE_URL'); + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} diff --git a/apps/traces/apps/backend/src/db/schema.ts b/apps/traces/apps/backend/src/db/schema.ts new file mode 100644 index 000000000..3533bf130 --- /dev/null +++ b/apps/traces/apps/backend/src/db/schema.ts @@ -0,0 +1,231 @@ +import { + pgTable, + uuid, + text, + doublePrecision, + timestamp, + integer, + pgEnum, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; + +// ============================================ +// Enums +// ============================================ + +export const locationSourceEnum = pgEnum('location_source', [ + 'foreground', + 'background', + 'manual', + 'photo-import', +]); + +export const deviceMotionEnum = pgEnum('device_motion', [ + 'stationary', + 'walking', + 'driving', + 'unknown', +]); + +export const poiCategoryEnum = pgEnum('poi_category', [ + 'building', + 'monument', + 'church', + 'museum', + 'palace', + 'bridge', + 'park', + 'square', + 'sculpture', + 'fountain', + 'historic_site', + 'other', +]); + +export const guideStatusEnum = pgEnum('guide_status', ['generating', 'ready', 'error']); + +// ============================================ +// Tables +// ============================================ + +export const locations = pgTable( + 'locations', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(), + accuracy: doublePrecision('accuracy'), + altitude: doublePrecision('altitude'), + speed: doublePrecision('speed'), + source: locationSourceEnum('source').default('foreground'), + deviceMotion: deviceMotionEnum('device_motion'), + addressFormatted: text('address_formatted'), + street: text('street'), + houseNumber: text('house_number'), + city: text('city'), + postalCode: text('postal_code'), + country: text('country'), + countryCode: text('country_code'), + cityId: uuid('city_id').references(() => cities.id), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('locations_user_id_idx').on(table.userId), + index('locations_recorded_at_idx').on(table.recordedAt), + index('locations_city_id_idx').on(table.cityId), + index('locations_user_recorded_idx').on(table.userId, table.recordedAt), + ] +); + +export const cities = pgTable( + 'cities', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + country: text('country').notNull(), + countryCode: text('country_code').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [uniqueIndex('cities_name_country_code_idx').on(table.name, table.countryCode)] +); + +export const cityVisits = pgTable( + 'city_visits', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id, { onDelete: 'cascade' }), + firstVisitAt: timestamp('first_visit_at', { withTimezone: true }).notNull(), + lastVisitAt: timestamp('last_visit_at', { withTimezone: true }).notNull(), + totalDurationMs: integer('total_duration_ms').default(0).notNull(), + visitCount: integer('visit_count').default(1).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex('city_visits_user_city_idx').on(table.userId, table.cityId), + index('city_visits_user_id_idx').on(table.userId), + ] +); + +export const places = pgTable( + 'places', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + name: text('name').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + radiusMeters: integer('radius_meters').default(100).notNull(), + addressFormatted: text('address_formatted'), + cityId: uuid('city_id').references(() => cities.id), + visitCount: integer('visit_count').default(0).notNull(), + totalDurationMs: integer('total_duration_ms').default(0).notNull(), + firstVisitAt: timestamp('first_visit_at', { withTimezone: true }), + lastVisitAt: timestamp('last_visit_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('places_user_id_idx').on(table.userId), + index('places_city_id_idx').on(table.cityId), + ] +); + +export const pois = pgTable( + 'pois', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + category: poiCategoryEnum('category').default('other').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id), + imageUrl: text('image_url'), + sourceUrls: text('source_urls').array(), + aiSummary: text('ai_summary'), + aiSummaryLanguage: text('ai_summary_language'), + aiSummaryGeneratedAt: timestamp('ai_summary_generated_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('pois_city_id_idx').on(table.cityId), + index('pois_lat_lng_idx').on(table.latitude, table.longitude), + ] +); + +export const guides = pgTable( + 'guides', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id), + title: text('title').notNull(), + description: text('description'), + status: guideStatusEnum('status').default('generating').notNull(), + routePolyline: text('route_polyline'), + estimatedDurationMin: integer('estimated_duration_min'), + distanceMeters: integer('distance_meters'), + language: text('language').default('de').notNull(), + creditsCost: integer('credits_cost'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('guides_user_id_idx').on(table.userId), + index('guides_city_id_idx').on(table.cityId), + ] +); + +export const guidePois = pgTable( + 'guide_pois', + { + id: uuid('id').defaultRandom().primaryKey(), + guideId: uuid('guide_id') + .notNull() + .references(() => guides.id, { onDelete: 'cascade' }), + poiId: uuid('poi_id') + .notNull() + .references(() => pois.id), + sortOrder: integer('sort_order').notNull(), + aiNarrative: text('ai_narrative'), + narrativeLanguage: text('narrative_language').default('de'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('guide_pois_guide_id_idx').on(table.guideId), + index('guide_pois_poi_id_idx').on(table.poiId), + ] +); + +// ============================================ +// Type Exports +// ============================================ + +export type Location = typeof locations.$inferSelect; +export type NewLocation = typeof locations.$inferInsert; +export type City = typeof cities.$inferSelect; +export type NewCity = typeof cities.$inferInsert; +export type CityVisit = typeof cityVisits.$inferSelect; +export type NewCityVisit = typeof cityVisits.$inferInsert; +export type Place = typeof places.$inferSelect; +export type NewPlace = typeof places.$inferInsert; +export type Poi = typeof pois.$inferSelect; +export type NewPoi = typeof pois.$inferInsert; +export type Guide = typeof guides.$inferSelect; +export type NewGuide = typeof guides.$inferInsert; +export type GuidePoi = typeof guidePois.$inferSelect; +export type NewGuidePoi = typeof guidePois.$inferInsert; diff --git a/apps/traces/apps/backend/src/guide/guide.controller.ts b/apps/traces/apps/backend/src/guide/guide.controller.ts new file mode 100644 index 000000000..685ffe030 --- /dev/null +++ b/apps/traces/apps/backend/src/guide/guide.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; +import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { GuideService } from './guide.service'; +import type { GenerateGuideRequest } from '@traces/types'; + +@Controller('guides') +@UseGuards(JwtAuthGuard) +export class GuideController { + constructor(private readonly guideService: GuideService) {} + + @Post('generate') + async generateGuide(@CurrentUser() user: CurrentUserData, @Body() body: GenerateGuideRequest) { + return this.guideService.generateGuide(user.userId, body); + } + + @Get() + async getGuides(@CurrentUser() user: CurrentUserData) { + return this.guideService.getUserGuides(user.userId); + } + + @Get(':id') + async getGuideDetail(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.guideService.getGuideDetail(user.userId, id); + } + + @Delete(':id') + async deleteGuide(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.guideService.deleteGuide(user.userId, id); + } +} diff --git a/apps/traces/apps/backend/src/guide/guide.module.ts b/apps/traces/apps/backend/src/guide/guide.module.ts new file mode 100644 index 000000000..d2f30ec95 --- /dev/null +++ b/apps/traces/apps/backend/src/guide/guide.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { GuideController } from './guide.controller'; +import { GuideService } from './guide.service'; +import { CityModule } from '../city/city.module'; +import { PoiModule } from '../poi/poi.module'; + +@Module({ + imports: [CityModule, PoiModule], + controllers: [GuideController], + providers: [GuideService], + exports: [GuideService], +}) +export class GuideModule {} diff --git a/apps/traces/apps/backend/src/guide/guide.service.ts b/apps/traces/apps/backend/src/guide/guide.service.ts new file mode 100644 index 000000000..1611567a4 --- /dev/null +++ b/apps/traces/apps/backend/src/guide/guide.service.ts @@ -0,0 +1,402 @@ +import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and, desc } from 'drizzle-orm'; +import { CreditClientService } from '@manacore/nestjs-integration'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +import { guides, guidePois, pois, cities } from '../db/schema'; +import { CityService } from '../city/city.service'; +import { PoiService } from '../poi/poi.service'; +import type { GenerateGuideRequest } from '@traces/types'; + +@Injectable() +export class GuideService { + private readonly logger = new Logger(GuideService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private readonly configService: ConfigService, + private readonly cityService: CityService, + private readonly poiService: PoiService, + private readonly creditClient: CreditClientService + ) {} + + async generateGuide(userId: string, request: GenerateGuideRequest) { + const city = await this.cityService.getCityById(request.cityId); + if (!city) throw new NotFoundException('City not found'); + + const language = request.language || 'de'; + const maxPois = request.maxPois || 8; + + // Calculate credit cost: 5 base + 2 per POI + const estimatedCost = 5 + 2 * maxPois; + + // Consume credits before starting generation + await this.creditClient.consumeCredits( + userId, + 'guide_generation', + estimatedCost, + `City guide: ${city.name}` + ); + + // Create guide record in 'generating' state + const [guide] = await this.db + .insert(guides) + .values({ + userId, + cityId: city.id, + title: `Stadtführung: ${city.name}`, + status: 'generating', + language, + creditsCost: estimatedCost, + }) + .returning(); + + // Start async generation pipeline + this.runGenerationPipeline(guide.id, city, language, maxPois, request).catch((err) => { + this.logger.error(`Guide generation failed for ${guide.id}:`, err); + this.db + .update(guides) + .set({ status: 'error', updatedAt: new Date() }) + .where(eq(guides.id, guide.id)) + .catch(() => {}); + }); + + return { + id: guide.id, + status: 'generating', + creditsCost: estimatedCost, + }; + } + + private async runGenerationPipeline( + guideId: string, + city: typeof cities.$inferSelect, + language: string, + maxPois: number, + request: GenerateGuideRequest + ) { + const manaSearchUrl = this.configService.get('MANA_SEARCH_URL'); + const manaLlmUrl = this.configService.get('MANA_LLM_URL'); + + // Step 1: POI Discovery via mana-search + this.logger.log(`[${guideId}] Step 1: POI Discovery for ${city.name}`); + const searchQueries = [ + `${city.name} Sehenswürdigkeiten Architektur Geschichte`, + `${city.name} historic buildings monuments Wikipedia`, + `${city.name} must see landmarks tourist attractions`, + ]; + + const discoveredPois: Array<{ + name: string; + description?: string; + latitude: number; + longitude: number; + category: string; + sourceUrls: string[]; + }> = []; + + if (manaSearchUrl) { + for (const query of searchQueries) { + try { + const searchResponse = await fetch(`${manaSearchUrl}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + options: { categories: ['general'], limit: 10 }, + }), + }); + + if (searchResponse.ok) { + const { results } = await searchResponse.json(); + // Extract POI names from search results (simplified - real implementation + // would parse structured data) + for (const result of results || []) { + // Results will be used for content enrichment + this.logger.debug(`Search result: ${result.title}`); + } + } + } catch (err) { + this.logger.warn(`Search failed for query "${query}":`, err); + } + } + } + + // Step 2: Create POI records (for now, use any existing POIs near the city) + this.logger.log(`[${guideId}] Step 2: Finding POIs near ${city.name}`); + const nearbyPois = await this.poiService.findNearby({ + lat: city.latitude, + lng: city.longitude, + radiusMeters: request.radiusMeters || 2000, + cityId: city.id, + limit: maxPois, + }); + + // Step 3: Enrich POIs with AI summaries + this.logger.log(`[${guideId}] Step 3: Content enrichment`); + if (manaLlmUrl) { + for (const poi of nearbyPois) { + if (!poi.aiSummary) { + try { + const prompt = + language === 'de' + ? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.` + : `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`; + + const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content: prompt }], + model: 'default', + max_tokens: 500, + }), + }); + + if (llmResponse.ok) { + const data = await llmResponse.json(); + const summary = data.choices?.[0]?.message?.content; + if (summary) { + await this.poiService.updateAiSummary(poi.id, summary, language); + } + } + } catch (err) { + this.logger.warn(`AI summary failed for POI ${poi.name}:`, err); + } + } + } + } + + // Step 4: Route generation (nearest-neighbor from city center) + this.logger.log(`[${guideId}] Step 4: Route generation`); + const sortedPois = this.sortPoisByNearestNeighbor(nearbyPois, city.latitude, city.longitude); + + let totalDistance = 0; + for (let i = 1; i < sortedPois.length; i++) { + totalDistance += this.haversineDistance( + sortedPois[i - 1].latitude, + sortedPois[i - 1].longitude, + sortedPois[i].latitude, + sortedPois[i].longitude + ); + } + + const estimatedDurationMin = Math.ceil(totalDistance / (4000 / 60)); // 4 km/h walking + const routePolyline = JSON.stringify(sortedPois.map((p) => [p.latitude, p.longitude])); + + // Step 5: Generate narratives + this.logger.log(`[${guideId}] Step 5: Narrative assembly`); + const guidePoiRecords: Array<{ + poiId: string; + sortOrder: number; + aiNarrative: string | null; + }> = []; + + for (let i = 0; i < sortedPois.length; i++) { + const poi = sortedPois[i]; + let narrative: string | null = null; + + if (manaLlmUrl) { + try { + const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt'; + const distanceToPrev = + i > 0 + ? Math.round( + this.haversineDistance( + sortedPois[i - 1].latitude, + sortedPois[i - 1].longitude, + poi.latitude, + poi.longitude + ) + ) + : 0; + + const prompt = + language === 'de' + ? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.` + : `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`; + + const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content: prompt }], + model: 'default', + max_tokens: 300, + }), + }); + + if (llmResponse.ok) { + const data = await llmResponse.json(); + narrative = data.choices?.[0]?.message?.content || null; + } + } catch (err) { + this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err); + } + } + + guidePoiRecords.push({ + poiId: poi.id, + sortOrder: i, + aiNarrative: narrative, + }); + } + + // Save guide POIs + if (guidePoiRecords.length > 0) { + await this.db.insert(guidePois).values( + guidePoiRecords.map((r) => ({ + guideId, + poiId: r.poiId, + sortOrder: r.sortOrder, + aiNarrative: r.aiNarrative, + narrativeLanguage: language, + })) + ); + } + + // Update guide to ready + const title = language === 'de' ? `Stadtführung: ${city.name}` : `City Guide: ${city.name}`; + + await this.db + .update(guides) + .set({ + status: 'ready', + title, + description: + language === 'de' + ? `${sortedPois.length} Stationen, ca. ${estimatedDurationMin} Min.` + : `${sortedPois.length} stations, approx. ${estimatedDurationMin} min.`, + routePolyline, + estimatedDurationMin, + distanceMeters: Math.round(totalDistance), + updatedAt: new Date(), + }) + .where(eq(guides.id, guideId)); + + this.logger.log( + `[${guideId}] Guide generation complete: ${sortedPois.length} POIs, ${Math.round(totalDistance)}m` + ); + } + + private sortPoisByNearestNeighbor( + poisList: Array<{ id: string; latitude: number; longitude: number; [key: string]: any }>, + startLat: number, + startLng: number + ) { + const remaining = [...poisList]; + const sorted: typeof remaining = []; + let currentLat = startLat; + let currentLng = startLng; + + while (remaining.length > 0) { + let nearestIdx = 0; + let nearestDist = Infinity; + + for (let i = 0; i < remaining.length; i++) { + const dist = this.haversineDistance( + currentLat, + currentLng, + remaining[i].latitude, + remaining[i].longitude + ); + if (dist < nearestDist) { + nearestDist = dist; + nearestIdx = i; + } + } + + const nearest = remaining.splice(nearestIdx, 1)[0]; + sorted.push(nearest); + currentLat = nearest.latitude; + currentLng = nearest.longitude; + } + + return sorted; + } + + private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const φ1 = (lat1 * Math.PI) / 180; + const φ2 = (lat2 * Math.PI) / 180; + const Δφ = ((lat2 - lat1) * Math.PI) / 180; + const Δλ = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + async getUserGuides(userId: string) { + const results = await this.db + .select({ + guide: guides, + cityName: cities.name, + cityCountry: cities.country, + }) + .from(guides) + .innerJoin(cities, eq(guides.cityId, cities.id)) + .where(eq(guides.userId, userId)) + .orderBy(desc(guides.createdAt)); + + return results.map((r) => ({ + id: r.guide.id, + title: r.guide.title, + description: r.guide.description, + status: r.guide.status, + cityName: r.cityName, + cityCountry: r.cityCountry, + estimatedDurationMin: r.guide.estimatedDurationMin, + distanceMeters: r.guide.distanceMeters, + language: r.guide.language, + creditsCost: r.guide.creditsCost, + createdAt: r.guide.createdAt.toISOString(), + })); + } + + async getGuideDetail(userId: string, guideId: string) { + const [guide] = await this.db.select().from(guides).where(eq(guides.id, guideId)).limit(1); + + if (!guide) throw new NotFoundException('Guide not found'); + if (guide.userId !== userId) throw new ForbiddenException(); + + const city = await this.cityService.getCityById(guide.cityId); + + const guidePoiResults = await this.db + .select({ + guidePoi: guidePois, + poi: pois, + }) + .from(guidePois) + .innerJoin(pois, eq(guidePois.poiId, pois.id)) + .where(eq(guidePois.guideId, guideId)) + .orderBy(guidePois.sortOrder); + + return { + ...guide, + createdAt: guide.createdAt.toISOString(), + updatedAt: guide.updatedAt.toISOString(), + city, + pois: guidePoiResults.map((r) => ({ + id: r.guidePoi.id, + sortOrder: r.guidePoi.sortOrder, + aiNarrative: r.guidePoi.aiNarrative, + narrativeLanguage: r.guidePoi.narrativeLanguage, + poi: r.poi, + })), + }; + } + + async deleteGuide(userId: string, guideId: string) { + const [guide] = await this.db.select().from(guides).where(eq(guides.id, guideId)).limit(1); + + if (!guide) throw new NotFoundException('Guide not found'); + if (guide.userId !== userId) throw new ForbiddenException(); + + // guide_pois cascade-deleted automatically + await this.db.delete(guides).where(eq(guides.id, guideId)); + return { deleted: true }; + } +} diff --git a/apps/traces/apps/backend/src/location/location.controller.ts b/apps/traces/apps/backend/src/location/location.controller.ts new file mode 100644 index 000000000..dfdf35d85 --- /dev/null +++ b/apps/traces/apps/backend/src/location/location.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Post, Get, Body, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; +import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { LocationService } from './location.service'; +import type { LocationSyncRequest, LocationQueryParams } from '@traces/types'; + +@Controller('locations') +@UseGuards(JwtAuthGuard) +export class LocationController { + constructor(private readonly locationService: LocationService) {} + + @Post('sync') + async syncLocations(@CurrentUser() user: CurrentUserData, @Body() body: LocationSyncRequest) { + return this.locationService.syncLocations(user.userId, body.locations); + } + + @Get() + async getLocations(@CurrentUser() user: CurrentUserData, @Query() query: LocationQueryParams) { + return this.locationService.getLocations(user.userId, query); + } +} diff --git a/apps/traces/apps/backend/src/location/location.module.ts b/apps/traces/apps/backend/src/location/location.module.ts new file mode 100644 index 000000000..1ab026c74 --- /dev/null +++ b/apps/traces/apps/backend/src/location/location.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { LocationController } from './location.controller'; +import { LocationService } from './location.service'; +import { CityModule } from '../city/city.module'; + +@Module({ + imports: [CityModule], + controllers: [LocationController], + providers: [LocationService], + exports: [LocationService], +}) +export class LocationModule {} diff --git a/apps/traces/apps/backend/src/location/location.service.ts b/apps/traces/apps/backend/src/location/location.service.ts new file mode 100644 index 000000000..00a968c86 --- /dev/null +++ b/apps/traces/apps/backend/src/location/location.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +import { locations } from '../db/schema'; +import { CityService } from '../city/city.service'; +import type { LocationSyncItem, LocationQueryParams } from '@traces/types'; + +@Injectable() +export class LocationService { + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private readonly cityService: CityService + ) {} + + async syncLocations(userId: string, items: LocationSyncItem[]) { + let synced = 0; + let duplicates = 0; + + for (const item of items) { + // Check for duplicate by original ID + const existing = await this.db + .select({ id: locations.id }) + .from(locations) + .where(eq(locations.id, item.id)) + .limit(1); + + if (existing.length > 0) { + duplicates++; + continue; + } + + // Auto-detect city from address + let cityId: string | undefined; + if (item.city && item.countryCode) { + const city = await this.cityService.findOrCreateCity({ + name: item.city, + country: item.country || item.city, + countryCode: item.countryCode, + latitude: item.latitude, + longitude: item.longitude, + }); + cityId = city.id; + + // Upsert city visit + await this.cityService.upsertCityVisit(userId, city.id, new Date(item.recordedAt)); + } + + await this.db.insert(locations).values({ + id: item.id, + userId, + latitude: item.latitude, + longitude: item.longitude, + recordedAt: new Date(item.recordedAt), + accuracy: item.accuracy, + altitude: item.altitude, + speed: item.speed, + source: item.source, + deviceMotion: item.deviceMotion, + addressFormatted: item.addressFormatted, + street: item.street, + houseNumber: item.houseNumber, + city: item.city, + postalCode: item.postalCode, + country: item.country, + countryCode: item.countryCode, + cityId, + }); + + synced++; + } + + return { synced, duplicates }; + } + + async getLocations(userId: string, params: LocationQueryParams) { + const conditions = [eq(locations.userId, userId)]; + + if (params.cityId) { + conditions.push(eq(locations.cityId, params.cityId)); + } + if (params.from) { + conditions.push(gte(locations.recordedAt, new Date(params.from))); + } + if (params.to) { + conditions.push(lte(locations.recordedAt, new Date(params.to))); + } + + const limit = params.limit ? Math.min(params.limit, 1000) : 100; + const offset = params.offset || 0; + + return this.db + .select() + .from(locations) + .where(and(...conditions)) + .orderBy(desc(locations.recordedAt)) + .limit(limit) + .offset(offset); + } +} diff --git a/apps/traces/apps/backend/src/main.ts b/apps/traces/apps/backend/src/main.ts new file mode 100644 index 000000000..5cfad03a3 --- /dev/null +++ b/apps/traces/apps/backend/src/main.ts @@ -0,0 +1,8 @@ +import { bootstrapApp } from '@manacore/shared-nestjs-setup'; +import { AppModule } from './app.module'; + +bootstrapApp(AppModule, { + defaultPort: 3026, + serviceName: 'Traces', + additionalCorsOrigins: ['http://localhost:5173', 'http://localhost:8081'], +}); diff --git a/apps/traces/apps/backend/src/place/place.controller.ts b/apps/traces/apps/backend/src/place/place.controller.ts new file mode 100644 index 000000000..e3bd45453 --- /dev/null +++ b/apps/traces/apps/backend/src/place/place.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; +import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { PlaceService } from './place.service'; +import type { CreatePlaceRequest, UpdatePlaceRequest } from '@traces/types'; + +@Controller('places') +@UseGuards(JwtAuthGuard) +export class PlaceController { + constructor(private readonly placeService: PlaceService) {} + + @Get() + async getPlaces(@CurrentUser() user: CurrentUserData) { + return this.placeService.getUserPlaces(user.userId); + } + + @Post() + async createPlace(@CurrentUser() user: CurrentUserData, @Body() body: CreatePlaceRequest) { + return this.placeService.createPlace(user.userId, body); + } + + @Put(':id') + async updatePlace( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() body: UpdatePlaceRequest + ) { + return this.placeService.updatePlace(user.userId, id, body); + } + + @Delete(':id') + async deletePlace(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.placeService.deletePlace(user.userId, id); + } +} diff --git a/apps/traces/apps/backend/src/place/place.module.ts b/apps/traces/apps/backend/src/place/place.module.ts new file mode 100644 index 000000000..44f86864c --- /dev/null +++ b/apps/traces/apps/backend/src/place/place.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PlaceController } from './place.controller'; +import { PlaceService } from './place.service'; +import { CityModule } from '../city/city.module'; + +@Module({ + imports: [CityModule], + controllers: [PlaceController], + providers: [PlaceService], + exports: [PlaceService], +}) +export class PlaceModule {} diff --git a/apps/traces/apps/backend/src/place/place.service.ts b/apps/traces/apps/backend/src/place/place.service.ts new file mode 100644 index 000000000..78d5e3c9e --- /dev/null +++ b/apps/traces/apps/backend/src/place/place.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +import { places, cities } from '../db/schema'; +import type { CreatePlaceRequest, UpdatePlaceRequest } from '@traces/types'; + +@Injectable() +export class PlaceService { + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getUserPlaces(userId: string) { + const results = await this.db + .select({ + place: places, + cityName: cities.name, + }) + .from(places) + .leftJoin(cities, eq(places.cityId, cities.id)) + .where(eq(places.userId, userId)); + + return results.map((r) => ({ + ...r.place, + cityName: r.cityName, + firstVisitAt: r.place.firstVisitAt?.toISOString(), + lastVisitAt: r.place.lastVisitAt?.toISOString(), + })); + } + + async createPlace(userId: string, data: CreatePlaceRequest) { + const [place] = await this.db + .insert(places) + .values({ + userId, + name: data.name, + latitude: data.latitude, + longitude: data.longitude, + radiusMeters: data.radiusMeters || 100, + addressFormatted: data.addressFormatted, + }) + .returning(); + + return place; + } + + async updatePlace(userId: string, id: string, data: UpdatePlaceRequest) { + const [existing] = await this.db.select().from(places).where(eq(places.id, id)).limit(1); + + if (!existing) throw new NotFoundException('Place not found'); + if (existing.userId !== userId) throw new ForbiddenException(); + + const [updated] = await this.db + .update(places) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(places.id, id)) + .returning(); + + return updated; + } + + async deletePlace(userId: string, id: string) { + const [existing] = await this.db.select().from(places).where(eq(places.id, id)).limit(1); + + if (!existing) throw new NotFoundException('Place not found'); + if (existing.userId !== userId) throw new ForbiddenException(); + + await this.db.delete(places).where(eq(places.id, id)); + return { deleted: true }; + } +} diff --git a/apps/traces/apps/backend/src/poi/poi.controller.ts b/apps/traces/apps/backend/src/poi/poi.controller.ts new file mode 100644 index 000000000..61da684ed --- /dev/null +++ b/apps/traces/apps/backend/src/poi/poi.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '@manacore/shared-nestjs-auth'; +import { PoiService } from './poi.service'; +import type { NearbyPoiQueryParams } from '@traces/types'; + +@Controller('pois') +@UseGuards(JwtAuthGuard) +export class PoiController { + constructor(private readonly poiService: PoiService) {} + + @Get() + async getNearbyPois(@Query() query: NearbyPoiQueryParams) { + return this.poiService.findNearby(query); + } + + @Get(':id') + async getPoiDetail(@Param('id') id: string) { + return this.poiService.getById(id); + } +} diff --git a/apps/traces/apps/backend/src/poi/poi.module.ts b/apps/traces/apps/backend/src/poi/poi.module.ts new file mode 100644 index 000000000..de3e9b17d --- /dev/null +++ b/apps/traces/apps/backend/src/poi/poi.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PoiController } from './poi.controller'; +import { PoiService } from './poi.service'; + +@Module({ + controllers: [PoiController], + providers: [PoiService], + exports: [PoiService], +}) +export class PoiModule {} diff --git a/apps/traces/apps/backend/src/poi/poi.service.ts b/apps/traces/apps/backend/src/poi/poi.service.ts new file mode 100644 index 000000000..8f0efbfa4 --- /dev/null +++ b/apps/traces/apps/backend/src/poi/poi.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import type { Database } from '../db/connection'; +import { pois } from '../db/schema'; +import type { NearbyPoiQueryParams } from '@traces/types'; + +@Injectable() +export class PoiService { + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async findNearby(params: NearbyPoiQueryParams) { + const { lat, lng, radiusMeters = 2000, cityId, category, limit = 20 } = params; + + // Haversine distance calculation in SQL (returns meters) + const distanceExpr = sql`( + 6371000 * acos( + cos(radians(${lat})) * cos(radians(${pois.latitude})) * + cos(radians(${pois.longitude}) - radians(${lng})) + + sin(radians(${lat})) * sin(radians(${pois.latitude})) + ) + )`; + + const conditions = [sql`${distanceExpr} < ${radiusMeters}`]; + + if (cityId) { + conditions.push(eq(pois.cityId, cityId)); + } + if (category) { + conditions.push(eq(pois.category, category)); + } + + const results = await this.db + .select({ + poi: pois, + distance: distanceExpr.as('distance'), + }) + .from(pois) + .where(and(...conditions)) + .orderBy(sql`distance`) + .limit(Math.min(limit, 50)); + + return results.map((r) => ({ + ...r.poi, + distance: Math.round(r.distance as number), + })); + } + + async getById(id: string) { + const [poi] = await this.db.select().from(pois).where(eq(pois.id, id)).limit(1); + + if (!poi) throw new NotFoundException('POI not found'); + return poi; + } + + async findOrCreatePoi(data: { + name: string; + description?: string; + latitude: number; + longitude: number; + category: string; + cityId: string; + sourceUrls?: string[]; + }) { + // Check for existing POI within ~50m + const nearby = await this.findNearby({ + lat: data.latitude, + lng: data.longitude, + radiusMeters: 50, + cityId: data.cityId, + limit: 1, + }); + + if (nearby.length > 0) { + return nearby[0]; + } + + const [poi] = await this.db + .insert(pois) + .values({ + name: data.name, + description: data.description, + latitude: data.latitude, + longitude: data.longitude, + category: data.category as any, + cityId: data.cityId, + sourceUrls: data.sourceUrls, + }) + .returning(); + + return poi; + } + + async updateAiSummary(poiId: string, summary: string, language: string) { + await this.db + .update(pois) + .set({ + aiSummary: summary, + aiSummaryLanguage: language, + aiSummaryGeneratedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(pois.id, poiId)); + } +} diff --git a/apps/traces/apps/backend/tsconfig.json b/apps/traces/apps/backend/tsconfig.json new file mode 100644 index 000000000..27971033a --- /dev/null +++ b/apps/traces/apps/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/traces/apps/mobile/.gitignore b/apps/traces/apps/mobile/.gitignore new file mode 100644 index 000000000..1861e0868 --- /dev/null +++ b/apps/traces/apps/mobile/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +# expo router +expo-env.d.ts + +# firebase/supabase/vexo +.env + +ios +android + +# macOS +.DS_Store + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* \ No newline at end of file diff --git a/apps/traces/apps/mobile/README.md b/apps/traces/apps/mobile/README.md new file mode 100644 index 000000000..795c129cb --- /dev/null +++ b/apps/traces/apps/mobile/README.md @@ -0,0 +1,290 @@ +# Standortverlauf App + +Eine Expo React Native App, die den Standortverlauf des Nutzers aufzeichnet und auf einer Karte darstellt. Alle Daten werden lokal auf dem Gerät gespeichert. + +![Standortverlauf App](https://via.placeholder.com/800x400?text=Standortverlauf+App) + +## Funktionen + +### 🗺️ Echtzeit-Standortverfolgung +- Verfolge deinen aktuellen Standort in Echtzeit auf einer interaktiven Karte +- Visualisiere deinen Bewegungsverlauf als Linie auf der Karte +- Automatische Zentrierung auf deinen aktuellen Standort (optional) +- Dark Mode Unterstützung für angenehme Nutzung bei Nacht + +### 📝 Detaillierter Standortverlauf +- Chronologische Liste aller aufgezeichneten Standorte +- Detaillierte Informationen zu jedem Standort: + - Datum und Uhrzeit (mit verbesserter Zeitstempel-Verwaltung) + - Genaue Koordinaten (Breiten- und Längengrad) + - Genauigkeit der Standortbestimmung + - Geschwindigkeit (falls verfügbar) + - Adressinformationen mit formatierter Darstellung (optional) + - Eindeutige ID für jeden Standort + - Erweiterte Metadaten (Quelle, Verbindungstyp, Batteriestand) + - Qualitätsindikatoren für Standortgenauigkeit +- Intelligente Standortkonsolidierung: + - Zusammenfassung von nahe beieinanderliegenden Standorten + - Berechnung der Verweildauer an jedem Ort + - Anzeige der Anzahl der zusammengefassten Datenpunkte + - Umschaltbar zwischen detaillierter und konsolidierter Ansicht +- Automatische Migration bestehender Daten + +### 🔍 Umfangreiches Logging und Diagnose +- Detaillierte Logs aller App-Aktivitäten +- Überwachung der Hintergrund- und Vordergrundaktualisierungen +- Fehlerdiagnose und Problembehandlung +- Visualisierung von Standortgenauigkeit und Tracking-Intervallen + +### 🔒 Datenschutz und Sicherheit +- Alle Daten werden ausschließlich lokal auf deinem Gerät gespeichert +- Keine Übertragung von Standortdaten an externe Server +- Volle Kontrolle über deine Daten mit der Möglichkeit, den Verlauf jederzeit zu löschen +- Option zum Ein-/Ausschalten der Adressspeicherung + +### ⚙️ Anpassbare Tracking-Einstellungen +- Starte und stoppe das Tracking nach Bedarf +- Mehrere Tracking-Intervalle zur Auswahl: + - 5 Minuten (hohe Genauigkeit, höherer Akkuverbrauch) + - 1 Stunde (mittlere Genauigkeit, moderater Akkuverbrauch) + - 3 Stunden (niedrigere Genauigkeit, geringer Akkuverbrauch) + - 6 Stunden (niedrige Genauigkeit, minimaler Akkuverbrauch) +- Auswählbare Genauigkeitsstufen: + - Niedrigste (1-3 km, geringster Akkuverbrauch) + - Niedrig (500m-1km) + - Mittel (100-500m, empfohlen) + - Hoch (20-100m) + - Höchste (0-20m, höchster Akkuverbrauch) +- Distanz-basierte Aktualisierung: Automatische Anpassung je nach Intervall + +## Technische Details + +### Verwendete Technologien +- **Expo**: Framework für die einfache Entwicklung von React Native Apps +- **React Native**: Cross-Platform Framework für native mobile Apps +- **Expo Router**: Für die Navigation zwischen den Screens +- **Expo Location**: Für den Zugriff auf die Standortdaten des Geräts +- **React Native Maps**: Für die Kartenansicht und -interaktion +- **AsyncStorage**: Für die lokale Datenspeicherung + +### Architektur + +Die App ist in mehrere Komponenten und Dienste unterteilt: + +#### Hauptkomponenten +- **LocationMap**: Zeigt die Karte mit dem aktuellen Standort und dem Bewegungsverlauf +- **TrackingControls**: Bietet Steuerelemente zum Starten/Stoppen des Trackings und Löschen des Verlaufs +- **LocationHistoryList**: Zeigt den Standortverlauf in einer detaillierten Liste +- **ConsolidatedLocationList**: Zeigt den zusammengefassten Standortverlauf +- **LogsList**: Visualisiert die App-Logs mit Farbkodierung je nach Schweregrad +- **ThemeWrapper**: Sorgt für die konsistente Darstellung im Light- und Dark-Mode + +#### Dienste +- **locationService**: Enthält alle Funktionen für das Standort-Tracking und die lokale Speicherung: + - `requestLocationPermissions`: Anfordern der Standortberechtigungen + - `getCurrentLocation`: Abrufen des aktuellen Standorts mit erweiterten Metadaten + - `startLocationTracking`: Starten der kontinuierlichen Standortverfolgung + - `saveLocationToHistory`: Speichern eines Standorts im Verlauf mit automatischer Migration + - `getLocationHistory`: Abrufen des gesamten Standortverlaufs mit Migrationssupport + - `clearLocationHistory`: Löschen des gesamten Standortverlaufs + - `getAccuracyLevel`/`saveAccuracyLevel`: Verwalten der Genauigkeitseinstellung + - `getDefaultInterval`/`saveDefaultInterval`: Verwalten des Standard-Tracking-Intervalls + - **LocationData Interface**: Erweiterte Datenstruktur mit: + - Eindeutige IDs für jeden Standort + - Strukturierte Zeitstempel (ISO String + Unix Millisekunden) + - Erweiterte Adressinformationen mit formatierter Darstellung + - Metadaten (Quelle, Batteriestand, Verbindungstyp, Bewegungsstatus) + - Qualitätsindikatoren (Genauigkeitslevel, horizontale/vertikale Genauigkeit) + - Legacy-Support für bestehende Daten +- **logService**: Protokolliert alle App-Aktivitäten und Fehler: + - `logInfo`/`logWarning`/`logError`: Protokollieren von Nachrichten mit verschiedenen Schweregraden + - `getStoredLogs`: Abrufen aller gespeicherten Logs + - `clearLogs`: Löschen aller Protokolle +- **locationHelper**: Hilft bei der Analyse und Zusammenfassung von Standortdaten: + - `consolidateLocationsByProximity`: Fasst nahe beieinander liegende Standorte zusammen (kompatibel mit neuer Datenstruktur) + - `getDistanceBetweenCoordinates`: Berechnet die Entfernung zwischen zwei Koordinaten + - `formatDuration`: Formatiert Zeitspannen in benutzerfreundlicher Weise + - Unterstützung für sowohl neue als auch Legacy-Adressformate +- **backgroundLocationTask**: Verarbeitung im Hintergrund mit erweiterten Metadaten: + - Generierung von UUIDs für Standorte + - Erfassung von Verbindungstyp und Batteriestand + - Kompatibilität mit neuer LocationData-Struktur + +#### Navigation +- **Tabs**: Die App verwendet eine Tab-Navigation mit drei Haupttabs: + - **Karte**: Zeigt die Kartenansicht mit Tracking-Steuerung + - **Verlauf**: Zeigt die chronologische Liste aller aufgezeichneten Standorte mit Umschaltmöglichkeit zur konsolidierten Ansicht + - **Logs**: Zeigt detaillierte Protokolle aller App-Aktivitäten + +## Installation und Einrichtung + +### Voraussetzungen +- Node.js (v14 oder höher) +- npm oder yarn +- Expo CLI (`npm install -g expo-cli`) +- iOS Simulator oder Android Emulator (optional für lokale Tests) +- Physisches Gerät mit Expo Go App (empfohlen für Standortfunktionen) + +### Installation + +1. Repository klonen oder herunterladen + ```bash + git clone https://github.com/username/standortverlauf.git + cd standortverlauf + ``` + +2. Abhängigkeiten installieren + ```bash + npm install + # oder + yarn install + ``` + +3. App starten + ```bash + npx expo start + ``` + +4. QR-Code mit der Expo Go App scannen oder auf iOS/Android Simulator starten + +### Berechtigungen + +Die App benötigt folgende Berechtigungen: +- **Standort**: Für die Standortverfolgung (wird beim ersten Start angefragt) + +## Nutzung + +### Standortverfolgung starten +1. Öffne die App und navigiere zum "Karte"-Tab +2. Tippe auf "Tracking starten" +3. Erteile die Standortberechtigung, wenn du dazu aufgefordert wirst +4. Dein aktueller Standort wird auf der Karte angezeigt und kontinuierlich aktualisiert + +### Standortverlauf anzeigen +1. Navigiere zum "Verlauf"-Tab +2. Hier siehst du eine chronologische Liste aller aufgezeichneten Standorte +3. Tippe auf einen Eintrag, um zu diesem Standort auf der Karte zu navigieren +4. Nutze den Umschalter oben, um zwischen der detaillierten und der konsolidierten Ansicht zu wechseln + +### Konsolidierte Standorte anzeigen +1. Navigiere zum "Verlauf"-Tab +2. Tippe auf den "Zusammengefasst"-Button im oberen Bereich +3. Die App zeigt nun zusammengefasste Standorte mit Verweildauer und Anzahl der Datenpunkte +4. Die Zahl im orangefarbenen Kreis zeigt die Gesamtzahl der konsolidierten Standorte an + +### App-Logs einsehen +1. Navigiere zum "Logs"-Tab +2. Hier siehst du farblich kodierte Einträge für alle App-Aktivitäten: + - Grün: Informationen + - Orange: Warnungen + - Rot: Fehler +3. Die Logs werden automatisch aktualisiert und zeigen Details zu Standortverfolgung, Intervallen und mehr + +### Standortverlauf löschen +1. Navigiere zum "Karte"-Tab +2. Tippe auf "Verlauf löschen" +3. Bestätige die Löschung im Dialog + +### Einstellungen anpassen +1. Tippe auf das Zahnrad-Symbol in der oberen linken Ecke +2. Passe folgende Einstellungen an: + - Erscheinungsbild (Dark Mode ein-/ausschalten) + - Datenschutz (Adressen speichern ein-/ausschalten) + - Tracking-Einstellungen (Standard-Intervall und Genauigkeit) + +## Anpassung und Weiterentwicklung + +Die App kann leicht an deine Bedürfnisse angepasst werden: + +### Tracking-Intervall und Genauigkeit anpassen +Verwende die Einstellungsseite, um die Tracking-Parameter anzupassen: + +1. Tippe auf das Zahnrad-Symbol in der oberen linken Ecke +2. Wähle unter "Tracking-Einstellungen" die gewünschten Optionen: + - Standard-Intervall: Bestimmt wie oft ein neuer Standort aufgezeichnet wird + - Genauigkeit: Bestimmt die Präzision der Standortermittlung + +Die App passt die zugrunde liegenden Parameter automatisch an: + +```javascript +// Die App verwendet nun diese Parameter basierend auf den Einstellungen +const subscription = await startLocationTracking( + onLocationUpdateCallback, + selectedInterval, // Der in den Einstellungen gewählte Intervall + distanceInterval // Wird automatisch basierend auf dem Intervall angepasst +); +``` + +### Kartenansicht anpassen +Die Kartenansicht kann in der Datei `components/LocationMap.tsx` angepasst werden. + +### Datenspeicherung erweitern +Die App verwendet jetzt eine erweiterte `LocationData`-Struktur. Um zusätzliche Daten zu speichern, kannst du das Interface in `utils/locationService.ts` erweitern: + +```typescript +export interface LocationData { + id: string; // Eindeutige UUID + latitude: number; + longitude: number; + timestamps: { + recorded: string; // ISO 8601 String + recordedMs: number; // Unix Millisekunden + }; + // ... weitere Felder + metadata: { + source: 'foreground' | 'background' | 'manual'; + batteryLevel?: number; + connectionType?: 'wifi' | 'cellular' | 'none'; + deviceMotion?: 'stationary' | 'walking' | 'driving' | 'unknown'; + // Hier können weitere Metadaten hinzugefügt werden + }; + quality: { + accuracyLevel: AccuracyLevel; + horizontalAccuracy: number; + verticalAccuracy?: number; + isSignificantLocation: boolean; + }; +} +``` + +### Standortkonsolidierung anpassen +Um den Radius für die Standortkonsolidierung anzupassen, ändere den `consolidationRadius`-Parameter in der Datei `app/(tabs)/two.tsx`: + +```javascript +const [consolidationRadius, setConsolidationRadius] = useState(100); // Radius in Metern +``` + +Ein größerer Radius fasst mehr Standorte zusammen, während ein kleinerer Radius zu einer feineren Aufteilung führt. + +## Fehlerbehebung + +### Standort wird nicht angezeigt +- Stelle sicher, dass die Standortberechtigung erteilt wurde +- Überprüfe, ob die Standortdienste auf deinem Gerät aktiviert sind +- Bei Verwendung eines Emulators: Simuliere einen Standort + +### App stürzt ab oder reagiert nicht +- Überprüfe die Konsolenausgabe auf Fehlermeldungen +- Stelle sicher, dass alle Abhängigkeiten korrekt installiert sind +- Versuche, die App neu zu starten + +### Migration bestehender Daten +- Die App migriert automatisch bestehende Standortdaten beim ersten Laden nach dem Update +- Migrationsstatus wird in den Logs angezeigt (grüne Info-Meldungen) +- Bei Problemen mit der Migration können die Standortdaten über "Verlauf löschen" zurückgesetzt werden + +### TypeScript/Lint Fehler +- Führe `npm run lint` aus, um Code-Qualitätsprobleme zu identifizieren +- Führe `npm run format` aus, um automatische Formatierungskorrekturen anzuwenden + +## Lizenz + +Diese App ist unter der MIT-Lizenz lizenziert. Siehe die LICENSE-Datei für Details. + +## Kontakt + +Bei Fragen oder Problemen erstelle bitte ein Issue im GitHub-Repository oder kontaktiere den Entwickler direkt. + +--- + +Entwickelt mit ❤️ und Expo React Native diff --git a/apps/traces/apps/mobile/app-env.d.ts b/apps/traces/apps/mobile/app-env.d.ts new file mode 100644 index 000000000..88dc403ea --- /dev/null +++ b/apps/traces/apps/mobile/app-env.d.ts @@ -0,0 +1,2 @@ +// @ts-ignore +/// diff --git a/apps/traces/apps/mobile/app.json b/apps/traces/apps/mobile/app.json new file mode 100644 index 000000000..cb25eb322 --- /dev/null +++ b/apps/traces/apps/mobile/app.json @@ -0,0 +1,90 @@ +{ + "expo": { + "name": "Traces", + "slug": "locations", + "version": "1.0.0", + "scheme": "traces", + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Diese App benötigt Zugriff auf Ihren Standort, auch im Hintergrund, um standortbezogene Funktionen anzubieten.", + "locationAlwaysPermission": "Diese App benötigt Zugriff auf Ihren Standort im Hintergrund, um standortbezogene Funktionen anzubieten.", + "locationWhenInUsePermission": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten." + } + ], + [ + "expo-media-library", + { + "photosPermission": "Diese App benötigt Zugriff auf Ihre Fotos, um GPS-Daten aus Bildern zu extrahieren und Ihre Reise-Historie zu importieren.", + "savePhotosPermission": "Diese App speichert keine Fotos, sondern liest nur GPS-Metadaten.", + "isAccessMediaLocationEnabled": true + } + ] + ], + "experiments": { + "typedRoutes": true, + "tsconfigPaths": true + }, + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.memoro.locations", + "icon": "./assets/traces.icon", + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, auch im Hintergrund, um standortbezogene Funktionen anzubieten.", + "NSLocationAlwaysUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten.", + "NSPhotoLibraryUsageDescription": "Diese App benötigt Zugriff auf Ihre Fotos, um GPS-Daten aus Bildern zu extrahieren.", + "UIBackgroundModes": ["location", "fetch", "processing"], + "BGTaskSchedulerPermittedIdentifiers": [ + "com.memoro.locations.locationupdatetask", + "com.memoro.locations.locationprocessingtask" + ] + }, + "config": { + "usesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.memoro.locations", + "permissions": [ + "ACCESS_COARSE_LOCATION", + "ACCESS_FINE_LOCATION", + "ACCESS_BACKGROUND_LOCATION", + "ACCESS_MEDIA_LOCATION", + "READ_EXTERNAL_STORAGE", + "READ_MEDIA_IMAGES", + "FOREGROUND_SERVICE", + "FOREGROUND_SERVICE_LOCATION" + ] + }, + "owner": "memoro", + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "555a9045-475c-4226-a237-3ffe5366e446" + } + } + } +} diff --git a/apps/traces/apps/mobile/app/(tabs)/_layout.tsx b/apps/traces/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 000000000..5881a51ed --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,32 @@ +import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; + +export default function TabLayout() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx b/apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx new file mode 100644 index 000000000..7dce808a7 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function CitiesLayout() { + return ; +} diff --git a/apps/traces/apps/mobile/app/(tabs)/cities/index.tsx b/apps/traces/apps/mobile/app/(tabs)/cities/index.tsx new file mode 100644 index 000000000..77f25e9e6 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/cities/index.tsx @@ -0,0 +1,140 @@ +import { useState, useCallback, useEffect } from 'react'; +import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native'; +import { useFocusEffect, router } from 'expo-router'; + +import { useTheme } from '../../../utils/themeContext'; +import { getCitiesFromLocations, type CityVisit } from '../../../utils/placeService'; +import { apiFetch, getAuthToken } from '../../../utils/apiClient'; +import { SettingsButton } from '../../../components/SettingsButton'; +import type { CityVisitResponse } from '@traces/types'; + +export default function CitiesScreen() { + const { isDarkMode } = useTheme(); + const [cities, setCities] = useState<(CityVisit | CityVisitResponse)[]>([]); + const [loading, setLoading] = useState(true); + const [isOnline, setIsOnline] = useState(false); + + const loadCities = useCallback(async () => { + setLoading(true); + try { + // Try backend first + const token = await getAuthToken(); + if (token) { + try { + const backendCities = await apiFetch('/api/v1/cities'); + setCities(backendCities); + setIsOnline(true); + setLoading(false); + return; + } catch { + // Fall back to local + } + } + + // Offline fallback: use local data + const localCities = await getCitiesFromLocations(); + setCities(localCities); + setIsOnline(false); + } catch (error) { + console.error('Failed to load cities:', error); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + loadCities(); + }, [loadCities]) + ); + + const formatDuration = (ms: number) => { + const hours = Math.floor(ms / (1000 * 60 * 60)); + const days = Math.floor(hours / 24); + if (days > 0) return `${days} Tag${days > 1 ? 'e' : ''}`; + if (hours > 0) return `${hours} Std.`; + const minutes = Math.floor(ms / (1000 * 60)); + return `${minutes} Min.`; + }; + + const getCityName = (item: CityVisit | CityVisitResponse): string => { + if ('city' in item && typeof item.city === 'object') + return (item as CityVisitResponse).city.name; + return (item as CityVisit).city; + }; + + const getVisitCount = (item: CityVisit | CityVisitResponse): number => { + return item.visitCount; + }; + + const getDuration = (item: CityVisit | CityVisitResponse): number => { + if ('totalDurationMs' in item) return item.totalDurationMs; + return (item as CityVisit).totalDuration; + }; + + const getCityId = (item: CityVisit | CityVisitResponse): string | undefined => { + if ('city' in item && typeof item.city === 'object') return (item as CityVisitResponse).city.id; + return undefined; + }; + + const renderCity = ({ item }: { item: CityVisit | CityVisitResponse }) => ( + { + const cityId = getCityId(item); + if (cityId) { + router.push({ pathname: '/city-detail', params: { id: cityId } }); + } + }} + > + + {getCityName(item)} + + + + {getVisitCount(item)} Besuch{getVisitCount(item) !== 1 ? 'e' : ''} + + + {formatDuration(getDuration(item))} + + + + ); + + return ( + + + + Städte + + + + + {!isOnline && ( + + Offline-Modus (lokale Daten) + + )} + + {loading ? ( + + + + ) : cities.length === 0 ? ( + + + Noch keine Städte erkannt. Starte das Tracking, um besuchte Städte zu sehen. + + + ) : ( + getCityId(item) || `city-${index}`} + contentContainerStyle={{ paddingTop: 8, paddingBottom: 20 }} + /> + )} + + ); +} diff --git a/apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx b/apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx new file mode 100644 index 000000000..e872d4133 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function GuidesLayout() { + return ; +} diff --git a/apps/traces/apps/mobile/app/(tabs)/guides/index.tsx b/apps/traces/apps/mobile/app/(tabs)/guides/index.tsx new file mode 100644 index 000000000..19f6e144b --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/guides/index.tsx @@ -0,0 +1,137 @@ +import { useState, useCallback } from 'react'; +import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native'; +import { useFocusEffect, router } from 'expo-router'; + +import { useTheme } from '../../../utils/themeContext'; +import { apiFetch, getAuthToken } from '../../../utils/apiClient'; +import { SettingsButton } from '../../../components/SettingsButton'; +import type { GuideResponse } from '@traces/types'; + +export default function GuidesScreen() { + const { isDarkMode } = useTheme(); + const [guides, setGuides] = useState([]); + const [loading, setLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + const loadGuides = useCallback(async () => { + setLoading(true); + try { + const token = await getAuthToken(); + if (!token) { + setIsAuthenticated(false); + setLoading(false); + return; + } + setIsAuthenticated(true); + + const result = await apiFetch('/api/v1/guides'); + setGuides(result); + } catch (error) { + console.error('Failed to load guides:', error); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + loadGuides(); + }, [loadGuides]) + ); + + const getStatusLabel = (status: string) => { + switch (status) { + case 'generating': + return { text: 'Wird erstellt...', color: 'text-yellow-600' }; + case 'ready': + return { text: 'Bereit', color: 'text-green-600' }; + case 'error': + return { text: 'Fehler', color: 'text-red-600' }; + default: + return { text: status, color: 'text-gray-500' }; + } + }; + + const renderGuide = ({ item }: { item: GuideResponse }) => { + const status = getStatusLabel(item.status); + + return ( + { + if (item.status === 'ready') { + router.push({ pathname: '/guide-detail', params: { id: item.id } }); + } + }} + disabled={item.status !== 'ready'} + > + + + {item.title} + + {status.text} + + + {item.description && ( + + {item.description} + + )} + + + {item.estimatedDurationMin && ( + + ~{item.estimatedDurationMin} Min. + + )} + {item.distanceMeters && ( + + {item.distanceMeters >= 1000 + ? `${(item.distanceMeters / 1000).toFixed(1)} km` + : `${item.distanceMeters}m`} + + )} + + + ); + }; + + return ( + + + + Führungen + + + + + {loading ? ( + + + + ) : !isAuthenticated ? ( + + + Melde dich an, um KI-Stadtführungen zu erstellen und zu sehen. + + + ) : guides.length === 0 ? ( + + + Noch keine Führungen. Gehe zu einer Stadt und erstelle deine erste KI-Stadtführung. + + + ) : ( + item.id} + contentContainerStyle={{ paddingTop: 8, paddingBottom: 20 }} + /> + )} + + ); +} diff --git a/apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx b/apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx new file mode 100644 index 000000000..28b30d417 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx @@ -0,0 +1,28 @@ +import { Stack } from 'expo-router'; + +import { useTheme } from '~/utils/themeContext'; + +export default function HistoryLayout() { + const { isDarkMode } = useTheme(); + + return ( + + + + ); +} diff --git a/apps/traces/apps/mobile/app/(tabs)/history/index.tsx b/apps/traces/apps/mobile/app/(tabs)/history/index.tsx new file mode 100644 index 000000000..e5144f3a4 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/history/index.tsx @@ -0,0 +1,122 @@ +import { FontAwesome } from '@expo/vector-icons'; +import { useRouter, useNavigation } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { StyleSheet, View, Pressable } from 'react-native'; + +import { ConsolidatedLocationList } from '~/components/ConsolidatedLocationList'; +import { LocationHistoryList } from '~/components/LocationHistoryList'; +import { SegmentedControl, SegmentedControlOption } from '~/components/SegmentedControl'; +import { ThemeWrapper } from '~/components/ThemeWrapper'; +import { ConsolidatedLocation, consolidateLocationsByProximity } from '~/utils/locationHelper'; +import { LocationData, getLocationHistory } from '~/utils/locationService'; +import { useTheme } from '~/utils/themeContext'; + +export default function HistoryScreen() { + const [locationHistory, setLocationHistory] = useState([]); + const [consolidatedLocations, setConsolidatedLocations] = useState([]); + const [showConsolidated, setShowConsolidated] = useState(false); + const [consolidationRadius, setConsolidationRadius] = useState(100); + const router = useRouter(); + + const segmentedOptions: SegmentedControlOption[] = [ + { value: 'all', label: 'Alle Standorte', icon: 'list' }, + { + value: 'consolidated', + label: 'Zusammengefasst', + icon: 'compress', + badge: consolidatedLocations.length, + }, + ]; + + useEffect(() => { + loadLocationHistory(); + const interval = setInterval(loadLocationHistory, 10000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (locationHistory.length > 0) { + const consolidated = consolidateLocationsByProximity(locationHistory, consolidationRadius); + setConsolidatedLocations(consolidated); + } else { + setConsolidatedLocations([]); + } + }, [locationHistory, consolidationRadius]); + + const loadLocationHistory = async () => { + const history = await getLocationHistory(); + setLocationHistory(history); + }; + + const handleLocationPress = (location: LocationData) => { + router.navigate('/'); + }; + + const handleConsolidatedLocationPress = (location: ConsolidatedLocation) => { + router.navigate('/'); + }; + + const { isDarkMode, colors } = useTheme(); + const navigation = useNavigation(); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + router.push('/settings')} + style={({ pressed }) => ({ + opacity: pressed ? 0.5 : 1, + paddingHorizontal: 16, + paddingVertical: 8, + })} + > + + + ), + headerRightContainerStyle: { + paddingRight: 8, + }, + }); + }, [isDarkMode, navigation, router]); + + return ( + + + + {showConsolidated ? ( + + ) : ( + + )} + + + setShowConsolidated(value === 'consolidated')} + isDarkMode={isDarkMode} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + listContainer: { + flex: 1, + paddingBottom: 80, + }, +}); diff --git a/apps/traces/apps/mobile/app/(tabs)/index.tsx b/apps/traces/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 000000000..aa4a9c73b --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,243 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { FontAwesome } from '@expo/vector-icons'; +import * as Location from 'expo-location'; +import { Stack, useRouter, Link } from 'expo-router'; +import { useEffect, useState, useRef } from 'react'; +import { StyleSheet, View, Alert, Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { HeaderButton } from '~/components/HeaderButton'; +import { LocationMap } from '~/components/LocationMap'; +import { SettingsButton } from '~/components/SettingsButton'; +import { ThemeWrapper } from '~/components/ThemeWrapper'; +import { TrackingControls, TRACKING_INTERVALS } from '~/components/TrackingControls'; +import { stopBackgroundLocationTask } from '~/utils/backgroundLocationTask'; +import { + DEFAULT_INTERVAL_KEY, + getDefaultInterval, + LocationData, + requestLocationPermissions, + getCurrentLocation, + startLocationTracking, + getLocationHistory, +} from '~/utils/locationService'; +import { useTheme } from '~/utils/themeContext'; + +export default function Home() { + const router = useRouter(); + const [currentLocation, setCurrentLocation] = useState(null); + const [locationHistory, setLocationHistory] = useState([]); + const [isTracking, setIsTracking] = useState(false); + const [selectedInterval, setSelectedInterval] = useState(TRACKING_INTERVALS[0].value); + const locationSubscription = useRef(null); + + // Sofortige Berechtigungsanfrage beim Laden + useEffect(() => { + const requestPermissions = async () => { + try { + // Zuerst direkt die Vordergrund-Berechtigung anfordern + const foreground = await Location.requestForegroundPermissionsAsync(); + + if (foreground.status === 'granted') { + // Wenn Vordergrund genehmigt, dann Hintergrund anfragen + await Location.requestBackgroundPermissionsAsync(); + } + } catch (error) { + console.error('Error requesting initial permissions:', error); + } + }; + + requestPermissions(); + }, []); + + // Load location history on mount + useEffect(() => { + loadLocationHistory(); + loadDefaultInterval(); + + // Get initial location + getCurrentLocation().then((location) => { + if (location) { + setCurrentLocation(location); + } + }); + + return () => { + // Clean up subscription when component unmounts + if (locationSubscription.current) { + locationSubscription.current.remove(); + } + + // Stoppe auch die Hintergrund-Standortverfolgung beim Beenden + stopBackgroundLocationTask().catch((err) => + console.error('Fehler beim Stoppen der Hintergrund-Standortverfolgung:', err) + ); + }; + }, []); // Nur beim Mount ausführen + + // Separater useEffect für History-Updates während Tracking + useEffect(() => { + if (!isTracking) return; + + // Intervall zum regelmäßigen Aktualisieren der Standorthistorie + const historyUpdateInterval = setInterval(() => { + loadLocationHistory(); + }, 3000); // Alle 3 Sekunden aktualisieren wenn Tracking aktiv (für bessere UI-Updates) + + return () => { + clearInterval(historyUpdateInterval); + }; + }, [isTracking]); + + const loadLocationHistory = async () => { + const history = await getLocationHistory(); + setLocationHistory(history); + }; + + // Lade den Standard-Intervall + const loadDefaultInterval = async () => { + try { + const interval = await getDefaultInterval(); + if (interval !== null) { + setSelectedInterval(interval); + } + } catch (error) { + console.error('Fehler beim Laden des Standard-Intervalls:', error); + } + }; + + const handleStartTracking = async (interval: number = TRACKING_INTERVALS[0].value) => { + const hasPermission = await requestLocationPermissions(); + if (!hasPermission) { + Alert.alert( + 'Standort-Berechtigung benötigt', + 'Diese App benötigt Zugriff auf deinen Standort, um deine Bewegungen zu verfolgen.', + [{ text: 'OK' }] + ); + return; + } + + setSelectedInterval(interval); + + // Bestimme die Distanz basierend auf dem Intervall + let distanceInterval = 10; // Standard: 10 Meter + + if (interval >= 3 * 60 * 60 * 1000) { + // 3 Stunden oder mehr + distanceInterval = 100; + } else if (interval >= 60 * 60 * 1000) { + // 1 Stunde oder mehr + distanceInterval = 50; + } + + const subscription = await startLocationTracking( + (location) => { + setCurrentLocation(location); + // Sofortige Aktualisierung der History bei neuen Standorten + setTimeout(() => loadLocationHistory(), 500); // Kurze Verzögerung damit Speichervorgang abgeschlossen ist + }, + interval, // Intervall aus der Auswahl + distanceInterval // Distanz basierend auf dem Intervall + ); + + if (subscription) { + locationSubscription.current = subscription; + setIsTracking(true); + // Sofortiges Update der History nach Tracking-Start + setTimeout(() => loadLocationHistory(), 1000); + } + }; + + const handleStopTracking = async () => { + if (locationSubscription.current) { + locationSubscription.current.remove(); + locationSubscription.current = null; + } + + // Stoppe auch die Hintergrund-Standortverfolgung + await stopBackgroundLocationTask(); + + setIsTracking(false); + }; + + const { isDarkMode } = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + + ( + + + + ), + headerLeft: () => ( + + + + ), + }} + /> + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mapContainer: { + flex: 1, + borderRadius: 8, + overflow: 'hidden', + borderWidth: 1, + borderColor: '#e0e0e0', + marginBottom: 16, + }, +}); diff --git a/apps/traces/apps/mobile/app/(tabs)/logs.tsx b/apps/traces/apps/mobile/app/(tabs)/logs.tsx new file mode 100644 index 000000000..1319e1a5d --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/logs.tsx @@ -0,0 +1,80 @@ +import { FontAwesome } from '@expo/vector-icons'; +import { Stack, Link } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { StyleSheet, View, Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { LogsList } from '~/components/LogsList'; +import { SettingsButton } from '~/components/SettingsButton'; +import { ThemeWrapper } from '~/components/ThemeWrapper'; +import { getStoredLogs } from '~/utils/logService'; +import { useTheme } from '~/utils/themeContext'; + +export interface LogEntry { + id: string; + timestamp: number; + level: 'info' | 'warning' | 'error'; + message: string; + details?: any; +} + +export default function LogsScreen() { + const [logs, setLogs] = useState([]); + const { isDarkMode } = useTheme(); + const insets = useSafeAreaInsets(); + + useEffect(() => { + loadLogs(); + + // Set up a listener to refresh logs every 5 seconds + const interval = setInterval(loadLogs, 5000); + + return () => clearInterval(interval); + }, []); + + const loadLogs = async () => { + const storedLogs = await getStoredLogs(); + setLogs(storedLogs); + }; + + return ( + + ( + + + + ), + }} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/apps/traces/apps/mobile/app/(tabs)/map.tsx b/apps/traces/apps/mobile/app/(tabs)/map.tsx new file mode 100644 index 000000000..521ed2baf --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/map.tsx @@ -0,0 +1,737 @@ +import { FontAwesome } from '@expo/vector-icons'; +import { Stack, Link } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet, View, Pressable, ScrollView, Text, TouchableOpacity } from 'react-native'; +import MapView, { Marker, Polyline } from 'react-native-maps'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SettingsButton } from '~/components/SettingsButton'; +import { ThemeWrapper } from '~/components/ThemeWrapper'; +import { LocationData, getLocationHistory } from '~/utils/locationService'; +import { useTheme } from '~/utils/themeContext'; + +export type TimeFilter = 'today' | 'week' | 'month' | 'year' | 'all'; + +interface TimeFilterOption { + id: TimeFilter; + label: string; + icon: React.ComponentProps['name']; +} + +const TIME_FILTERS: TimeFilterOption[] = [ + { id: 'today', label: 'Heute', icon: 'calendar-o' }, + { id: 'week', label: 'Woche', icon: 'calendar' }, + { id: 'month', label: 'Monat', icon: 'calendar' }, + { id: 'year', label: 'Jahr', icon: 'calendar' }, + { id: 'all', label: 'Alle', icon: 'globe' }, +]; + +export default function MapOverviewScreen() { + const { isDarkMode, colors } = useTheme(); + const insets = useSafeAreaInsets(); + const [locationHistory, setLocationHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedTimeFilter, setSelectedTimeFilter] = useState('all'); + const [showRoute, setShowRoute] = useState(true); + const [showHeatmap, setShowHeatmap] = useState(false); + + useEffect(() => { + loadLocationHistory(); + + // Auto-Update alle 30 Sekunden + const interval = setInterval(loadLocationHistory, 30000); + return () => clearInterval(interval); + }, []); + + const loadLocationHistory = async () => { + setLoading(true); + try { + const history = await getLocationHistory(); + setLocationHistory(history); + } catch (error) { + console.error('Fehler beim Laden des Standortverlaufs:', error); + } finally { + setLoading(false); + } + }; + + // Filter locations based on selected time range + const getFilteredLocations = (): LocationData[] => { + if (selectedTimeFilter === 'all') return locationHistory; + + const now = Date.now(); + let cutoffTime = 0; + + switch (selectedTimeFilter) { + case 'today': + const today = new Date(); + today.setHours(0, 0, 0, 0); + cutoffTime = today.getTime(); + break; + case 'week': + cutoffTime = now - 7 * 24 * 60 * 60 * 1000; + break; + case 'month': + cutoffTime = now - 30 * 24 * 60 * 60 * 1000; + break; + case 'year': + cutoffTime = now - 365 * 24 * 60 * 60 * 1000; + break; + } + + return locationHistory.filter((loc) => { + const timestamp = loc.timestamps?.recordedMs || loc.timestamp || 0; + return timestamp >= cutoffTime; + }); + }; + + // Cluster nearby locations for heatmap view + const clusterLocations = ( + locations: LocationData[], + radiusKm: number = 0.1 + ): Array<{ + latitude: number; + longitude: number; + count: number; + locations: LocationData[]; + }> => { + const clusters: Array<{ + latitude: number; + longitude: number; + count: number; + locations: LocationData[]; + }> = []; + + locations.forEach((loc) => { + const existingCluster = clusters.find((cluster) => { + const distance = calculateDistance( + loc.latitude, + loc.longitude, + cluster.latitude, + cluster.longitude + ); + return distance <= radiusKm; + }); + + if (existingCluster) { + existingCluster.count++; + existingCluster.locations.push(loc); + const totalLat = existingCluster.locations.reduce((sum, l) => sum + l.latitude, 0); + const totalLng = existingCluster.locations.reduce((sum, l) => sum + l.longitude, 0); + existingCluster.latitude = totalLat / existingCluster.locations.length; + existingCluster.longitude = totalLng / existingCluster.locations.length; + } else { + clusters.push({ + latitude: loc.latitude, + longitude: loc.longitude, + count: 1, + locations: [loc], + }); + } + }); + + return clusters; + }; + + // Calculate distance between two coordinates (Haversine formula) + const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371; // Earth's radius in km + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + }; + + // Get color intensity based on cluster count + const getHeatmapColor = (count: number, maxCount: number): string => { + const intensity = Math.min(count / maxCount, 1); + if (intensity < 0.3) return '#4CAF50'; // Green + if (intensity < 0.6) return '#FF9800'; // Orange + return '#F44336'; // Red + }; + + const filteredLocations = getFilteredLocations(); + const locationClusters = clusterLocations(filteredLocations); + const maxClusterCount = Math.max(...locationClusters.map((c) => c.count), 1); + + // Berechne die Region, die alle Standorte umfasst + const getRegionForLocations = () => { + const locations = filteredLocations.length > 0 ? filteredLocations : locationHistory; + + if (locations.length === 0) { + // Standard-Region (Apple Campus) + return { + latitude: 37.33233141, + longitude: -122.0312186, + latitudeDelta: 0.01, + longitudeDelta: 0.01, + }; + } + + if (locations.length === 1) { + // Einzelner Standort + const location = locations[0]; + return { + latitude: location.latitude, + longitude: location.longitude, + latitudeDelta: 0.01, + longitudeDelta: 0.01, + }; + } + + // Mehrere Standorte - berechne Bounding Box + const lats = locations.map((loc) => loc.latitude); + const lngs = locations.map((loc) => loc.longitude); + + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLng = Math.min(...lngs); + const maxLng = Math.max(...lngs); + + const centerLat = (minLat + maxLat) / 2; + const centerLng = (minLng + maxLng) / 2; + + // Füge etwas Padding hinzu + const latDelta = (maxLat - minLat) * 1.2 || 0.01; + const lngDelta = (maxLng - minLng) * 1.2 || 0.01; + + return { + latitude: centerLat, + longitude: centerLng, + latitudeDelta: Math.max(latDelta, 0.01), + longitudeDelta: Math.max(lngDelta, 0.01), + }; + }; + + // Erstelle Koordinaten für die Polyline (chronologischer Pfad) + const getPolylineCoordinates = () => { + return filteredLocations + .sort((a, b) => { + const aTime = a.timestamps?.recordedMs || a.timestamp || 0; + const bTime = b.timestamps?.recordedMs || b.timestamp || 0; + return aTime - bTime; + }) + .map((location) => ({ + latitude: location.latitude, + longitude: location.longitude, + })); + }; + + return ( + + ( + + + + ), + }} + /> + + + {/* Polyline für den Bewegungspfad */} + {showRoute && filteredLocations.length > 1 && ( + + )} + + {/* Marker - Heatmap oder normale Ansicht */} + {showHeatmap + ? // Heatmap: Cluster anzeigen + locationClusters.map((cluster, index) => ( + + + {cluster.count} + + + )) + : // Normale Ansicht: Einzelne Marker + filteredLocations.map((location, index) => ( + + ))} + + + {/* Time Filter */} + + {TIME_FILTERS.map((filter) => ( + setSelectedTimeFilter(filter.id)} + > + + + {filter.label} + + + ))} + + + {/* View Mode Controls */} + + {/* Route Toggle */} + setShowRoute(!showRoute)} + > + + + + {/* Heatmap Toggle */} + setShowHeatmap(!showHeatmap)} + > + + + + + {/* Location Count Badge */} + + + + {filteredLocations.length} + + + + + ); +} + +// Dark Mode Map Style (minimalistisch) +const darkMapStyle = [ + { + elementType: 'geometry', + stylers: [ + { + color: '#1d2c4d', + }, + ], + }, + { + elementType: 'labels.text.fill', + stylers: [ + { + color: '#8ec3b9', + }, + ], + }, + { + elementType: 'labels.text.stroke', + stylers: [ + { + color: '#1a3646', + }, + ], + }, + { + featureType: 'administrative.country', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#4b6878', + }, + ], + }, + { + featureType: 'administrative.land_parcel', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#64779f', + }, + ], + }, + { + featureType: 'administrative.province', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#4b6878', + }, + ], + }, + { + featureType: 'landscape.man_made', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#334e87', + }, + ], + }, + { + featureType: 'landscape.natural', + elementType: 'geometry', + stylers: [ + { + color: '#023e58', + }, + ], + }, + { + featureType: 'poi', + elementType: 'geometry', + stylers: [ + { + color: '#283d6a', + }, + ], + }, + { + featureType: 'poi', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#6f9ba5', + }, + ], + }, + { + featureType: 'poi', + elementType: 'labels.text.stroke', + stylers: [ + { + color: '#1d2c4d', + }, + ], + }, + { + featureType: 'poi.park', + elementType: 'geometry.fill', + stylers: [ + { + color: '#023e58', + }, + ], + }, + { + featureType: 'poi.park', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#3C7680', + }, + ], + }, + { + featureType: 'road', + elementType: 'geometry', + stylers: [ + { + color: '#304a7d', + }, + ], + }, + { + featureType: 'road', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#98a5be', + }, + ], + }, + { + featureType: 'road', + elementType: 'labels.text.stroke', + stylers: [ + { + color: '#1d2c4d', + }, + ], + }, + { + featureType: 'road.highway', + elementType: 'geometry', + stylers: [ + { + color: '#2c6675', + }, + ], + }, + { + featureType: 'road.highway', + elementType: 'geometry.stroke', + stylers: [ + { + color: '#255763', + }, + ], + }, + { + featureType: 'road.highway', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#b0d5ce', + }, + ], + }, + { + featureType: 'road.highway', + elementType: 'labels.text.stroke', + stylers: [ + { + color: '#023e58', + }, + ], + }, + { + featureType: 'transit', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#98a5be', + }, + ], + }, + { + featureType: 'transit', + elementType: 'labels.text.stroke', + stylers: [ + { + color: '#1d2c4d', + }, + ], + }, + { + featureType: 'transit.line', + elementType: 'geometry.fill', + stylers: [ + { + color: '#283d6a', + }, + ], + }, + { + featureType: 'transit.station', + elementType: 'geometry', + stylers: [ + { + color: '#3a4762', + }, + ], + }, + { + featureType: 'water', + elementType: 'geometry', + stylers: [ + { + color: '#0e1626', + }, + ], + }, + { + featureType: 'water', + elementType: 'labels.text.fill', + stylers: [ + { + color: '#4e6d70', + }, + ], + }, +]; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + map: { + flex: 1, + }, + filterScrollView: { + position: 'absolute', + bottom: 100, + left: 0, + right: 0, + maxHeight: 60, + }, + filterContainer: { + paddingHorizontal: 12, + gap: 10, + }, + filterButton: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 24, + paddingHorizontal: 18, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 3, + }, + filterText: { + fontSize: 15, + color: '#666', + fontWeight: '600', + }, + viewControls: { + position: 'absolute', + bottom: 170, + right: 16, + flexDirection: 'column', + gap: 10, + }, + controlButton: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 28, + width: 56, + height: 56, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 4, + }, + clusterMarker: { + justifyContent: 'center', + alignItems: 'center', + opacity: 0.8, + borderWidth: 2, + borderColor: 'white', + }, + clusterText: { + color: 'white', + fontWeight: 'bold', + fontSize: 14, + }, + locationCountBadge: { + position: 'absolute', + bottom: 16, + left: 16, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + locationCountText: { + fontSize: 16, + fontWeight: 'bold', + color: '#000', + }, +}); diff --git a/apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx b/apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx new file mode 100644 index 000000000..28fc30c35 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx @@ -0,0 +1,28 @@ +import { Stack } from 'expo-router'; + +import { useTheme } from '~/utils/themeContext'; + +export default function PlacesLayout() { + const { isDarkMode } = useTheme(); + + return ( + + + + ); +} diff --git a/apps/traces/apps/mobile/app/(tabs)/places/index.tsx b/apps/traces/apps/mobile/app/(tabs)/places/index.tsx new file mode 100644 index 000000000..35efe4f48 --- /dev/null +++ b/apps/traces/apps/mobile/app/(tabs)/places/index.tsx @@ -0,0 +1,182 @@ +import { FontAwesome } from '@expo/vector-icons'; +import { useRouter, useNavigation } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet, View, Alert, Pressable } from 'react-native'; + +import { CitiesList } from '~/components/CitiesList'; +import { CountriesList } from '~/components/CountriesList'; +import { PlacesList } from '~/components/PlacesList'; +import { SegmentedControl, SegmentedControlOption } from '~/components/SegmentedControl'; +import { ThemeWrapper } from '~/components/ThemeWrapper'; +import { Place, ConsolidatedLocation } from '~/utils/locationHelper'; +import { + getSavedPlaces, + getFrequentLocations, + createPlaceFromLocation, + getCitiesFromLocations, + getCountriesFromLocations, + CityVisit, + CountryVisit, +} from '~/utils/placeService'; +import { useTheme } from '~/utils/themeContext'; + +export default function PlacesScreen() { + const { isDarkMode, colors } = useTheme(); + const router = useRouter(); + const navigation = useNavigation(); + + const [savedPlaces, setSavedPlaces] = useState([]); + const [frequentLocations, setFrequentLocations] = useState<(ConsolidatedLocation | Place)[]>([]); + const [cities, setCities] = useState([]); + const [countries, setCountries] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'frequent' | 'cities' | 'countries'>('frequent'); + + const segmentedOptions: SegmentedControlOption[] = [ + { value: 'frequent', label: 'Orte', icon: 'map-marker' }, + { value: 'cities', label: 'Städte', icon: 'building' }, + { value: 'countries', label: 'Länder', icon: 'globe' }, + ]; + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const places = await getSavedPlaces(); + setSavedPlaces(places); + + const frequentLocs = await getFrequentLocations(1); + + const filteredFrequentLocations = frequentLocs.filter((loc) => { + return !places.some( + (place) => + Math.abs(place.latitude - loc.latitude) < 0.0005 && + Math.abs(place.longitude - loc.longitude) < 0.0005 + ); + }); + + const allLocations = [...places, ...filteredFrequentLocations]; + setFrequentLocations(allLocations); + + // Lade Städte + const citiesData = await getCitiesFromLocations(); + setCities(citiesData); + + // Lade Länder + const countriesData = await getCountriesFromLocations(); + setCountries(countriesData); + } catch (error) { + console.error('Fehler beim Laden der Orte:', error); + Alert.alert('Fehler', 'Die Orte konnten nicht geladen werden.'); + } finally { + setLoading(false); + } + }; + + const handlePlacePress = (place: Place | ConsolidatedLocation) => { + if ('id' in place) { + router.push({ + pathname: '/place-details', + params: { placeId: place.id }, + }); + } else { + Alert.alert( + 'Ort erstellen', + 'Möchtest du aus diesem häufig besuchten Ort einen benannten Ort erstellen?', + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Erstellen', + onPress: () => handleAddPlace(place), + }, + ] + ); + } + }; + + const handleAddPlace = (location: ConsolidatedLocation) => { + Alert.prompt( + 'Neuer Ort', + 'Wie soll dieser Ort heißen?', + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Erstellen', + onPress: async (name: string | undefined) => { + if (name && name.trim()) { + const newPlace = createPlaceFromLocation(location, name.trim()); + const { savePlace } = require('~/utils/placeService'); + await savePlace(newPlace); + await loadData(); + } else { + Alert.alert('Fehler', 'Bitte gib einen Namen für den Ort ein.'); + } + }, + }, + ], + 'plain-text' + ); + }; + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + router.push('/settings')} + style={({ pressed }) => ({ + opacity: pressed ? 0.5 : 1, + paddingHorizontal: 16, + paddingVertical: 8, + })} + > + + + ), + headerRightContainerStyle: { + paddingRight: 8, + }, + }); + }, [isDarkMode, navigation, router]); + + return ( + + + + {activeTab === 'frequent' ? ( + + ) : activeTab === 'cities' ? ( + + ) : ( + + )} + + + setActiveTab(value as 'frequent' | 'cities' | 'countries')} + isDarkMode={isDarkMode} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + flex: 1, + paddingBottom: 80, + }, +}); diff --git a/apps/traces/apps/mobile/app/+html.tsx b/apps/traces/apps/mobile/app/+html.tsx new file mode 100644 index 000000000..4068c9f94 --- /dev/null +++ b/apps/traces/apps/mobile/app/+html.tsx @@ -0,0 +1,57 @@ +import { ScrollViewStyleReset } from 'expo-router/html'; + +// This file is web-only and used to configure the root HTML for every +// web page during static rendering. +// The contents of this function only run in Node.js environments and +// do not have access to the DOM or browser APIs. +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + + {/* + This viewport disables scaling which makes the mobile website act more like a native app. + However this does reduce built-in accessibility. If you want to enable scaling, use this instead: + + */} + + {/* + Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. + However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} +