diff --git a/.claude/plans/same-origin-unified-app-migration.md b/.claude/plans/same-origin-unified-app-migration.md index 8454b2cac..385b88e1b 100644 --- a/.claude/plans/same-origin-unified-app-migration.md +++ b/.claude/plans/same-origin-unified-app-migration.md @@ -4,6 +4,42 @@ > Eine IndexedDB, ein SyncEngine, ein Build, ein Deploy. > Games und Matrix bleiben separat. +## Status (Stand: 2026-04-01) + +### Abgeschlossene Phasen + +- **Phase 0:** Vorbereitung abgeschlossen — Modul-Struktur definiert, Route-Namespaces geplant +- **Phase 1:** Fundament steht — Unified Dexie-Datenbank mit 120+ Collections, Table-Name-Kollisionen aufgelöst, `SYNC_APP_MAP` definiert, SvelteKit-App unter `apps/manacore/apps/web/` existiert + +### Phase 2: Module migrieren — Fortschritt + +| # | App | Modul | Routen | Status | +|---|-----|-------|--------|--------| +| 1 | **calc** | collections, components (5 skins), engine, stores, queries | `/calc`, `/calc/standard` | **Done** | +| 2 | **zitare** | collections, stores (5), components (2), queries | `/zitare` + 6 Sub-Routen | **Done** | +| 3 | **clock** | collections, stores (6), components (2), queries | `/clock`, `/clock/alarms` | **Done** | +| 4 | **skilltree** | collections, stores (2), components (9), queries | Ordner-Struktur, keine .svelte-Dateien | **Modul done, Routen fehlen** | +| 5 | **moodlit** | collections, stores (2), components (3), queries | `/moodlit`, `/moodlit/moods`, `/moodlit/sequences` | **Done** | +| 6 | **inventar** | collections, queries, types (kein stores/components-Ordner) | Ordner-Struktur, keine .svelte-Dateien | **Modul teilweise, Routen fehlen** | +| 7-25 | times, planta, citycorners, photos, presi, uload, context, questions, nutriphi, storage, cards, contacts, todo, calendar, picture, chat, mukke, memoro, playground | Leere Modul-Ordner existieren | — | **Nicht begonnen** | + +### Offene Phasen + +- **Phase 3:** Split-Screen ohne iFrame — nicht begonnen +- **Phase 4:** Dashboard-Widgets — nicht begonnen +- **Phase 5:** Infrastruktur-Anpassungen (Docker, Cloudflare, CORS) — nicht begonnen +- **Phase 6:** Aufräumen (alte Apps archivieren) — nicht begonnen +- **Phase 7:** local-store Package anpassen — nicht begonnen + +### Nächste Schritte + +1. Skilltree + Inventar Route-Dateien erstellen (.svelte) +2. Weiter mit **times** (#7), **planta** (#8), **citycorners** (#9) — alle ohne Backend +3. Danach mittlere Apps: photos, presi, uload, context, questions +4. Zuletzt komplexe Apps: contacts, todo, calendar, picture, chat, mukke, memoro + +--- + ## Scope: Was rein kommt, was draußen bleibt ### IN SCOPE — Unified App (22 Apps → 1) diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/collections.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/collections.ts new file mode 100644 index 000000000..e639e93ca --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/collections.ts @@ -0,0 +1,120 @@ +/** + * CityCorners module — collection accessors and guest seed data. + * + * Uses prefixed table names in the unified DB: cities, ccLocations, ccFavorites. + */ + +import { db } from '$lib/data/database'; +import type { LocalCity, LocalLocation, LocalFavorite } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const cityTable = db.table('cities'); +export const ccLocationTable = db.table('ccLocations'); +export const ccFavoriteTable = db.table('ccFavorites'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const CITYCORNERS_GUEST_SEED = { + cities: [ + { + id: 'city-konstanz', + name: 'Konstanz', + slug: 'konstanz', + country: 'Deutschland', + state: 'Baden-Württemberg', + description: + 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt, direkt an der Schweizer Grenze.', + latitude: 47.6603, + longitude: 9.1757, + }, + { + id: 'city-zuerich', + name: 'Zürich', + slug: 'zuerich', + country: 'Schweiz', + state: 'Zürich', + description: + 'Größte Stadt der Schweiz am Zürichsee, bekannt für Kultur, Finanzen und hohe Lebensqualität.', + latitude: 47.3769, + longitude: 8.5417, + }, + { + id: 'city-berlin', + name: 'Berlin', + slug: 'berlin', + country: 'Deutschland', + state: 'Berlin', + description: 'Hauptstadt Deutschlands mit vielfältiger Kultur, Geschichte und Nachtleben.', + latitude: 52.52, + longitude: 13.405, + }, + ], + ccLocations: [ + { + id: 'loc-muenster', + cityId: 'city-konstanz', + name: 'Konstanzer Münster', + category: 'sight' as const, + description: + 'Das Münster Unserer Lieben Frau ist die ehemalige Bischofskirche des Bistums Konstanz und Wahrzeichen der Stadt.', + address: 'Münsterplatz 1, 78462 Konstanz', + latitude: 47.6603, + longitude: 9.1752, + }, + { + id: 'loc-imperia', + cityId: 'city-konstanz', + name: 'Imperia', + category: 'sight' as const, + description: + 'Die 9 Meter hohe Statue im Hafen von Konstanz dreht sich einmal in 4 Minuten um ihre Achse.', + address: 'Hafen, 78462 Konstanz', + latitude: 47.6596, + longitude: 9.1789, + }, + { + id: 'loc-insel', + cityId: 'city-konstanz', + name: 'Mainau – Blumeninsel', + category: 'park' as const, + description: + 'Die Blumeninsel Mainau im Bodensee ist bekannt für ihre Gärten, das Schmetterlingshaus und das Barockschloss.', + address: 'Mainau 1, 78465 Konstanz', + latitude: 47.7051, + longitude: 9.1919, + }, + { + id: 'loc-strandbad', + cityId: 'city-konstanz', + name: 'Strandbad Horn', + category: 'beach' as const, + description: 'Beliebtes Freibad am Bodensee mit Sandstrand und Blick auf die Alpen.', + address: 'Eichhornstraße 100, 78464 Konstanz', + latitude: 47.6753, + longitude: 9.2001, + }, + { + id: 'loc-grossmuenster', + cityId: 'city-zuerich', + name: 'Grossmünster', + category: 'sight' as const, + description: + 'Romanische Kirche aus dem 12. Jahrhundert, Wahrzeichen Zürichs mit Aussichtsturm über die Altstadt.', + address: 'Grossmünsterplatz, 8001 Zürich', + latitude: 47.3701, + longitude: 8.5441, + }, + { + id: 'loc-brandenburger-tor', + cityId: 'city-berlin', + name: 'Brandenburger Tor', + category: 'sight' as const, + description: + 'Das bekannteste Wahrzeichen Berlins und Symbol der deutschen Wiedervereinigung.', + address: 'Pariser Platz, 10117 Berlin', + latitude: 52.5163, + longitude: 13.3777, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/index.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/index.ts new file mode 100644 index 000000000..8abc1ec83 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/index.ts @@ -0,0 +1,25 @@ +/** + * CityCorners module — barrel exports. + */ + +export { favoritesStore } from './stores/favorites.svelte'; +export { + useAllCities, + useAllLocations, + useAllFavorites, + getFavoriteIds, + isFavorite, + filterByCity, + filterByCategory, + searchLocations, + searchCities, + findCityBySlug, + getLocationCountByCity, + getCityStats, + getPlatformStats, +} from './queries'; +export type { CityStats, PlatformStats } from './queries'; +export { cityTable, ccLocationTable, ccFavoriteTable, CITYCORNERS_GUEST_SEED } from './collections'; +export type { LocalCity, LocalLocation, LocalFavorite } from './types'; +export { CATEGORY_KEYS, CATEGORY_COLORS } from './types'; +export { isOpenNow, haversine, formatDistance } from './utils/opening-hours'; diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/queries.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/queries.ts new file mode 100644 index 000000000..53e5cbf52 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/queries.ts @@ -0,0 +1,169 @@ +/** + * Reactive Queries & Pure Filter Helpers for CityCorners + * + * Uses Dexie liveQuery on the unified DB. Components call these hooks + * at init time; no manual fetch/refresh needed. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalCity, LocalLocation, LocalFavorite } from './types'; + +// ─── Live Query Hooks ───────────────────────────────────── + +/** All cities, sorted by name. Auto-updates on any change. */ +export function useAllCities() { + return liveQuery(async () => { + const all = await db.table('cities').toArray(); + return all.filter((c) => !c.deletedAt).sort((a, b) => a.name.localeCompare(b.name)); + }); +} + +/** All locations, sorted by name. Auto-updates on any change. */ +export function useAllLocations() { + return liveQuery(async () => { + const all = await db.table('ccLocations').toArray(); + return all.filter((l) => !l.deletedAt).sort((a, b) => a.name.localeCompare(b.name)); + }); +} + +/** All favorites. Auto-updates on any change. */ +export function useAllFavorites() { + return liveQuery(async () => { + const all = await db.table('ccFavorites').toArray(); + return all.filter((f) => !f.deletedAt); + }); +} + +// ─── Pure Filter Functions (for $derived) ─────────────────── + +/** Get a Set of favorite location IDs for quick lookup. */ +export function getFavoriteIds(favorites: LocalFavorite[]): Set { + return new Set(favorites.map((f) => f.locationId)); +} + +/** Check if a location is favorited. */ +export function isFavorite(favorites: LocalFavorite[], locationId: string): boolean { + return favorites.some((f) => f.locationId === locationId); +} + +/** Filter locations by city. */ +export function filterByCity(locations: LocalLocation[], cityId: string): LocalLocation[] { + return locations.filter((l) => l.cityId === cityId); +} + +/** Filter locations by category. */ +export function filterByCategory( + locations: LocalLocation[], + category: string | null +): LocalLocation[] { + if (!category) return locations; + return locations.filter((l) => l.category === category); +} + +/** Filter locations by search query across name, description, address. */ +export function searchLocations(locations: LocalLocation[], query: string): LocalLocation[] { + if (!query.trim()) return locations; + const search = query.toLowerCase().trim(); + return locations.filter( + (l) => + l.name.toLowerCase().includes(search) || + l.description?.toLowerCase().includes(search) || + l.address?.toLowerCase().includes(search) + ); +} + +/** Filter cities by search query across name, country, state, description. */ +export function searchCities(cities: LocalCity[], query: string): LocalCity[] { + if (!query.trim()) return cities; + const search = query.toLowerCase().trim(); + return cities.filter( + (c) => + c.name.toLowerCase().includes(search) || + c.country.toLowerCase().includes(search) || + c.state?.toLowerCase().includes(search) || + c.description?.toLowerCase().includes(search) + ); +} + +/** Find a city by slug. */ +export function findCityBySlug(cities: LocalCity[], slug: string): LocalCity | undefined { + return cities.find((c) => c.slug === slug); +} + +/** Count locations per city. */ +export function getLocationCountByCity(locations: LocalLocation[]): Map { + const counts = new Map(); + for (const loc of locations) { + counts.set(loc.cityId, (counts.get(loc.cityId) || 0) + 1); + } + return counts; +} + +/** Stats for a single city. */ +export interface CityStats { + locationCount: number; + categoryCounts: Record; + topCategories: { category: string; count: number }[]; + contributorCount: number; + hasCoordinates: number; + recentLocations: LocalLocation[]; +} + +/** Compute stats for a city's locations. */ +export function getCityStats(locations: LocalLocation[]): CityStats { + const categoryCounts: Record = {}; + const contributors = new Set(); + let hasCoordinates = 0; + + for (const loc of locations) { + categoryCounts[loc.category] = (categoryCounts[loc.category] || 0) + 1; + if ((loc as any).createdBy) contributors.add((loc as any).createdBy); + if (loc.latitude && loc.longitude) hasCoordinates++; + } + + const topCategories = Object.entries(categoryCounts) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const recentLocations = [...locations] + .sort((a, b) => { + const aTime = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0; + const bTime = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0; + return bTime - aTime; + }) + .slice(0, 3); + + return { + locationCount: locations.length, + categoryCounts, + topCategories, + contributorCount: contributors.size, + hasCoordinates, + recentLocations, + }; +} + +/** Stats summary for the city discovery page. */ +export interface PlatformStats { + totalCities: number; + totalLocations: number; + totalContributors: number; +} + +/** Compute platform-wide stats. */ +export function getPlatformStats(cities: LocalCity[], locations: LocalLocation[]): PlatformStats { + const contributors = new Set(); + for (const loc of locations) { + if ((loc as any).createdBy) contributors.add((loc as any).createdBy); + } + for (const city of cities) { + if (city.createdBy) contributors.add(city.createdBy); + } + return { + totalCities: cities.length, + totalLocations: locations.length, + totalContributors: contributors.size, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts new file mode 100644 index 000000000..fb33a490e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/stores/favorites.svelte.ts @@ -0,0 +1,48 @@ +/** + * Favorites Store — Mutation-Only + * + * All reads are handled by liveQuery (see queries.ts). + * This store only exposes mutations that write to IndexedDB. + */ + +import { db } from '$lib/data/database'; +import type { LocalFavorite } from '../types'; + +let loading = $state(false); + +export const favoritesStore = { + get loading() { + return loading; + }, + + /** + * Toggle a favorite — writes to / removes from IndexedDB instantly. + */ + async toggle(locationId: string) { + loading = true; + + try { + const all = await db.table('ccFavorites').toArray(); + const existing = all.find((f) => f.locationId === locationId && !f.deletedAt); + + if (existing) { + await db.table('ccFavorites').update(existing.id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } else { + const newFav: LocalFavorite = { + id: crypto.randomUUID(), + locationId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await db.table('ccFavorites').add(newFav); + } + } catch (err) { + console.error('Failed to toggle favorite:', err); + } finally { + loading = false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/types.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/types.ts new file mode 100644 index 000000000..2f15a8453 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/types.ts @@ -0,0 +1,72 @@ +/** + * CityCorners module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalCity extends BaseRecord { + name: string; + slug: string; + country: string; + state?: string | null; + description?: string | null; + latitude: number; + longitude: number; + imageUrl?: string | null; + createdBy?: string | null; +} + +export interface LocalLocation extends BaseRecord { + cityId: string; + name: string; + category: + | 'sight' + | 'restaurant' + | 'shop' + | 'museum' + | 'cafe' + | 'bar' + | 'park' + | 'beach' + | 'hotel' + | 'event_venue' + | 'viewpoint'; + description?: string | null; + address?: string | null; + latitude?: number | null; + longitude?: number | null; + imageUrl?: string | null; + timeline?: Array<{ year: number; event: string }> | null; +} + +export interface LocalFavorite extends BaseRecord { + locationId: string; +} + +export const CATEGORY_KEYS = [ + 'sight', + 'restaurant', + 'shop', + 'museum', + 'cafe', + 'bar', + 'park', + 'beach', + 'hotel', + 'event_venue', + 'viewpoint', +] as const; + +export const CATEGORY_COLORS: Record = { + sight: '#2563eb', + restaurant: '#dc2626', + shop: '#16a34a', + museum: '#9333ea', + cafe: '#b45309', + bar: '#ea580c', + park: '#15803d', + beach: '#0891b2', + hotel: '#4f46e5', + event_venue: '#db2777', + viewpoint: '#0ea5e9', +}; diff --git a/apps/manacore/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts b/apps/manacore/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts new file mode 100644 index 000000000..53af03483 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/citycorners/utils/opening-hours.ts @@ -0,0 +1,52 @@ +const DAY_KEYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const; + +/** + * Check if a location is currently open based on its opening hours. + * Returns null if no opening hours are provided. + */ +export function isOpenNow(openingHours?: Record | null): boolean | null { + if (!openingHours || Object.keys(openingHours).length === 0) return null; + + const now = new Date(); + const dayKey = DAY_KEYS[now.getDay()]; + const hours = openingHours[dayKey]; + + if (!hours || hours === 'closed') return false; + + // Parse "HH:MM - HH:MM" format + const match = hours.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/); + if (!match) return null; + + const [, openH, openM, closeH, closeM] = match; + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const openMinutes = parseInt(openH) * 60 + parseInt(openM); + const closeMinutes = parseInt(closeH) * 60 + parseInt(closeM); + + // Handle overnight hours (e.g., 22:00 - 03:00) + if (closeMinutes < openMinutes) { + return currentMinutes >= openMinutes || currentMinutes < closeMinutes; + } + + return currentMinutes >= openMinutes && currentMinutes < closeMinutes; +} + +/** + * Haversine formula — distance between two lat/lng points in meters. + */ +export function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +/** + * Format distance in meters to human-readable string. + */ +export function formatDistance(meters: number): string { + if (meters < 1000) return `${meters} m`; + return `${(meters / 1000).toFixed(1)} km`; +} diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/components/StatusBadge.svelte b/apps/manacore/apps/web/src/lib/modules/inventar/components/StatusBadge.svelte new file mode 100644 index 000000000..8354aa81a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/components/StatusBadge.svelte @@ -0,0 +1,34 @@ + + + + {statusLabels[status]} + diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/components/ViewModeToggle.svelte b/apps/manacore/apps/web/src/lib/modules/inventar/components/ViewModeToggle.svelte new file mode 100644 index 000000000..02375ca3d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/components/ViewModeToggle.svelte @@ -0,0 +1,46 @@ + + +
+ {#each modes as mode} + + {/each} +
diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldEditor.svelte b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldEditor.svelte new file mode 100644 index 000000000..a291447c2 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldEditor.svelte @@ -0,0 +1,134 @@ + + +{#if field.type === 'text'} + +{:else if field.type === 'number'} + +{:else if field.type === 'currency'} +
+ + + {field.currencyCode || 'EUR'} + +
+{:else if field.type === 'date'} + +{:else if field.type === 'checkbox'} + +{:else if field.type === 'select'} + +{:else if field.type === 'url'} + +{:else if field.type === 'tags'} + {@const currentTags = Array.isArray(value) ? (value as string[]) : []} +
+
+ {#each currentTags as tag, i} + + {tag} + + + {/each} +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + const target = e.target as HTMLInputElement; + const newTag = target.value.trim(); + if (newTag && !currentTags.includes(newTag)) { + onchange([...currentTags, newTag]); + target.value = ''; + } + } + }} + /> +
+{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldRenderer.svelte b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldRenderer.svelte new file mode 100644 index 000000000..27d2983b1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/FieldRenderer.svelte @@ -0,0 +1,76 @@ + + +{#if value === undefined || value === null || value === ''} + -- +{:else if field.type === 'checkbox'} + {#if value} + + {:else} + + {/if} +{:else if field.type === 'currency'} + {formatCurrency(value, field.currencyCode)} +{:else if field.type === 'date'} + {formatDate(value)} +{:else if field.type === 'url'} + + {String(value) + .replace(/^https?:\/\//, '') + .slice(0, 40)} + +{:else if field.type === 'select'} + + {String(value)} + +{:else if field.type === 'tags'} +
+ {#each Array.isArray(value) ? value : [] as tag} + {tag} + {/each} +
+{:else if field.type === 'number'} + {Number(value).toLocaleString('de-DE')} +{:else} + {String(value)} +{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/SchemaEditor.svelte b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/SchemaEditor.svelte new file mode 100644 index 000000000..94218c3bd --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/components/fields/SchemaEditor.svelte @@ -0,0 +1,226 @@ + + +
+ {#each fields.sort((a, b) => a.order - b.order) as field, index (field.id)} +
+
+ +
+ + +
+ + +
+
+ updateField(field.id, { name: (e.target as HTMLInputElement).value })} + /> + +
+ +
+ + + + updateField(field.id, { + placeholder: (e.target as HTMLInputElement).value || undefined, + })} + /> +
+ + + {#if field.type === 'currency'} + + updateField(field.id, { currencyCode: (e.target as HTMLInputElement).value })} + /> + {/if} + + + {#if field.type === 'select'} +
+
+ {#each field.options || [] as option, i} + + {option} + + + {/each} +
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + addOption(field.id); + } + }} + /> + +
+
+ {/if} +
+ + + +
+
+ {/each} + + +
diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/constants.ts b/apps/manacore/apps/web/src/lib/modules/inventar/constants.ts new file mode 100644 index 000000000..9cef7d35e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/constants.ts @@ -0,0 +1,245 @@ +/** + * Inventar constants — templates and status definitions. + * + * Inlined from @inventar/shared since the unified app does not depend on it. + */ + +import type { ItemStatus } from './queries'; + +export type FieldType = + | 'text' + | 'number' + | 'date' + | 'select' + | 'tags' + | 'checkbox' + | 'url' + | 'currency'; + +export interface FieldDefinition { + id: string; + name: string; + type: FieldType; + required?: boolean; + defaultValue?: unknown; + options?: string[]; + currencyCode?: string; + placeholder?: string; + order: number; +} + +export interface CollectionSchema { + fields: FieldDefinition[]; +} + +export interface Template { + id: string; + name: string; + description: string; + icon: string; + schema: CollectionSchema; + category: string; +} + +export const ITEM_STATUSES: { + value: ItemStatus; + label: string; + color: string; +}[] = [ + { value: 'owned', label: 'Besitzt', color: '#22c55e' }, + { value: 'lent', label: 'Verliehen', color: '#f59e0b' }, + { value: 'stored', label: 'Eingelagert', color: '#3b82f6' }, + { value: 'for_sale', label: 'Zu verkaufen', color: '#a855f7' }, + { value: 'disposed', label: 'Entsorgt', color: '#6b7280' }, +]; + +export const DEFAULT_TEMPLATES: Template[] = [ + { + id: 'electronics', + name: 'Elektronik', + description: 'Computer, Smartphones, Gadgets', + icon: '💻', + category: 'tech', + schema: { + fields: [ + { id: 'brand', name: 'Marke', type: 'text', order: 0 }, + { id: 'model', name: 'Modell', type: 'text', order: 1 }, + { id: 'serial_number', name: 'Seriennummer', type: 'text', order: 2 }, + { id: 'purchase_date', name: 'Kaufdatum', type: 'date', order: 3 }, + { id: 'warranty_until', name: 'Garantie bis', type: 'date', order: 4 }, + { id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 }, + { + id: 'condition', + name: 'Zustand', + type: 'select', + options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Defekt'], + order: 6, + }, + ], + }, + }, + { + id: 'books', + name: 'Bucher', + description: 'Bucher, E-Books, Horbucher', + icon: '📚', + category: 'media', + schema: { + fields: [ + { id: 'author', name: 'Autor', type: 'text', order: 0 }, + { id: 'isbn', name: 'ISBN', type: 'text', order: 1 }, + { id: 'publisher', name: 'Verlag', type: 'text', order: 2 }, + { id: 'genre', name: 'Genre', type: 'text', order: 3 }, + { id: 'pages', name: 'Seiten', type: 'number', order: 4 }, + { id: 'read', name: 'Gelesen', type: 'checkbox', order: 5 }, + { + id: 'rating', + name: 'Bewertung', + type: 'select', + options: ['1', '2', '3', '4', '5'], + order: 6, + }, + ], + }, + }, + { + id: 'furniture', + name: 'Mobel', + description: 'Tische, Stuhle, Regale', + icon: '🪑', + category: 'home', + schema: { + fields: [ + { id: 'material', name: 'Material', type: 'text', order: 0 }, + { + id: 'dimensions', + name: 'Masse', + type: 'text', + placeholder: 'B x H x T in cm', + order: 1, + }, + { id: 'color', name: 'Farbe', type: 'text', order: 2 }, + { id: 'room', name: 'Raum', type: 'text', order: 3 }, + { + id: 'condition', + name: 'Zustand', + type: 'select', + options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Reparaturbedurftig'], + order: 4, + }, + { id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 }, + ], + }, + }, + { + id: 'clothing', + name: 'Kleidung', + description: 'Kleidung, Schuhe, Accessoires', + icon: '👕', + category: 'fashion', + schema: { + fields: [ + { id: 'brand', name: 'Marke', type: 'text', order: 0 }, + { id: 'size', name: 'Grosse', type: 'text', order: 1 }, + { id: 'color', name: 'Farbe', type: 'text', order: 2 }, + { id: 'material', name: 'Material', type: 'text', order: 3 }, + { + id: 'season', + name: 'Saison', + type: 'select', + options: ['Fruhling', 'Sommer', 'Herbst', 'Winter', 'Ganzjahrig'], + order: 4, + }, + { id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 }, + ], + }, + }, + { + id: 'tools', + name: 'Werkzeug', + description: 'Handwerkzeug, Elektrowerkzeug', + icon: '🔧', + category: 'home', + schema: { + fields: [ + { id: 'brand', name: 'Marke', type: 'text', order: 0 }, + { id: 'model', name: 'Modell', type: 'text', order: 1 }, + { + id: 'type', + name: 'Typ', + type: 'select', + options: ['Handwerkzeug', 'Elektrowerkzeug', 'Messwerkzeug', 'Sonstiges'], + order: 2, + }, + { + id: 'condition', + name: 'Zustand', + type: 'select', + options: ['Neu', 'Gut', 'Gebraucht', 'Defekt'], + order: 3, + }, + { id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 }, + ], + }, + }, + { + id: 'kitchen', + name: 'Kuche', + description: 'Kuchengerate, Geschirr, Besteck', + icon: '🍳', + category: 'home', + schema: { + fields: [ + { id: 'brand', name: 'Marke', type: 'text', order: 0 }, + { id: 'material', name: 'Material', type: 'text', order: 1 }, + { + id: 'category', + name: 'Kategorie', + type: 'select', + options: ['Gerat', 'Geschirr', 'Besteck', 'Topf/Pfanne', 'Sonstiges'], + order: 2, + }, + { id: 'dishwasher_safe', name: 'Spulmaschinenfest', type: 'checkbox', order: 3 }, + { id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 }, + ], + }, + }, + { + id: 'media', + name: 'Medien', + description: 'Filme, Musik, Spiele', + icon: '🎬', + category: 'media', + schema: { + fields: [ + { + id: 'format', + name: 'Format', + type: 'select', + options: ['DVD', 'Blu-ray', 'CD', 'Vinyl', 'Digital', 'Kassette'], + order: 0, + }, + { id: 'artist', name: 'Kunstler/Regisseur', type: 'text', order: 1 }, + { id: 'genre', name: 'Genre', type: 'text', order: 2 }, + { id: 'year', name: 'Erscheinungsjahr', type: 'number', order: 3 }, + { + id: 'rating', + name: 'Bewertung', + type: 'select', + options: ['1', '2', '3', '4', '5'], + order: 4, + }, + ], + }, + }, + { + id: 'custom', + name: 'Benutzerdefiniert', + description: 'Leere Sammlung, eigene Felder definieren', + icon: '✨', + category: 'other', + schema: { + fields: [], + }, + }, +]; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/index.ts b/apps/manacore/apps/web/src/lib/modules/inventar/index.ts new file mode 100644 index 000000000..2f12dfec8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/index.ts @@ -0,0 +1,54 @@ +/** + * Inventar module — barrel exports. + */ + +export { collectionsStore } from './stores/collections.svelte'; +export { itemsStore } from './stores/items.svelte'; +export { locationsStore } from './stores/locations.svelte'; +export { categoriesStore } from './stores/categories.svelte'; +export { viewStore } from './stores/view.svelte'; +export { + useAllCollections, + useAllItems, + useAllLocations, + useAllCategories, + toCollection, + toItem, + toLocation, + toCategory, + getCollectionById, + getSortedCollections, + getItemById, + getItemsByCollection, + getItemCountByCollection, + getTotalItemCount, + getFilteredItems, + getSortedItems, + getLocationById, + getRootLocations, + getLocationChildren, + getLocationTree, + getLocationFullPath, + getCategoryById, + getRootCategories, + getCategoryChildren, + getCategoryTree, +} from './queries'; +export type { + Collection, + Item, + ItemStatus, + Location, + Category, + ViewMode, + SortOption, + FilterCriteria, +} from './queries'; +export { + invCollectionTable, + invItemTable, + invLocationTable, + invCategoryTable, + INVENTAR_GUEST_SEED, +} from './collections'; +export type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/stores/categories.svelte.ts b/apps/manacore/apps/web/src/lib/modules/inventar/stores/categories.svelte.ts new file mode 100644 index 000000000..469cb9ac5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/stores/categories.svelte.ts @@ -0,0 +1,51 @@ +/** + * Categories Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store only handles writes to IndexedDB via the unified database. + */ + +import { invCategoryTable } from '../collections'; +import { toCategory } from '../queries'; +import type { LocalCategory } from '../types'; + +export const categoriesStore = { + async create(data: { name: string; icon?: string; color?: string; parentId?: string }) { + const all = await invCategoryTable.toArray(); + const active = all.filter((c) => !c.deletedAt); + const siblings = active.filter((c) => c.parentId === data.parentId); + + const newLocal: LocalCategory = { + id: crypto.randomUUID(), + parentId: data.parentId ?? null, + name: data.name, + icon: data.icon ?? null, + color: data.color ?? null, + order: siblings.length, + }; + await invCategoryTable.add(newLocal); + return toCategory(newLocal); + }, + + async update(id: string, data: Partial>) { + await invCategoryTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string) { + const all = await invCategoryTable.toArray(); + const active = all.filter((c) => !c.deletedAt); + const idsToDelete = new Set(); + const collectIds = (parentId: string) => { + idsToDelete.add(parentId); + active.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id)); + }; + collectIds(id); + const now = new Date().toISOString(); + for (const deleteId of idsToDelete) { + await invCategoryTable.update(deleteId, { deletedAt: now, updatedAt: now }); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/stores/collections.svelte.ts b/apps/manacore/apps/web/src/lib/modules/inventar/stores/collections.svelte.ts new file mode 100644 index 000000000..09b652f42 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/stores/collections.svelte.ts @@ -0,0 +1,68 @@ +/** + * Collections Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store only handles writes to IndexedDB via the unified database. + */ + +import { invCollectionTable } from '../collections'; +import { toCollection } from '../queries'; +import type { LocalCollection } from '../types'; + +export const collectionsStore = { + async create(data: { + name: string; + description?: string; + icon?: string; + color?: string; + schema: LocalCollection['schema']; + templateId?: string; + }) { + const all = await invCollectionTable.toArray(); + const active = all.filter((c) => !c.deletedAt); + const newLocal: LocalCollection = { + id: crypto.randomUUID(), + name: data.name, + description: data.description ?? null, + icon: data.icon ?? null, + color: data.color ?? null, + schema: data.schema, + templateId: data.templateId ?? null, + order: active.length, + itemCount: 0, + }; + await invCollectionTable.add(newLocal); + return toCollection(newLocal); + }, + + async update( + id: string, + data: Partial> + ) { + await invCollectionTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string) { + await invCollectionTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async reorder(orderedIds: string[]) { + const now = new Date().toISOString(); + for (let i = 0; i < orderedIds.length; i++) { + await invCollectionTable.update(orderedIds[i], { order: i, updatedAt: now }); + } + }, + + async updateItemCount(collectionId: string, count: number) { + await invCollectionTable.update(collectionId, { + itemCount: count, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/stores/items.svelte.ts b/apps/manacore/apps/web/src/lib/modules/inventar/stores/items.svelte.ts new file mode 100644 index 000000000..facda3e0b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/stores/items.svelte.ts @@ -0,0 +1,109 @@ +/** + * Items Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store only handles writes to IndexedDB via the unified database. + */ + +import { invItemTable } from '../collections'; +import { toItem } from '../queries'; +import type { LocalItem } from '../types'; +import type { ItemStatus } from '../queries'; + +export const itemsStore = { + async create(data: { + collectionId: string; + name: string; + description?: string; + status?: ItemStatus; + quantity?: number; + locationId?: string; + categoryId?: string; + fieldValues?: Record; + purchaseData?: LocalItem['purchaseData']; + tags?: string[]; + }) { + const existing = await invItemTable.toArray(); + const collectionItems = existing.filter( + (i) => !i.deletedAt && i.collectionId === data.collectionId + ); + + const newLocal: LocalItem = { + id: crypto.randomUUID(), + collectionId: data.collectionId, + name: data.name, + description: data.description ?? null, + status: data.status || 'owned', + quantity: data.quantity || 1, + locationId: data.locationId ?? null, + categoryId: data.categoryId ?? null, + fieldValues: data.fieldValues || {}, + purchaseData: data.purchaseData ?? null, + photos: [], + notes: [], + tags: data.tags || [], + order: collectionItems.length, + }; + await invItemTable.add(newLocal); + return toItem(newLocal); + }, + + async update( + id: string, + data: Partial< + Pick< + LocalItem, + | 'name' + | 'description' + | 'status' + | 'quantity' + | 'locationId' + | 'categoryId' + | 'fieldValues' + | 'purchaseData' + | 'tags' + > + > + ) { + await invItemTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string) { + await invItemTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async deleteByCollection(collectionId: string) { + const all = await invItemTable.toArray(); + const toDelete = all.filter((i) => !i.deletedAt && i.collectionId === collectionId); + const now = new Date().toISOString(); + for (const item of toDelete) { + await invItemTable.update(item.id, { deletedAt: now, updatedAt: now }); + } + }, + + async addNote(itemId: string, content: string) { + const item = await invItemTable.get(itemId); + if (!item) return; + const now = new Date().toISOString(); + const note = { id: crypto.randomUUID(), content, createdAt: now }; + await invItemTable.update(itemId, { + notes: [...item.notes, note], + updatedAt: now, + }); + }, + + async deleteNote(itemId: string, noteId: string) { + const item = await invItemTable.get(itemId); + if (!item) return; + await invItemTable.update(itemId, { + notes: item.notes.filter((n) => n.id !== noteId), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/stores/locations.svelte.ts b/apps/manacore/apps/web/src/lib/modules/inventar/stores/locations.svelte.ts new file mode 100644 index 000000000..c359bab42 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/stores/locations.svelte.ts @@ -0,0 +1,68 @@ +/** + * Locations Store — Mutations Only + * + * Reads come from liveQuery hooks in queries.ts. + * This store only handles writes to IndexedDB via the unified database. + */ + +import { invLocationTable } from '../collections'; +import { toLocation } from '../queries'; +import type { LocalLocation } from '../types'; + +function buildPath(locations: LocalLocation[], parentId?: string): string { + if (!parentId) return ''; + const parent = locations.find((l) => l.id === parentId); + if (!parent) return ''; + return parent.path ? `${parent.path}/${parent.name}` : parent.name; +} + +function getDepth(locations: LocalLocation[], parentId?: string): number { + if (!parentId) return 0; + const parent = locations.find((l) => l.id === parentId); + return parent ? parent.depth + 1 : 0; +} + +export const locationsStore = { + async create(data: { name: string; description?: string; icon?: string; parentId?: string }) { + const all = await invLocationTable.toArray(); + const active = all.filter((l) => !l.deletedAt); + const path = buildPath(active, data.parentId); + const depth = getDepth(active, data.parentId); + const siblings = active.filter((l) => l.parentId === data.parentId); + + const newLocal: LocalLocation = { + id: crypto.randomUUID(), + parentId: data.parentId ?? null, + name: data.name, + description: data.description ?? null, + icon: data.icon ?? null, + path, + depth, + order: siblings.length, + }; + await invLocationTable.add(newLocal); + return toLocation(newLocal); + }, + + async update(id: string, data: Partial>) { + await invLocationTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string) { + const all = await invLocationTable.toArray(); + const active = all.filter((l) => !l.deletedAt); + const idsToDelete = new Set(); + const collectIds = (parentId: string) => { + idsToDelete.add(parentId); + active.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id)); + }; + collectIds(id); + const now = new Date().toISOString(); + for (const deleteId of idsToDelete) { + await invLocationTable.update(deleteId, { deletedAt: now, updatedAt: now }); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/stores/view.svelte.ts b/apps/manacore/apps/web/src/lib/modules/inventar/stores/view.svelte.ts new file mode 100644 index 000000000..d70a27587 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/stores/view.svelte.ts @@ -0,0 +1,117 @@ +/** + * View Store — Client-side view preferences persisted to localStorage. + */ + +import { browser } from '$app/environment'; +import type { ViewMode, SortOption, FilterCriteria } from '../queries'; + +const VIEW_KEY = 'inventar_view_mode'; +const SORT_KEY = 'inventar_sort'; + +interface SavedFilter { + id: string; + name: string; + criteria: FilterCriteria; + createdAt: string; +} + +const FILTERS_KEY = 'inventar_saved_filters'; + +function load(key: string, fallback: T): T { + if (!browser) return fallback; + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : fallback; + } catch { + return fallback; + } +} + +function save(key: string, value: unknown) { + if (!browser) return; + localStorage.setItem(key, JSON.stringify(value)); +} + +let viewMode = $state('list'); +let sort = $state({ field: 'name', direction: 'asc' }); +let activeFilters = $state({}); +let savedFilters = $state([]); +let initialized = $state(false); + +export const viewStore = { + get viewMode() { + return viewMode; + }, + get sort() { + return sort; + }, + get activeFilters() { + return activeFilters; + }, + get savedFilters() { + return savedFilters; + }, + get hasActiveFilters() { + return !!( + activeFilters.search || + activeFilters.status?.length || + activeFilters.locationId || + activeFilters.categoryId || + activeFilters.tagIds?.length || + activeFilters.collectionId + ); + }, + + initialize() { + if (initialized) return; + viewMode = load(VIEW_KEY, 'list'); + sort = load(SORT_KEY, { field: 'name', direction: 'asc' }); + savedFilters = load(FILTERS_KEY, []); + initialized = true; + }, + + setViewMode(mode: ViewMode) { + viewMode = mode; + save(VIEW_KEY, mode); + }, + + setSort(newSort: SortOption) { + sort = newSort; + save(SORT_KEY, newSort); + }, + + setFilters(filters: FilterCriteria) { + activeFilters = filters; + }, + + updateFilter(key: K, value: FilterCriteria[K]) { + activeFilters = { ...activeFilters, [key]: value }; + }, + + clearFilters() { + activeFilters = {}; + }, + + saveFilter(name: string) { + const filter: SavedFilter = { + id: crypto.randomUUID(), + name, + criteria: { ...activeFilters }, + createdAt: new Date().toISOString(), + }; + savedFilters = [...savedFilters, filter]; + save(FILTERS_KEY, savedFilters); + }, + + loadFilter(id: string) { + const filter = savedFilters.find((f) => f.id === id); + if (filter) { + activeFilters = { ...filter.criteria }; + } + }, + + deleteSavedFilter(id: string) { + savedFilters = savedFilters.filter((f) => f.id !== id); + save(FILTERS_KEY, savedFilters); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/collections.ts b/apps/manacore/apps/web/src/lib/modules/photos/collections.ts new file mode 100644 index 000000000..6bdc605b4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/collections.ts @@ -0,0 +1,46 @@ +/** + * Photos module — collection accessors and guest seed data. + * + * Uses table names in the unified DB: albums, albumItems, photoFavorites, photoTags, photoMediaTags. + */ + +import { db } from '$lib/data/database'; +import type { LocalAlbum, LocalAlbumItem, LocalFavorite, LocalTag, LocalPhotoTag } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const albumTable = db.table('albums'); +export const albumItemTable = db.table('albumItems'); +export const photoFavoriteTable = db.table('photoFavorites'); +export const photoTagTable = db.table('photoTags'); +export const photoMediaTagTable = db.table('photoMediaTags'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const PHOTOS_GUEST_SEED = { + albums: [ + { + id: 'album-favorites', + name: 'Favoriten', + description: 'Deine Lieblingsfotos an einem Ort.', + isAutoGenerated: false, + }, + ], + photoTags: [ + { + id: 'tag-nature', + name: 'Natur', + color: '#22c55e', + }, + { + id: 'tag-people', + name: 'Menschen', + color: '#3b82f6', + }, + { + id: 'tag-travel', + name: 'Reisen', + color: '#f59e0b', + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumCard.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumCard.svelte new file mode 100644 index 000000000..f66c88b21 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumCard.svelte @@ -0,0 +1,100 @@ + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumGrid.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumGrid.svelte new file mode 100644 index 000000000..100224744 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/AlbumGrid.svelte @@ -0,0 +1,53 @@ + + +
+ {#each albums as album (album.id)} + onAlbumClick(album)} /> + {/each} +
+ +{#if loading} +
+
+
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/albums/CreateAlbumModal.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/CreateAlbumModal.svelte new file mode 100644 index 000000000..8e13e7cfc --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/albums/CreateAlbumModal.svelte @@ -0,0 +1,100 @@ + + + + + +
+
+
+

Album erstellen

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/filters/FilterBar.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/filters/FilterBar.svelte new file mode 100644 index 000000000..1011945b3 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/filters/FilterBar.svelte @@ -0,0 +1,120 @@ + + +
+
+ App +
+ {#each apps as app} + + {/each} +
+
+ +
+ Zeitraum +
+ + - + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoCard.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoCard.svelte new file mode 100644 index 000000000..79a768b1a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoCard.svelte @@ -0,0 +1,157 @@ + + + +
(e.key === 'Enter' || e.key === ' ') && onClick()} + role="button" + tabindex="0" +> + {#if !loaded && !error} +
+ {/if} + + (loaded = true)} + onerror={() => (error = true)} + /> + + {#if error} +
+ +
+ {/if} + +
+ +
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte new file mode 100644 index 000000000..d28cf942e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte @@ -0,0 +1,296 @@ + + + + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoGrid.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoGrid.svelte new file mode 100644 index 000000000..7d5d3d263 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/gallery/PhotoGrid.svelte @@ -0,0 +1,105 @@ + + +
+ {#each photos as photo (photo.id)} + onPhotoClick(photo)} /> + {/each} +
+ +{#if loading} +
+
+
+{/if} + +{#if hasMore} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/modules/photos/components/upload/UploadDropzone.svelte b/apps/manacore/apps/web/src/lib/modules/photos/components/upload/UploadDropzone.svelte new file mode 100644 index 000000000..0166ea1da --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/components/upload/UploadDropzone.svelte @@ -0,0 +1,83 @@ + + + +
e.key === 'Enter' && openFilePicker()} +> + + +
+ +
+

Fotos hierher ziehen oder klicken zum Auswählen

+ +
diff --git a/apps/manacore/apps/web/src/lib/modules/photos/index.ts b/apps/manacore/apps/web/src/lib/modules/photos/index.ts new file mode 100644 index 000000000..c236912ab --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/index.ts @@ -0,0 +1,46 @@ +/** + * Photos module — barrel exports. + */ + +export { photoStore } from './stores/photos.svelte'; +export { albumMutations } from './stores/albums.svelte'; +export { + useAllPhotoTags, + getTagById, + getTagsByIds, + tagMutations, + photoTagOps, +} from './stores/tags.svelte'; +export { + useAllAlbums, + useAllAlbumItems, + useAllFavorites, + toAlbum, + toAlbumItem, + getAlbumById, + getAlbumItemsForAlbum, + getAlbumItemCount, + enrichAlbumsWithCounts, + isFavorited, + getFavoritedMediaIds, +} from './queries'; +export { + albumTable, + albumItemTable, + photoFavoriteTable, + photoTagTable, + photoMediaTagTable, + PHOTOS_GUEST_SEED, +} from './collections'; +export type { + LocalAlbum, + LocalAlbumItem, + LocalFavorite, + LocalTag, + LocalPhotoTag, + Photo, + PhotoFilters, + PhotoStats, + Album, + AlbumItem, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/queries.ts b/apps/manacore/apps/web/src/lib/modules/photos/queries.ts new file mode 100644 index 000000000..100c00746 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/queries.ts @@ -0,0 +1,101 @@ +/** + * Reactive Queries & Pure Helpers for Photos + * + * Uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalAlbum, LocalAlbumItem, LocalFavorite, Album, AlbumItem } from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +/** Convert a LocalAlbum (IndexedDB) to the Album type. */ +export function toAlbum(local: LocalAlbum): Album { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + coverMediaId: local.coverMediaId ?? undefined, + isAutoGenerated: local.isAutoGenerated, + autoGenerateType: local.autoGenerateType ?? undefined, + autoGenerateValue: local.autoGenerateValue ?? undefined, + itemCount: 0, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert a LocalAlbumItem (IndexedDB) to the AlbumItem type. */ +export function toAlbumItem(local: LocalAlbumItem): AlbumItem { + return { + id: local.id, + albumId: local.albumId, + mediaId: local.mediaId, + sortOrder: local.sortOrder, + addedAt: local.createdAt ?? new Date().toISOString(), + }; +} + +// ─── Live Query Hooks (call during component init) ───────── + +/** All albums. Auto-updates on any change. */ +export function useAllAlbums() { + return liveQuery(async () => { + const locals = await db.table('albums').toArray(); + return locals.filter((a) => !a.deletedAt).map(toAlbum); + }); +} + +/** All album items. Auto-updates on any change. */ +export function useAllAlbumItems() { + return liveQuery(async () => { + const locals = await db.table('albumItems').toArray(); + return locals.filter((i) => !i.deletedAt).map(toAlbumItem); + }); +} + +/** All favorites. Auto-updates on any change. */ +export function useAllFavorites() { + return liveQuery(async () => { + const locals = await db.table('photoFavorites').toArray(); + return locals.filter((f) => !f.deletedAt); + }); +} + +// ─── Pure Album Helpers ──────────────────────────────────── + +/** Get an album by ID. */ +export function getAlbumById(albums: Album[], id: string): Album | undefined { + return albums.find((a) => a.id === id); +} + +/** Get album items for a specific album, sorted by sortOrder. */ +export function getAlbumItemsForAlbum(allItems: AlbumItem[], albumId: string): AlbumItem[] { + return allItems.filter((i) => i.albumId === albumId).sort((a, b) => a.sortOrder - b.sortOrder); +} + +/** Get the count of items in an album. */ +export function getAlbumItemCount(allItems: AlbumItem[], albumId: string): number { + return allItems.filter((i) => i.albumId === albumId).length; +} + +/** Enrich albums with item counts from album items. */ +export function enrichAlbumsWithCounts(albums: Album[], allItems: AlbumItem[]): Album[] { + return albums.map((album) => ({ + ...album, + itemCount: getAlbumItemCount(allItems, album.id), + })); +} + +// ─── Pure Favorite Helpers ───────────────────────────────── + +/** Check if a media ID is favorited. */ +export function isFavorited(favorites: LocalFavorite[], mediaId: string): boolean { + return favorites.some((f) => f.mediaId === mediaId); +} + +/** Get the set of favorited media IDs. */ +export function getFavoritedMediaIds(favorites: LocalFavorite[]): Set { + return new Set(favorites.map((f) => f.mediaId)); +} diff --git a/apps/manacore/apps/web/src/lib/modules/photos/stores/albums.svelte.ts b/apps/manacore/apps/web/src/lib/modules/photos/stores/albums.svelte.ts new file mode 100644 index 000000000..744cb56f2 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/stores/albums.svelte.ts @@ -0,0 +1,124 @@ +/** + * Albums Store — Mutation-Only + * + * Reads are handled by live queries in queries.ts. + * This store handles mutations (create, update, delete, add/remove items). + */ + +import { db } from '$lib/data/database'; +import type { LocalAlbum, LocalAlbumItem, Album } from '../types'; +import { toAlbum } from '../queries'; + +export const albumMutations = { + async createAlbum(data: { name: string; description?: string }): Promise { + try { + const now = new Date().toISOString(); + const newLocal: LocalAlbum = { + id: crypto.randomUUID(), + name: data.name, + description: data.description ?? null, + coverMediaId: null, + isAutoGenerated: false, + autoGenerateType: null, + autoGenerateValue: null, + createdAt: now, + updatedAt: now, + }; + await db.table('albums').add(newLocal); + return toAlbum(newLocal); + } catch (e) { + console.error('Failed to create album:', e); + return null; + } + }, + + async updateAlbum( + id: string, + data: { name?: string; description?: string } + ): Promise { + try { + const updateData: Record = { updatedAt: new Date().toISOString() }; + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description ?? null; + + await db.table('albums').update(id, updateData); + const updated = await db.table('albums').get(id); + return updated ? toAlbum(updated) : null; + } catch (e) { + console.error('Failed to update album:', e); + return null; + } + }, + + async deleteAlbum(id: string): Promise { + try { + const now = new Date().toISOString(); + // Soft-delete album items first + const items = await db.table('albumItems').toArray(); + for (const item of items.filter((i) => i.albumId === id)) { + await db.table('albumItems').update(item.id, { deletedAt: now, updatedAt: now }); + } + await db.table('albums').update(id, { deletedAt: now, updatedAt: now }); + return true; + } catch (e) { + console.error('Failed to delete album:', e); + return false; + } + }, + + async addPhotosToAlbum(albumId: string, mediaIds: string[]): Promise { + try { + const existing = await db.table('albumItems').toArray(); + const existingInAlbum = existing.filter((i) => i.albumId === albumId && !i.deletedAt); + let nextOrder = existingInAlbum.length; + const now = new Date().toISOString(); + + for (const mediaId of mediaIds) { + if (existingInAlbum.some((i) => i.mediaId === mediaId)) continue; + + await db.table('albumItems').add({ + id: crypto.randomUUID(), + albumId, + mediaId, + sortOrder: nextOrder++, + createdAt: now, + updatedAt: now, + }); + } + return true; + } catch (e) { + console.error('Failed to add photos to album:', e); + return false; + } + }, + + async removePhotoFromAlbum(albumId: string, mediaId: string): Promise { + try { + const items = await db.table('albumItems').toArray(); + const item = items.find( + (i) => i.albumId === albumId && i.mediaId === mediaId && !i.deletedAt + ); + if (item) { + const now = new Date().toISOString(); + await db.table('albumItems').update(item.id, { deletedAt: now, updatedAt: now }); + } + return true; + } catch (e) { + console.error('Failed to remove photo from album:', e); + return false; + } + }, + + async setCover(albumId: string, mediaId: string): Promise { + try { + await db.table('albums').update(albumId, { + coverMediaId: mediaId, + updatedAt: new Date().toISOString(), + }); + return true; + } catch (e) { + console.error('Failed to set album cover:', e); + return false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/stores/photos.svelte.ts b/apps/manacore/apps/web/src/lib/modules/photos/stores/photos.svelte.ts new file mode 100644 index 000000000..e4342866b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/stores/photos.svelte.ts @@ -0,0 +1,198 @@ +/** + * Photos Store — Server-fetched photos from mana-media + local-first mutations. + * + * Photo files live on mana-media (server-side, not in Dexie). + * Favorites are local-first via Dexie. + */ + +import { db } from '$lib/data/database'; +import type { LocalFavorite, Photo, PhotoFilters, PhotoStats } from '../types'; + +const MEDIA_URL = () => + (typeof window !== 'undefined' + ? (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }).__PUBLIC_MANA_MEDIA_URL__ + : null) || + import.meta.env.PUBLIC_MANA_MEDIA_URL || + 'http://localhost:3015'; + +async function mediaFetch( + path: string, + token: string | null, + options: RequestInit = {} +): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${MEDIA_URL()}/api/v1${path}`, { ...options, headers }); + if (!response.ok) return null; + return response.json(); +} + +// State — server-fetched photos (not local-first) +let photos = $state([]); +let loading = $state(false); +let error = $state(null); +let hasMore = $state(true); +let filters = $state({ + limit: 50, + offset: 0, + sortBy: 'dateTaken', + sortOrder: 'desc', +}); +let stats = $state(null); +let selectedPhoto = $state(null); + +export const photoStore = { + get photos() { + return photos; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get hasMore() { + return hasMore; + }, + get filters() { + return filters; + }, + get stats() { + return stats; + }, + get selectedPhoto() { + return selectedPhoto; + }, + + async loadPhotos(reset = false, token: string | null = null) { + if (loading) return; + + if (reset) { + photos = []; + filters = { ...filters, offset: 0 }; + hasMore = true; + } + + loading = true; + error = null; + + try { + const params = new URLSearchParams(); + if (filters.apps?.length) params.set('apps', filters.apps.join(',')); + if (filters.mimeType) params.set('mimeType', filters.mimeType); + if (filters.dateFrom) params.set('dateFrom', filters.dateFrom); + if (filters.dateTo) params.set('dateTo', filters.dateTo); + if (filters.hasLocation !== undefined) params.set('hasLocation', String(filters.hasLocation)); + params.set('limit', String(filters.limit || 50)); + params.set('offset', String(filters.offset || 0)); + params.set('sortBy', filters.sortBy || 'dateTaken'); + params.set('sortOrder', filters.sortOrder || 'desc'); + + const result = await mediaFetch<{ items: Photo[]; total: number; hasMore: boolean }>( + `/media/list/all?${params.toString()}`, + token + ); + + if (result) { + photos = reset ? result.items : [...photos, ...result.items]; + hasMore = result.hasMore; + filters = { ...filters, offset: (filters.offset || 0) + result.items.length }; + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load photos'; + } finally { + loading = false; + } + }, + + async loadMore(token: string | null = null) { + if (!hasMore || loading) return; + await this.loadPhotos(false, token); + }, + + async setFilters(newFilters: Partial, token: string | null = null) { + filters = { ...filters, ...newFilters, offset: 0 }; + await this.loadPhotos(true, token); + }, + + async loadStats(token: string | null = null) { + try { + const result = await mediaFetch('/media/stats', token); + if (result) stats = result; + } catch (e) { + console.error('Failed to load stats:', e); + } + }, + + selectPhoto(photo: Photo | null) { + selectedPhoto = photo; + }, + + /** Toggle favorite — local-first via Dexie. */ + async toggleFavorite(mediaId: string) { + try { + const existing = await db.table('photoFavorites').toArray(); + const fav = existing.find((f) => f.mediaId === mediaId && !f.deletedAt); + + if (fav) { + await db.table('photoFavorites').update(fav.id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } else { + await db.table('photoFavorites').add({ + id: crypto.randomUUID(), + mediaId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + // Update server-fetched photos in-memory for immediate UI feedback + const isFav = !fav; + photos = photos.map((p) => (p.id === mediaId ? { ...p, isFavorited: isFav } : p)); + if (selectedPhoto?.id === mediaId) { + selectedPhoto = { ...selectedPhoto, isFavorited: isFav }; + } + } catch (e) { + console.error('Failed to toggle favorite:', e); + } + }, + + async deletePhoto(mediaId: string, token: string | null = null) { + try { + const response = await fetch(`${MEDIA_URL()}/api/v1/media/${mediaId}`, { + method: 'DELETE', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!response.ok) { + error = 'Failed to delete photo'; + return false; + } + + photos = photos.filter((p) => p.id !== mediaId); + if (selectedPhoto?.id === mediaId) selectedPhoto = null; + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete photo'; + return false; + } + }, + + reset() { + photos = []; + loading = false; + error = null; + hasMore = true; + filters = { limit: 50, offset: 0, sortBy: 'dateTaken', sortOrder: 'desc' }; + stats = null; + selectedPhoto = null; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/stores/tags.svelte.ts b/apps/manacore/apps/web/src/lib/modules/photos/stores/tags.svelte.ts new file mode 100644 index 000000000..5655a6879 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/stores/tags.svelte.ts @@ -0,0 +1,146 @@ +/** + * Photo Tag Store — Local-First via Dexie + * + * Tag CRUD and photo-tag junction table operations. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalTag, LocalPhotoTag } from '../types'; + +// ─── Tag CRUD ───────────────────────────────────────────── + +export function useAllPhotoTags() { + return liveQuery(async () => { + const all = await db.table('photoTags').toArray(); + return all.filter((t) => !t.deletedAt); + }); +} + +export function getTagById(tags: LocalTag[], id: string): LocalTag | undefined { + return tags.find((t) => t.id === id); +} + +export function getTagsByIds(tags: LocalTag[], ids: string[]): LocalTag[] { + const idSet = new Set(ids); + return tags.filter((t) => idSet.has(t.id)); +} + +export const tagMutations = { + async createTag(data: { name: string; color?: string }): Promise { + try { + const now = new Date().toISOString(); + const tag: LocalTag = { + id: crypto.randomUUID(), + name: data.name, + color: data.color ?? null, + createdAt: now, + updatedAt: now, + }; + await db.table('photoTags').add(tag); + return tag; + } catch (e) { + console.error('Failed to create tag:', e); + return null; + } + }, + + async deleteTag(id: string): Promise { + try { + const now = new Date().toISOString(); + await db.table('photoTags').update(id, { deletedAt: now, updatedAt: now }); + // Also soft-delete photo-tag associations + const associations = await db.table('photoMediaTags').toArray(); + for (const a of associations.filter((pt) => pt.tagId === id)) { + await db.table('photoMediaTags').update(a.id, { deletedAt: now, updatedAt: now }); + } + return true; + } catch (e) { + console.error('Failed to delete tag:', e); + return false; + } + }, +}; + +// ─── Photo-Tag Junction ─────────────────────────────────── + +export const photoTagOps = { + /** Get tags for a photo */ + async getPhotoTags(mediaId: string): Promise { + try { + const all = await db.table('photoMediaTags').toArray(); + return all.filter((pt) => pt.mediaId === mediaId && !pt.deletedAt).map((pt) => pt.tagId); + } catch (e) { + console.error('Failed to get photo tags:', e); + return []; + } + }, + + /** Add tag to photo */ + async addTagToPhoto(mediaId: string, tagId: string) { + try { + const all = await db.table('photoMediaTags').toArray(); + const exists = all.some( + (pt) => pt.mediaId === mediaId && pt.tagId === tagId && !pt.deletedAt + ); + if (exists) return true; + + const now = new Date().toISOString(); + await db.table('photoMediaTags').add({ + id: crypto.randomUUID(), + mediaId, + tagId, + createdAt: now, + updatedAt: now, + }); + return true; + } catch (e) { + console.error('Failed to add tag to photo:', e); + return false; + } + }, + + /** Remove tag from photo */ + async removeTagFromPhoto(mediaId: string, tagId: string) { + try { + const all = await db.table('photoMediaTags').toArray(); + const item = all.find((pt) => pt.mediaId === mediaId && pt.tagId === tagId && !pt.deletedAt); + if (item) { + const now = new Date().toISOString(); + await db.table('photoMediaTags').update(item.id, { deletedAt: now, updatedAt: now }); + } + return true; + } catch (e) { + console.error('Failed to remove tag from photo:', e); + return false; + } + }, + + /** Set all tags for a photo (replace) */ + async setPhotoTags(mediaId: string, tagIds: string[]) { + try { + const now = new Date().toISOString(); + // Soft-delete existing tags for this photo + const all = await db.table('photoMediaTags').toArray(); + const existing = all.filter((pt) => pt.mediaId === mediaId && !pt.deletedAt); + for (const item of existing) { + await db.table('photoMediaTags').update(item.id, { deletedAt: now, updatedAt: now }); + } + + // Add new tags + for (const tagId of tagIds) { + await db.table('photoMediaTags').add({ + id: crypto.randomUUID(), + mediaId, + tagId, + createdAt: now, + updatedAt: now, + }); + } + return true; + } catch (e) { + console.error('Failed to set photo tags:', e); + return false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/photos/types.ts b/apps/manacore/apps/web/src/lib/modules/photos/types.ts new file mode 100644 index 000000000..daea2033e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/photos/types.ts @@ -0,0 +1,99 @@ +/** + * Photos module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalAlbum extends BaseRecord { + name: string; + description?: string | null; + coverMediaId?: string | null; + isAutoGenerated: boolean; + autoGenerateType?: 'date' | 'location' | 'camera' | null; + autoGenerateValue?: string | null; +} + +export interface LocalAlbumItem extends BaseRecord { + albumId: string; + mediaId: string; + sortOrder: number; +} + +export interface LocalFavorite extends BaseRecord { + mediaId: string; +} + +export interface LocalTag extends BaseRecord { + name: string; + color?: string | null; +} + +export interface LocalPhotoTag extends BaseRecord { + mediaId: string; + tagId: string; +} + +/** Server-fetched photo (from mana-media, not local-first). */ +export interface Photo { + id: string; + url: string; + thumbnailUrl?: string; + width?: number; + height?: number; + size?: number; + mimeType?: string; + isFavorited?: boolean; + createdAt?: string; + tags?: Array<{ id: string; name: string; color?: string }>; + exif?: { + dateTaken?: string; + cameraMake?: string; + cameraModel?: string; + focalLength?: string; + aperture?: string; + iso?: number; + exposureTime?: string; + gpsLatitude?: number; + gpsLongitude?: number; + }; +} + +export interface PhotoFilters { + apps?: string[]; + mimeType?: string; + dateFrom?: string; + dateTo?: string; + hasLocation?: boolean; + limit?: number; + offset?: number; + sortBy?: 'dateTaken' | 'createdAt' | 'size'; + sortOrder?: 'asc' | 'desc'; +} + +export interface PhotoStats { + totalCount: number; + totalSize?: number; +} + +/** Album type for UI consumption. */ +export interface Album { + id: string; + name: string; + description?: string; + coverMediaId?: string; + coverUrl?: string; + isAutoGenerated: boolean; + autoGenerateType?: string; + autoGenerateValue?: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface AlbumItem { + id: string; + albumId: string; + mediaId: string; + sortOrder: number; + addedAt: string; +} diff --git a/apps/manacore/apps/web/src/lib/modules/planta/collections.ts b/apps/manacore/apps/web/src/lib/modules/planta/collections.ts new file mode 100644 index 000000000..e5cdc4d93 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/collections.ts @@ -0,0 +1,77 @@ +/** + * Planta module — collection accessors and guest seed data. + * + * Tables are already defined in the unified database (database.ts): + * plants, plantPhotos, wateringSchedules, wateringLogs + */ + +import { db } from '$lib/data/database'; +import type { LocalPlant, LocalPlantPhoto, LocalWateringSchedule, LocalWateringLog } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const plantTable = db.table('plants'); +export const plantPhotoTable = db.table('plantPhotos'); +export const wateringScheduleTable = db.table('wateringSchedules'); +export const wateringLogTable = db.table('wateringLogs'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_PLANT_ID = 'demo-monstera'; + +export const PLANTA_GUEST_SEED = { + plants: [ + { + id: DEMO_PLANT_ID, + name: 'Monstera', + scientificName: 'Monstera deliciosa', + commonName: 'Fensterblatt', + species: null, + lightRequirements: 'bright' as const, + wateringFrequencyDays: 7, + humidity: 'medium' as const, + temperature: '18-24\u00b0C', + soilType: null, + careNotes: 'Mag indirektes Licht. Erde zwischen dem Giessen leicht antrocknen lassen.', + isActive: true, + healthStatus: 'healthy' as const, + acquiredAt: null, + }, + { + id: 'demo-cactus', + name: 'Kaktus', + scientificName: 'Echinocactus grusonii', + commonName: 'Schwiegermutterstuhl', + species: null, + lightRequirements: 'direct' as const, + wateringFrequencyDays: 21, + humidity: 'low' as const, + temperature: '15-30\u00b0C', + soilType: null, + careNotes: 'Selten giessen, mag viel Sonne.', + isActive: true, + healthStatus: 'healthy' as const, + acquiredAt: null, + }, + ], + wateringSchedules: [ + { + id: 'schedule-monstera', + plantId: DEMO_PLANT_ID, + frequencyDays: 7, + lastWateredAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + nextWateringAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(), + reminderEnabled: true, + reminderHoursBefore: 24, + }, + { + id: 'schedule-cactus', + plantId: 'demo-cactus', + frequencyDays: 21, + lastWateredAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), + nextWateringAt: new Date(Date.now() + 11 * 24 * 60 * 60 * 1000).toISOString(), + reminderEnabled: true, + reminderHoursBefore: 24, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/planta/index.ts b/apps/manacore/apps/web/src/lib/modules/planta/index.ts new file mode 100644 index 000000000..d260466e4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/index.ts @@ -0,0 +1,64 @@ +/** + * Planta module — barrel exports. + */ + +// Collections & seed data +export { + plantTable, + plantPhotoTable, + wateringScheduleTable, + wateringLogTable, + PLANTA_GUEST_SEED, +} from './collections'; + +// Types +export type { + LocalPlant, + LocalPlantPhoto, + LocalWateringSchedule, + LocalWateringLog, + Plant, + PlantPhoto, + WateringSchedule, + WateringLog, + CreatePlantDto, + UpdatePlantDto, + LightLevel, + HumidityLevel, + HealthStatus, + HealthAssessment, +} from './types'; + +// Queries +export { + useAllPlants, + useAllPlantPhotos, + useAllWateringSchedules, + useAllWateringLogs, + toPlant, + toPlantPhoto, + toWateringSchedule, + toWateringLog, + getPlantById, + getActivePlants, + getPhotosForPlant, + getPrimaryPhoto, + getScheduleForPlant, + getLogsForPlant, + getDaysUntilWatering, + isWateringOverdue, +} from './queries'; + +// Mutations +export { plantMutations, wateringMutations } from './mutations'; + +// Utils +export { + parsePlantInput, + resolvePlantData, + formatParsedPlantPreview, + type CareAction, + type ParsedPlant, + type ParsedPlantWithIds, +} from './utils/plant-parser'; +export { PLANTA_SYNTAX } from './utils/syntax-help'; diff --git a/apps/manacore/apps/web/src/lib/modules/planta/mutations.ts b/apps/manacore/apps/web/src/lib/modules/planta/mutations.ts new file mode 100644 index 000000000..ebc26d14c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/mutations.ts @@ -0,0 +1,164 @@ +/** + * Planta — Mutation Helpers (Local-First) + * + * All writes go to IndexedDB first, sync handles the rest. + */ + +import { db } from '$lib/data/database'; +import { toPlant, toWateringSchedule } from './queries'; +import { trackEvent } from '@manacore/shared-utils/analytics'; +import type { + LocalPlant, + LocalWateringSchedule, + LocalWateringLog, + Plant, + CreatePlantDto, + UpdatePlantDto, +} from './types'; + +export const plantMutations = { + async create(dto: CreatePlantDto): Promise { + try { + const now = new Date().toISOString(); + const newLocal: LocalPlant = { + id: crypto.randomUUID(), + name: dto.name, + scientificName: dto.scientificName ?? null, + commonName: dto.commonName ?? null, + species: null, + lightRequirements: null, + wateringFrequencyDays: null, + humidity: null, + temperature: null, + soilType: null, + careNotes: null, + isActive: true, + healthStatus: null, + acquiredAt: dto.acquiredAt ?? null, + createdAt: now, + updatedAt: now, + }; + await db.table('plants').add(newLocal); + trackEvent('plant_created'); + return toPlant(newLocal); + } catch (e) { + console.error('Failed to create plant:', e); + return null; + } + }, + + async update(id: string, dto: UpdatePlantDto): Promise { + try { + const updateData: Record = { + updatedAt: new Date().toISOString(), + }; + if (dto.name !== undefined) updateData.name = dto.name; + if (dto.scientificName !== undefined) updateData.scientificName = dto.scientificName ?? null; + if (dto.commonName !== undefined) updateData.commonName = dto.commonName ?? null; + if (dto.careNotes !== undefined) updateData.careNotes = dto.careNotes ?? null; + if (dto.isActive !== undefined) updateData.isActive = dto.isActive; + if (dto.lightRequirements !== undefined) + updateData.lightRequirements = dto.lightRequirements ?? null; + if (dto.wateringFrequencyDays !== undefined) + updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null; + if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null; + + await db.table('plants').update(id, updateData); + const updated = await db.table('plants').get(id); + return updated ? toPlant(updated) : null; + } catch (e) { + console.error('Failed to update plant:', e); + return null; + } + }, + + async delete(id: string): Promise { + try { + await db.table('plants').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + trackEvent('plant_deleted'); + return true; + } catch (e) { + console.error('Failed to delete plant:', e); + return false; + } + }, +}; + +export const wateringMutations = { + async logWatering(plantId: string, notes?: string): Promise { + try { + const now = new Date().toISOString(); + + // Create watering log entry + const logEntry: LocalWateringLog = { + id: crypto.randomUUID(), + plantId, + wateredAt: now, + notes: notes ?? null, + createdAt: now, + updatedAt: now, + }; + await db.table('wateringLogs').add(logEntry); + + // Update watering schedule + const schedules = await db.table('wateringSchedules').toArray(); + const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); + if (schedule) { + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + schedule.frequencyDays); + + await db.table('wateringSchedules').update(schedule.id, { + lastWateredAt: now, + nextWateringAt: nextDate.toISOString(), + updatedAt: now, + }); + } + + trackEvent('plant_watered'); + return true; + } catch (e) { + console.error('Failed to log watering:', e); + return false; + } + }, + + async updateSchedule(plantId: string, frequencyDays: number): Promise { + try { + const now = new Date().toISOString(); + const schedules = await db.table('wateringSchedules').toArray(); + const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); + + if (schedule) { + const nextDate = schedule.lastWateredAt + ? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000) + : new Date(Date.now() + frequencyDays * 86400000); + + await db.table('wateringSchedules').update(schedule.id, { + frequencyDays, + nextWateringAt: nextDate.toISOString(), + updatedAt: now, + }); + } else { + const nextDate = new Date(Date.now() + frequencyDays * 86400000); + await db.table('wateringSchedules').add({ + id: crypto.randomUUID(), + plantId, + frequencyDays, + lastWateredAt: null, + nextWateringAt: nextDate.toISOString(), + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: now, + updatedAt: now, + }); + } + return true; + } catch (e) { + console.error('Failed to update watering schedule:', e); + return false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/planta/queries.ts b/apps/manacore/apps/web/src/lib/modules/planta/queries.ts new file mode 100644 index 000000000..ed5c4aa83 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/queries.ts @@ -0,0 +1,180 @@ +/** + * Reactive Queries & Pure Helpers for Planta + * + * Uses Dexie liveQuery to automatically re-render when IndexedDB changes + * (local writes, sync updates, other tabs). Components call these hooks + * at init time; no manual fetch/refresh needed. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalPlant, + LocalPlantPhoto, + LocalWateringSchedule, + LocalWateringLog, + Plant, + PlantPhoto, + WateringSchedule, + WateringLog, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +/** Convert a LocalPlant (IndexedDB) to the shared Plant type. */ +export function toPlant(local: LocalPlant): Plant { + return { + id: local.id, + userId: 'local', + name: local.name, + scientificName: local.scientificName ?? undefined, + commonName: local.commonName ?? undefined, + species: local.species ?? undefined, + lightRequirements: local.lightRequirements ?? undefined, + wateringFrequencyDays: local.wateringFrequencyDays ?? undefined, + humidity: local.humidity ?? undefined, + temperature: local.temperature ?? undefined, + soilType: local.soilType ?? undefined, + careNotes: local.careNotes ?? undefined, + isActive: local.isActive, + healthStatus: local.healthStatus ?? undefined, + acquiredAt: local.acquiredAt ? new Date(local.acquiredAt) : undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + updatedAt: new Date(local.updatedAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalPlantPhoto (IndexedDB) to the shared PlantPhoto type. */ +export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + storagePath: local.storagePath, + publicUrl: local.publicUrl ?? undefined, + filename: local.filename, + mimeType: local.mimeType ?? undefined, + fileSize: local.fileSize ?? undefined, + width: local.width ?? undefined, + height: local.height ?? undefined, + isPrimary: local.isPrimary, + isAnalyzed: local.isAnalyzed, + takenAt: local.takenAt ? new Date(local.takenAt) : undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalWateringSchedule (IndexedDB) to the shared WateringSchedule type. */ +export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedule { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + frequencyDays: local.frequencyDays, + lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined, + nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined, + reminderEnabled: local.reminderEnabled, + reminderHoursBefore: local.reminderHoursBefore, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + updatedAt: new Date(local.updatedAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalWateringLog (IndexedDB) to the shared WateringLog type. */ +export function toWateringLog(local: LocalWateringLog): WateringLog { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + wateredAt: new Date(local.wateredAt), + notes: local.notes ?? undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +/** All plants. Auto-updates on any change. */ +export function useAllPlants() { + return liveQuery(async () => { + const locals = await db.table('plants').toArray(); + return locals.filter((p) => !p.deletedAt).map(toPlant); + }); +} + +/** All plant photos. Auto-updates on any change. */ +export function useAllPlantPhotos() { + return liveQuery(async () => { + const locals = await db.table('plantPhotos').toArray(); + return locals.filter((p) => !p.deletedAt).map(toPlantPhoto); + }); +} + +/** All watering schedules. Auto-updates on any change. */ +export function useAllWateringSchedules() { + return liveQuery(async () => { + const locals = await db.table('wateringSchedules').toArray(); + return locals.filter((s) => !s.deletedAt).map(toWateringSchedule); + }); +} + +/** All watering logs. Auto-updates on any change. */ +export function useAllWateringLogs() { + return liveQuery(async () => { + const locals = await db.table('wateringLogs').toArray(); + return locals.filter((l) => !l.deletedAt).map(toWateringLog); + }); +} + +// ─── Pure Plant Helpers ──────────────────────────────────── + +/** Get a plant by ID. */ +export function getPlantById(plants: Plant[], id: string): Plant | undefined { + return plants.find((p) => p.id === id); +} + +/** Get active plants only. */ +export function getActivePlants(plants: Plant[]): Plant[] { + return plants.filter((p) => p.isActive); +} + +/** Get photos for a specific plant. */ +export function getPhotosForPlant(photos: PlantPhoto[], plantId: string): PlantPhoto[] { + return photos.filter((p) => p.plantId === plantId); +} + +/** Get the primary photo for a plant. */ +export function getPrimaryPhoto(photos: PlantPhoto[], plantId: string): PlantPhoto | undefined { + return photos.find((p) => p.plantId === plantId && p.isPrimary); +} + +// ─── Pure Watering Helpers ───────────────────────────────── + +/** Get watering schedule for a specific plant. */ +export function getScheduleForPlant( + schedules: WateringSchedule[], + plantId: string +): WateringSchedule | undefined { + return schedules.find((s) => s.plantId === plantId); +} + +/** Get watering logs for a specific plant, sorted by date (newest first). */ +export function getLogsForPlant(logs: WateringLog[], plantId: string): WateringLog[] { + return logs + .filter((l) => l.plantId === plantId) + .sort((a, b) => new Date(b.wateredAt).getTime() - new Date(a.wateredAt).getTime()); +} + +/** Calculate days until next watering. Negative means overdue. */ +export function getDaysUntilWatering(schedule: WateringSchedule | undefined): number | null { + if (!schedule?.nextWateringAt) return null; + const now = new Date(); + const next = new Date(schedule.nextWateringAt); + return Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); +} + +/** Check if a plant's watering is overdue. */ +export function isWateringOverdue(schedule: WateringSchedule | undefined): boolean { + const days = getDaysUntilWatering(schedule); + return days !== null && days < 0; +} diff --git a/apps/manacore/apps/web/src/lib/modules/planta/stores/tags.svelte.ts b/apps/manacore/apps/web/src/lib/modules/planta/stores/tags.svelte.ts new file mode 100644 index 000000000..074782375 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/stores/tags.svelte.ts @@ -0,0 +1,13 @@ +/** + * Tag Store — Local-First via Shared Tag Store + * Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps. + * Use context ('tags') for reads, tagMutations for writes. + */ +export { + tagMutations, + useAllTags, + getTagById, + getTagsByIds, + getTagColor, + getTagsByGroup, +} from '@manacore/shared-stores'; diff --git a/apps/manacore/apps/web/src/lib/modules/planta/types.ts b/apps/manacore/apps/web/src/lib/modules/planta/types.ts new file mode 100644 index 000000000..7e40f6d74 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/types.ts @@ -0,0 +1,140 @@ +/** + * Planta module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Enums ───────────────────────────────────────────────── + +export type LightLevel = 'low' | 'medium' | 'bright' | 'direct'; +export type HumidityLevel = 'low' | 'medium' | 'high'; +export type HealthStatus = 'healthy' | 'needs_attention' | 'sick'; +export type HealthAssessment = 'healthy' | 'minor_issues' | 'needs_care' | 'critical'; + +// ─── Local Record Types ──────────────────────────────────── + +export interface LocalPlant extends BaseRecord { + name: string; + scientificName?: string | null; + commonName?: string | null; + species?: string | null; + lightRequirements?: LightLevel | null; + wateringFrequencyDays?: number | null; + humidity?: HumidityLevel | null; + temperature?: string | null; + soilType?: string | null; + careNotes?: string | null; + isActive: boolean; + healthStatus?: HealthStatus | null; + acquiredAt?: string | null; +} + +export interface LocalPlantPhoto extends BaseRecord { + plantId: string; + storagePath: string; + publicUrl?: string | null; + filename: string; + mimeType?: string | null; + fileSize?: number | null; + width?: number | null; + height?: number | null; + isPrimary: boolean; + isAnalyzed: boolean; + takenAt?: string | null; +} + +export interface LocalWateringSchedule extends BaseRecord { + plantId: string; + frequencyDays: number; + lastWateredAt?: string | null; + nextWateringAt?: string | null; + reminderEnabled: boolean; + reminderHoursBefore: number; +} + +export interface LocalWateringLog extends BaseRecord { + plantId: string; + wateredAt: string; + notes?: string | null; +} + +// ─── Shared Domain Types ─────────────────────────────────── + +export interface Plant { + id: string; + userId: string; + name: string; + scientificName?: string; + commonName?: string; + species?: string; + lightRequirements?: LightLevel; + wateringFrequencyDays?: number; + humidity?: HumidityLevel; + temperature?: string; + soilType?: string; + careNotes?: string; + isActive: boolean; + healthStatus?: HealthStatus; + acquiredAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface PlantPhoto { + id: string; + plantId: string; + userId: string; + storagePath: string; + publicUrl?: string; + filename: string; + mimeType?: string; + fileSize?: number; + width?: number; + height?: number; + isPrimary: boolean; + isAnalyzed: boolean; + takenAt?: Date; + createdAt: Date; +} + +export interface WateringSchedule { + id: string; + plantId: string; + userId: string; + frequencyDays: number; + lastWateredAt?: Date; + nextWateringAt?: Date; + reminderEnabled: boolean; + reminderHoursBefore: number; + createdAt: Date; + updatedAt: Date; +} + +export interface WateringLog { + id: string; + plantId: string; + userId: string; + wateredAt: Date; + notes?: string; + createdAt: Date; +} + +// ─── DTOs ────────────────────────────────────────────────── + +export interface CreatePlantDto { + name: string; + scientificName?: string; + commonName?: string; + acquiredAt?: string; +} + +export interface UpdatePlantDto { + name?: string; + scientificName?: string; + commonName?: string; + careNotes?: string; + isActive?: boolean; + lightRequirements?: LightLevel; + wateringFrequencyDays?: number; + humidity?: HumidityLevel; +} diff --git a/apps/manacore/apps/web/src/lib/modules/planta/utils/plant-parser.ts b/apps/manacore/apps/web/src/lib/modules/planta/utils/plant-parser.ts new file mode 100644 index 000000000..638b57e83 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/utils/plant-parser.ts @@ -0,0 +1,210 @@ +/** + * Plant Parser for Planta Module + * + * Extends the base parser with plant-specific patterns: + * - Scientific names (italic Latin names) + * - Acquisition date + * - Tags for categories + * + * Examples: + * - "Monstera deliciosa #tropisch" + * - "Basilikum heute gekauft #kraeuter" + * - "Ficus benjamina morgen #zimmerpflanze" + */ + +import { + parseBaseInput, + extractTags, + combineDateAndTime, + formatDatePreview, + type ParserLocale, +} from '@manacore/shared-utils'; + +export type CareAction = 'watered' | 'repotted' | 'fertilized' | 'pruned'; + +export interface ParsedPlant { + name: string; + acquiredAt?: Date; + tagNames: string[]; + action?: CareAction; +} + +export interface ParsedPlantWithIds { + name: string; + acquiredAt?: string; +} + +// Care action patterns per locale +const CARE_ACTION_PATTERNS_BY_LOCALE: Record< + ParserLocale, + { action: CareAction; pattern: RegExp }[] +> = { + de: [ + { action: 'watered', pattern: /\b(?:gegossen|gewaessert)\b/i }, + { action: 'repotted', pattern: /\bumgetopft\b/i }, + { action: 'fertilized', pattern: /\bgeduengt\b/i }, + { action: 'pruned', pattern: /\b(?:geschnitten|gestutzt)\b/i }, + ], + en: [ + { action: 'watered', pattern: /\bwatered\b/i }, + { action: 'repotted', pattern: /\brepotted\b/i }, + { action: 'fertilized', pattern: /\bfertilized\b/i }, + { action: 'pruned', pattern: /\b(?:pruned|trimmed)\b/i }, + ], + fr: [ + { action: 'watered', pattern: /\barros\u00e9\b/i }, + { action: 'repotted', pattern: /\brempot\u00e9\b/i }, + { action: 'fertilized', pattern: /\bfertilis\u00e9\b/i }, + { action: 'pruned', pattern: /\btaill\u00e9\b/i }, + ], + es: [ + { action: 'watered', pattern: /\bregado\b/i }, + { action: 'repotted', pattern: /\btrasplantado\b/i }, + { action: 'fertilized', pattern: /\bfertilizado\b/i }, + { action: 'pruned', pattern: /\bpodado\b/i }, + ], + it: [ + { action: 'watered', pattern: /\bannaffiato\b/i }, + { action: 'repotted', pattern: /\brinvasato\b/i }, + { action: 'fertilized', pattern: /\bfertilizzato\b/i }, + { action: 'pruned', pattern: /\bpotato\b/i }, + ], +}; + +const ACTION_LABELS: Record> = { + watered: { de: 'Gegossen', en: 'Watered', fr: 'Arros\u00e9', es: 'Regado', it: 'Annaffiato' }, + repotted: { + de: 'Umgetopft', + en: 'Repotted', + fr: 'Rempot\u00e9', + es: 'Trasplantado', + it: 'Rinvasato', + }, + fertilized: { + de: 'Ged\u00fcngt', + en: 'Fertilized', + fr: 'Fertilis\u00e9', + es: 'Fertilizado', + it: 'Fertilizzato', + }, + pruned: { de: 'Geschnitten', en: 'Pruned', fr: 'Taill\u00e9', es: 'Podado', it: 'Potato' }, +}; + +const ACTION_EMOJIS: Record = { + watered: '\ud83d\udca7', + repotted: '\ud83c\udf31', + fertilized: '\ud83e\uddea', + pruned: '\u2702\ufe0f', +}; + +function extractCareAction( + text: string, + locale: ParserLocale = 'de' +): { action?: CareAction; remaining: string } { + const patterns = CARE_ACTION_PATTERNS_BY_LOCALE[locale]; + for (const { action, pattern } of patterns) { + if (pattern.test(text)) { + return { + action, + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { action: undefined, remaining: text }; +} + +// Acquisition keywords per locale +const ACQUIRED_PATTERNS_BY_LOCALE: Record = { + de: [/\bgekauft\b/i, /\bbekommen\b/i, /\berhalten\b/i, /\bgepflanzt\b/i], + en: [/\bbought\b/i, /\breceived\b/i, /\bgot\b/i, /\bplanted\b/i], + fr: [/\bachet\u00e9\b/i, /\bre\u00e7u\b/i, /\bplant\u00e9\b/i], + es: [/\bcomprado\b/i, /\brecibido\b/i, /\bplantado\b/i], + it: [/\bcomprato\b/i, /\bricevuto\b/i, /\bpiantato\b/i], +}; + +function extractAcquiredKeyword( + text: string, + locale: ParserLocale = 'de' +): { found: boolean; remaining: string } { + const patterns = ACQUIRED_PATTERNS_BY_LOCALE[locale]; + for (const pattern of patterns) { + if (pattern.test(text)) { + return { + found: true, + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { found: false, remaining: text }; +} + +/** + * Parse natural language plant input + * + * Examples: + * - "Monstera #tropisch" + * - "Basilikum heute gekauft #kraeuter" + * - "Ficus benjamina" + */ +export function parsePlantInput(input: string, locale: ParserLocale = 'de'): ParsedPlant { + let text = input.trim(); + + // Extract care action BEFORE base parser so the action word is removed from title + const careResult = extractCareAction(text, locale); + text = careResult.remaining; + + // Check for acquisition keywords + const acquiredResult = extractAcquiredKeyword(text, locale); + text = acquiredResult.remaining; + + // Use base parser for date, time, tags + const base = parseBaseInput(text, locale); + + // If we found a date (or acquisition keyword implies today) + let acquiredAt: Date | undefined; + if (base.date) { + acquiredAt = combineDateAndTime(base.date, base.time); + } else if (acquiredResult.found) { + acquiredAt = new Date(); // "gekauft" without date = today + } + + return { + name: base.title, + acquiredAt, + tagNames: base.tagNames, + action: careResult.action, + }; +} + +/** + * Resolve to API-ready format + */ +export function resolvePlantData(parsed: ParsedPlant): ParsedPlantWithIds { + return { + name: parsed.name, + acquiredAt: parsed.acquiredAt?.toISOString(), + }; +} + +/** + * Format parsed plant for preview display + */ +export function formatParsedPlantPreview(parsed: ParsedPlant, locale: ParserLocale = 'de'): string { + const parts: string[] = []; + + if (parsed.action) { + const emoji = ACTION_EMOJIS[parsed.action]; + const label = ACTION_LABELS[parsed.action][locale]; + parts.push(`${emoji} ${label}`); + } + + if (parsed.acquiredAt) { + parts.push(`\ud83d\udcc5 ${formatDatePreview(parsed.acquiredAt, locale)}`); + } + + if (parsed.tagNames.length > 0) { + parts.push(`\ud83c\udff7\ufe0f ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' \u00b7 '); +} diff --git a/apps/manacore/apps/web/src/lib/modules/planta/utils/syntax-help.ts b/apps/manacore/apps/web/src/lib/modules/planta/utils/syntax-help.ts new file mode 100644 index 000000000..227f0d74c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/planta/utils/syntax-help.ts @@ -0,0 +1,24 @@ +/** + * Planta-specific syntax help patterns + */ +import type { SyntaxGroup } from '@manacore/shared-ui'; + +export const PLANTA_SYNTAX: SyntaxGroup[] = [ + { + title: 'Pflanzen', + items: [ + { + pattern: 'Pflege', + description: 'Pflege-Aktion loggen', + examples: ['Monstera gegossen', 'Ficus umgetopft', 'Rose geduengt'], + color: 'success', + }, + { + pattern: 'Erworben', + description: 'Erwerbsdatum angeben', + examples: ['gekauft', 'gepflanzt', 'bekommen'], + color: 'accent', + }, + ], + }, +]; diff --git a/apps/manacore/apps/web/src/lib/modules/times/collections.ts b/apps/manacore/apps/web/src/lib/modules/times/collections.ts new file mode 100644 index 000000000..c2d8bffdb --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/collections.ts @@ -0,0 +1,195 @@ +/** + * Times module — collection accessors and guest seed data. + * + * Tables are defined in the unified database.ts as: + * timeClients, timeProjects, timeEntries, timeTags, timeTemplates, timeSettings + */ + +import { db } from '$lib/data/database'; +import type { + LocalClient, + LocalProject, + LocalTimeEntry, + LocalTag, + LocalTemplate, + LocalSettings, +} from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const clientTable = db.table('timeClients'); +export const projectTable = db.table('timeProjects'); +export const timeEntryTable = db.table('timeEntries'); +export const tagTable = db.table('timeTags'); +export const templateTable = db.table('timeTemplates'); +export const settingsTable = db.table('timeSettings'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_CLIENT_ID = 'demo-client-acme'; +const DEMO_PROJECT_ID = 'demo-project-redesign'; +const DEMO_INTERNAL_PROJECT_ID = 'demo-project-internal'; + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +function yesterdayStr(): string { + const d = new Date(); + d.setDate(d.getDate() - 1); + return d.toISOString().split('T')[0]; +} + +export const TIMES_GUEST_SEED = { + timeClients: [ + { + id: DEMO_CLIENT_ID, + name: 'Acme Corp', + shortCode: 'ACME', + email: 'kontakt@acme.de', + color: '#3b82f6', + isArchived: false, + billingRate: { amount: 95, currency: 'EUR', per: 'hour' as const }, + order: 0, + }, + { + id: 'demo-client-startup', + name: 'TechStartup GmbH', + shortCode: 'TS', + color: '#8b5cf6', + isArchived: false, + billingRate: { amount: 85, currency: 'EUR', per: 'hour' as const }, + order: 1, + }, + ], + timeProjects: [ + { + id: DEMO_PROJECT_ID, + clientId: DEMO_CLIENT_ID, + name: 'Website Redesign', + description: 'Kompletter Relaunch der Unternehmenswebsite', + color: '#3b82f6', + isArchived: false, + isBillable: true, + billingRate: { amount: 95, currency: 'EUR', per: 'hour' as const }, + budget: { type: 'hours' as const, amount: 120 }, + visibility: 'private' as const, + order: 0, + }, + { + id: DEMO_INTERNAL_PROJECT_ID, + name: 'Intern / Meetings', + description: 'Interne Meetings, Orga, Admin', + color: '#6b7280', + isArchived: false, + isBillable: false, + visibility: 'private' as const, + order: 1, + }, + { + id: 'demo-project-app', + clientId: 'demo-client-startup', + name: 'Mobile App', + description: 'React Native App Entwicklung', + color: '#8b5cf6', + isArchived: false, + isBillable: true, + budget: { type: 'hours' as const, amount: 200 }, + visibility: 'private' as const, + order: 2, + }, + ], + timeEntries: [ + { + id: 'times-entry-1', + projectId: DEMO_PROJECT_ID, + clientId: DEMO_CLIENT_ID, + description: 'Homepage Layout erstellen', + date: todayStr(), + startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(), + endTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(), + duration: 9000, + isBillable: true, + isRunning: false, + tags: ['design'], + visibility: 'private' as const, + source: { app: 'manual' as const }, + }, + { + id: 'times-entry-2', + projectId: DEMO_INTERNAL_PROJECT_ID, + description: 'Sprint Planning', + date: todayStr(), + startTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(), + endTime: new Date(new Date().setHours(12, 15, 0, 0)).toISOString(), + duration: 2700, + isBillable: false, + isRunning: false, + tags: ['meeting'], + visibility: 'private' as const, + source: { app: 'manual' as const }, + }, + { + id: 'times-entry-3', + projectId: DEMO_PROJECT_ID, + clientId: DEMO_CLIENT_ID, + description: 'API Integration', + date: todayStr(), + startTime: new Date(new Date().setHours(13, 0, 0, 0)).toISOString(), + endTime: new Date(new Date().setHours(15, 0, 0, 0)).toISOString(), + duration: 7200, + isBillable: true, + isRunning: false, + tags: ['development'], + visibility: 'private' as const, + source: { app: 'timer' as const }, + }, + { + id: 'times-entry-4', + projectId: 'demo-project-app', + clientId: 'demo-client-startup', + description: 'Login Screen implementieren', + date: yesterdayStr(), + startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(), + endTime: new Date(new Date().setHours(12, 0, 0, 0)).toISOString(), + duration: 10800, + isBillable: true, + isRunning: false, + tags: ['development'], + visibility: 'private' as const, + source: { app: 'timer' as const }, + }, + { + id: 'times-entry-5', + projectId: DEMO_PROJECT_ID, + clientId: DEMO_CLIENT_ID, + description: 'Code Review & Testing', + date: yesterdayStr(), + duration: 5400, + isBillable: true, + isRunning: false, + tags: ['review'], + visibility: 'private' as const, + source: { app: 'manual' as const }, + }, + ], + timeTags: [ + { id: 'times-tag-design', name: 'design', color: '#f59e0b', order: 0 }, + { id: 'times-tag-dev', name: 'development', color: '#3b82f6', order: 1 }, + { id: 'times-tag-meeting', name: 'meeting', color: '#6b7280', order: 2 }, + { id: 'times-tag-review', name: 'review', color: '#22c55e', order: 3 }, + ], + timeSettings: [ + { + id: 'times-default-settings', + workingHoursPerDay: 8, + workingDaysPerWeek: 5, + roundingIncrement: 0, + roundingMethod: 'none' as const, + defaultVisibility: 'private' as const, + weekStartsOn: 1 as const, + timerReminderMinutes: 0, + autoStopTimerHours: 0, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/EntryForm.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/EntryForm.svelte new file mode 100644 index 000000000..f8df0756d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/EntryForm.svelte @@ -0,0 +1,329 @@ + + +{#if visible} + + +{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/EntryItem.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/EntryItem.svelte new file mode 100644 index 000000000..5b7d7c3fc --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/EntryItem.svelte @@ -0,0 +1,209 @@ + + +
+ + + + + {#if isExpanded} +
+ + handleDescriptionChange((e.target as HTMLInputElement).value)} + placeholder={$_('entry.description')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none" + /> + + +
+ + +
+ + handleDurationChange(parseInt((e.target as HTMLInputElement).value) || 0)} + min="0" + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]" + /> + min +
+
+ + +
+ + + +
+
+ {/if} +
+ + (showDeleteConfirm = false)} +/> diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/EntryList.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/EntryList.svelte new file mode 100644 index 000000000..3a02b4a53 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/EntryList.svelte @@ -0,0 +1,72 @@ + + +{#if entries.length === 0} +
+ +

{$_('entry.noEntries')}

+
+{:else} +
+ {#each groupedEntries() as [date, dayEntries]} +
+ +
+

+ {formatDateHeader(date)} +

+ + {formatDurationCompact(getTotalDuration(dayEntries))} + +
+ + +
+ {#each dayEntries as entry (entry.id)} + (expandedEntryId = entry.id)} + onCollapse={() => (expandedEntryId = null)} + /> + {/each} +
+
+ {/each} +
+{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/KeyboardShortcuts.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/KeyboardShortcuts.svelte new file mode 100644 index 000000000..5ed49daef --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/KeyboardShortcuts.svelte @@ -0,0 +1,49 @@ + diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/QuickStart.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/QuickStart.svelte new file mode 100644 index 000000000..deaff950f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/QuickStart.svelte @@ -0,0 +1,59 @@ + + +{#if recentEntries().length > 0} +
+

Quick Start

+
+ {#each recentEntries() as entry} + {@const project = entry.projectId + ? allProjects.value.find((p) => p.id === entry.projectId) + : undefined} + + {/each} +
+
+{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/TimerCard.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/TimerCard.svelte new file mode 100644 index 000000000..b94600d8e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/TimerCard.svelte @@ -0,0 +1,188 @@ + + +
+ +
+
+ {formattedTime} +
+
+ + +
+ handleDescriptionChange((e.target as HTMLInputElement).value)} + placeholder={$_('timer.whatAreYouWorkingOn')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:border-[hsl(var(--primary))] focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]" + /> +
+ + +
+ +
+ +
+ + + +
+ + + + + + {#if timerStore.isRunning && selectedProject} +
+
+ {selectedProject.name} + {#if selectedClient} + · {selectedClient.name} + {/if} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/lib/modules/times/components/TimerIndicator.svelte b/apps/manacore/apps/web/src/lib/modules/times/components/TimerIndicator.svelte new file mode 100644 index 000000000..9f6c707e3 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/components/TimerIndicator.svelte @@ -0,0 +1,61 @@ + + +{#if timerStore.isRunning} +
+ +
+ + +
+ + + {#if project} +
+ {/if} + + + + + {formattedTime} + + + + +
+{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/times/index.ts b/apps/manacore/apps/web/src/lib/modules/times/index.ts new file mode 100644 index 000000000..f83525188 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/index.ts @@ -0,0 +1,70 @@ +/** + * Times module — barrel exports. + */ + +export { timerStore } from './stores/timer.svelte'; +export { viewStore } from './stores/view.svelte'; +export { + useAllClients, + useAllProjects, + useAllTimeEntries, + useAllTags, + useAllTemplates, + useSettings, + toClient, + toProject, + toTimeEntry, + toTag, + toTemplate, + toSettings, + formatDuration, + formatDurationCompact, + formatDurationDecimal, + getEntriesByDate, + getEntriesByDateRange, + getTotalDuration, + getBillableDuration, + groupEntriesByDate, + groupEntriesByProject, + getFilteredEntries, + getSortedEntries, + getActiveProjects, + getActiveClients, + getProjectById, + getClientById, + getProjectsByClient, +} from './queries'; +export { + clientTable, + projectTable, + timeEntryTable, + tagTable, + templateTable, + settingsTable, + TIMES_GUEST_SEED, +} from './collections'; +export { roundDuration } from './utils/rounding'; +export { exportEntriesToCSV } from './utils/export'; +export { PROJECT_COLORS } from './types'; +export type { + LocalClient, + LocalProject, + LocalTimeEntry, + LocalTag, + LocalTemplate, + LocalSettings, + BillingRate, + ProjectVisibility, + EntrySourceRef, + Client, + Project, + TimeEntry, + Tag, + EntryTemplate, + TimesSettings, + FilterCriteria, + SortOption, + SavedFilter, + ViewMode, + RoundingMethod, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/times/queries.ts b/apps/manacore/apps/web/src/lib/modules/times/queries.ts new file mode 100644 index 000000000..cd47f31b5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/queries.ts @@ -0,0 +1,328 @@ +/** + * Reactive Queries & Pure Helpers for Times module. + * + * Uses Dexie liveQuery on the unified database. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalClient, + LocalProject, + LocalTimeEntry, + LocalTag, + LocalTemplate, + LocalSettings, + Client, + Project, + TimeEntry, + Tag, + EntryTemplate, + TimesSettings, + FilterCriteria, + SortOption, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toClient(local: LocalClient): Client { + return { + id: local.id, + name: local.name, + shortCode: local.shortCode ?? undefined, + contactId: local.contactId ?? undefined, + email: local.email ?? undefined, + color: local.color, + isArchived: local.isArchived, + billingRate: local.billingRate ?? undefined, + notes: local.notes ?? undefined, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toProject(local: LocalProject): Project { + return { + id: local.id, + clientId: local.clientId ?? undefined, + name: local.name, + description: local.description ?? undefined, + color: local.color, + isArchived: local.isArchived, + isBillable: local.isBillable, + billingRate: local.billingRate ?? undefined, + budget: local.budget ?? undefined, + visibility: local.visibility, + guildId: local.guildId ?? undefined, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toTimeEntry(local: LocalTimeEntry): TimeEntry { + return { + id: local.id, + projectId: local.projectId ?? undefined, + clientId: local.clientId ?? undefined, + description: local.description, + date: local.date, + startTime: local.startTime ?? undefined, + endTime: local.endTime ?? undefined, + duration: local.duration, + isBillable: local.isBillable, + isRunning: local.isRunning, + tags: local.tags, + billingRate: local.billingRate ?? undefined, + visibility: local.visibility, + guildId: local.guildId ?? undefined, + source: local.source ?? undefined, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toTag(local: LocalTag): Tag { + return { + id: local.id, + name: local.name, + color: local.color, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toTemplate(local: LocalTemplate): EntryTemplate { + return { + id: local.id, + name: local.name, + projectId: local.projectId ?? undefined, + clientId: local.clientId ?? undefined, + description: local.description, + isBillable: local.isBillable, + tags: local.tags, + usageCount: local.usageCount, + lastUsedAt: local.lastUsedAt ?? undefined, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toSettings(local: LocalSettings): TimesSettings { + return { + id: local.id, + defaultBillingRate: local.defaultBillingRate ?? undefined, + workingHoursPerDay: local.workingHoursPerDay, + workingDaysPerWeek: local.workingDaysPerWeek, + roundingIncrement: local.roundingIncrement, + roundingMethod: local.roundingMethod, + defaultVisibility: local.defaultVisibility, + weekStartsOn: local.weekStartsOn, + timerReminderMinutes: local.timerReminderMinutes, + autoStopTimerHours: local.autoStopTimerHours, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllClients() { + return liveQuery(async () => { + const locals = await db.table('timeClients').toArray(); + return locals.filter((c) => !c.deletedAt).map(toClient); + }); +} + +export function useAllProjects() { + return liveQuery(async () => { + const locals = await db.table('timeProjects').toArray(); + return locals.filter((p) => !p.deletedAt).map(toProject); + }); +} + +export function useAllTimeEntries() { + return liveQuery(async () => { + const locals = await db.table('timeEntries').toArray(); + return locals.filter((e) => !e.deletedAt).map(toTimeEntry); + }); +} + +export function useAllTags() { + return liveQuery(async () => { + const locals = await db.table('timeTags').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTag); + }); +} + +export function useAllTemplates() { + return liveQuery(async () => { + const locals = await db.table('timeTemplates').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTemplate); + }); +} + +export function useSettings() { + return liveQuery(async () => { + const locals = await db.table('timeSettings').toArray(); + const active = locals.filter((s) => !s.deletedAt); + return active.length > 0 ? toSettings(active[0]) : null; + }); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +/** Format duration in seconds to HH:MM:SS */ +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +/** Format duration in seconds to compact form (e.g., "2h 30m") */ +export function formatDurationCompact(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} + +/** Format duration in seconds to decimal hours (e.g., "2.50") */ +export function formatDurationDecimal(seconds: number): string { + return (seconds / 3600).toFixed(2); +} + +/** Get entries for a specific date */ +export function getEntriesByDate(entries: TimeEntry[], date: string): TimeEntry[] { + return entries + .filter((e) => e.date === date) + .sort((a, b) => { + if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime); + return 0; + }); +} + +/** Get entries for a date range */ +export function getEntriesByDateRange(entries: TimeEntry[], from: string, to: string): TimeEntry[] { + return entries.filter((e) => e.date >= from && e.date <= to); +} + +/** Get total duration for a list of entries */ +export function getTotalDuration(entries: TimeEntry[]): number { + return entries.reduce((sum, e) => sum + e.duration, 0); +} + +/** Get billable duration for a list of entries */ +export function getBillableDuration(entries: TimeEntry[]): number { + return entries.filter((e) => e.isBillable).reduce((sum, e) => sum + e.duration, 0); +} + +/** Group entries by date */ +export function groupEntriesByDate(entries: TimeEntry[]): Map { + const groups = new Map(); + for (const entry of entries) { + const existing = groups.get(entry.date) || []; + existing.push(entry); + groups.set(entry.date, existing); + } + return groups; +} + +/** Group entries by project */ +export function groupEntriesByProject(entries: TimeEntry[]): Map { + const groups = new Map(); + for (const entry of entries) { + const key = entry.projectId || 'no-project'; + const existing = groups.get(key) || []; + existing.push(entry); + groups.set(key, existing); + } + return groups; +} + +/** Filter entries by criteria */ +export function getFilteredEntries(entries: TimeEntry[], filters: FilterCriteria): TimeEntry[] { + let result = entries; + + if (filters.projectId) { + result = result.filter((e) => e.projectId === filters.projectId); + } + if (filters.clientId) { + result = result.filter((e) => e.clientId === filters.clientId); + } + if (filters.isBillable !== undefined) { + result = result.filter((e) => e.isBillable === filters.isBillable); + } + if (filters.tagIds?.length) { + result = result.filter((e) => filters.tagIds!.some((t) => e.tags.includes(t))); + } + if (filters.dateFrom) { + result = result.filter((e) => e.date >= filters.dateFrom!); + } + if (filters.dateTo) { + result = result.filter((e) => e.date <= filters.dateTo!); + } + if (filters.search) { + const q = filters.search.toLowerCase(); + result = result.filter((e) => e.description.toLowerCase().includes(q)); + } + + return result; +} + +/** Sort entries */ +export function getSortedEntries(entries: TimeEntry[], sort: SortOption): TimeEntry[] { + return [...entries].sort((a, b) => { + let cmp = 0; + switch (sort.field) { + case 'date': + cmp = a.date.localeCompare(b.date); + if (cmp === 0 && a.startTime && b.startTime) { + cmp = a.startTime.localeCompare(b.startTime); + } + break; + case 'duration': + cmp = a.duration - b.duration; + break; + case 'project': + cmp = (a.projectId || '').localeCompare(b.projectId || ''); + break; + case 'client': + cmp = (a.clientId || '').localeCompare(b.clientId || ''); + break; + case 'createdAt': + cmp = (a.createdAt || '').localeCompare(b.createdAt || ''); + break; + } + return sort.direction === 'desc' ? -cmp : cmp; + }); +} + +/** Get active projects (not archived) */ +export function getActiveProjects(projects: Project[]): Project[] { + return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order); +} + +/** Get active clients (not archived) */ +export function getActiveClients(clients: Client[]): Client[] { + return clients.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order); +} + +/** Get project by ID */ +export function getProjectById(projects: Project[], id: string): Project | undefined { + return projects.find((p) => p.id === id); +} + +/** Get client by ID */ +export function getClientById(clients: Client[], id: string): Client | undefined { + return clients.find((c) => c.id === id); +} + +/** Get projects for a client */ +export function getProjectsByClient(projects: Project[], clientId: string): Project[] { + return projects.filter((p) => p.clientId === clientId); +} diff --git a/apps/manacore/apps/web/src/lib/modules/times/stores/timer.svelte.ts b/apps/manacore/apps/web/src/lib/modules/times/stores/timer.svelte.ts new file mode 100644 index 000000000..0ded74a70 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/stores/timer.svelte.ts @@ -0,0 +1,173 @@ +/** + * Timer Store — manages the active time tracking timer. + * + * The timer state persists in IndexedDB via the timeEntries table. + * When a timer is running, there's a timeEntry with isRunning=true. + * This store provides reactive access to the running entry and elapsed time. + */ + +import { browser } from '$app/environment'; +import { timeEntryTable, settingsTable } from '$lib/modules/times/collections'; +import { roundDuration } from '$lib/modules/times/utils/rounding'; +import type { LocalTimeEntry } from '$lib/modules/times/types'; + +let runningEntry = $state(null); +let elapsedSeconds = $state(0); +let tickInterval: ReturnType | null = null; +let autoSaveInterval: ReturnType | null = null; + +function startTicking() { + stopTicking(); + tickInterval = setInterval(() => { + if (runningEntry?.startTime) { + elapsedSeconds = Math.floor((Date.now() - new Date(runningEntry.startTime).getTime()) / 1000); + } + }, 1000); +} + +function stopTicking() { + if (tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } + if (autoSaveInterval) { + clearInterval(autoSaveInterval); + autoSaveInterval = null; + } +} + +function startAutoSave() { + if (autoSaveInterval) clearInterval(autoSaveInterval); + autoSaveInterval = setInterval(async () => { + if (runningEntry) { + await timeEntryTable.update(runningEntry.id, { + duration: elapsedSeconds, + }); + } + }, 10000); // Auto-save every 10 seconds +} + +export const timerStore = { + get runningEntry() { + return runningEntry; + }, + get elapsedSeconds() { + return elapsedSeconds; + }, + get isRunning() { + return runningEntry !== null; + }, + + /** Initialize: check for any running entry in IndexedDB */ + async initialize() { + if (!browser) return; + const entries = await timeEntryTable.toArray(); + const running = entries.find((e) => e.isRunning && !e.deletedAt); + if (running) { + runningEntry = running; + if (running.startTime) { + elapsedSeconds = Math.floor((Date.now() - new Date(running.startTime).getTime()) / 1000); + } + startTicking(); + startAutoSave(); + } + }, + + /** Start a new timer */ + async start(options?: { + projectId?: string; + clientId?: string; + description?: string; + isBillable?: boolean; + tags?: string[]; + }) { + // Stop any existing timer first + if (runningEntry) { + await timerStore.stop(); + } + + const now = new Date(); + const entry: LocalTimeEntry = { + id: crypto.randomUUID(), + projectId: options?.projectId ?? null, + clientId: options?.clientId ?? null, + description: options?.description ?? '', + date: now.toISOString().split('T')[0], + startTime: now.toISOString(), + endTime: null, + duration: 0, + isBillable: options?.isBillable ?? false, + isRunning: true, + tags: options?.tags ?? [], + billingRate: null, + visibility: 'private', + guildId: null, + source: { app: 'timer' }, + }; + + await timeEntryTable.add(entry); + runningEntry = entry; + elapsedSeconds = 0; + startTicking(); + startAutoSave(); + }, + + /** Stop the running timer */ + async stop(): Promise { + if (!runningEntry) return null; + + const now = new Date(); + const finalDuration = runningEntry.startTime + ? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000) + : elapsedSeconds; + + // Apply rounding from settings + const settings = await settingsTable.toArray(); + const s = settings.find((s) => !s.deletedAt); + const roundedDuration = s + ? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod) + : finalDuration; + + await timeEntryTable.update(runningEntry.id, { + isRunning: false, + endTime: now.toISOString(), + duration: roundedDuration, + }); + + const stoppedEntry = { + ...runningEntry, + isRunning: false, + endTime: now.toISOString(), + duration: roundedDuration, + }; + stopTicking(); + runningEntry = null; + elapsedSeconds = 0; + return stoppedEntry as LocalTimeEntry; + }, + + /** Discard the running timer without saving */ + async discard() { + if (!runningEntry) return; + await timeEntryTable.delete(runningEntry.id); + stopTicking(); + runningEntry = null; + elapsedSeconds = 0; + }, + + /** Update the running entry's metadata (project, description, etc.) */ + async updateRunning( + updates: Partial< + Pick + > + ) { + if (!runningEntry) return; + await timeEntryTable.update(runningEntry.id, updates); + runningEntry = { ...runningEntry, ...updates }; + }, + + /** Cleanup on unmount */ + destroy() { + stopTicking(); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/times/stores/view.svelte.ts b/apps/manacore/apps/web/src/lib/modules/times/stores/view.svelte.ts new file mode 100644 index 000000000..8d6740195 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/stores/view.svelte.ts @@ -0,0 +1,110 @@ +/** + * View Store — manages view mode, sort, and filter state for Times. + */ + +import { browser } from '$app/environment'; +import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '$lib/modules/times/types'; + +const VIEW_KEY = 'times_view_mode'; +const SORT_KEY = 'times_sort'; +const FILTERS_KEY = 'times_saved_filters'; + +function load(key: string, fallback: T): T { + if (!browser) return fallback; + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : fallback; + } catch { + return fallback; + } +} + +function save(key: string, value: unknown) { + if (!browser) return; + localStorage.setItem(key, JSON.stringify(value)); +} + +let viewMode = $state('week'); +let sort = $state({ field: 'date', direction: 'desc' }); +let activeFilters = $state({}); +let savedFilters = $state([]); +let initialized = $state(false); + +export const viewStore = { + get viewMode() { + return viewMode; + }, + get sort() { + return sort; + }, + get activeFilters() { + return activeFilters; + }, + get savedFilters() { + return savedFilters; + }, + get hasActiveFilters() { + return !!( + activeFilters.search || + activeFilters.projectId || + activeFilters.clientId || + activeFilters.tagIds?.length || + activeFilters.isBillable !== undefined || + activeFilters.dateFrom || + activeFilters.dateTo + ); + }, + + initialize() { + if (initialized) return; + viewMode = load(VIEW_KEY, 'week'); + sort = load(SORT_KEY, { field: 'date', direction: 'desc' }); + savedFilters = load(FILTERS_KEY, []); + initialized = true; + }, + + setViewMode(mode: ViewMode) { + viewMode = mode; + save(VIEW_KEY, mode); + }, + + setSort(newSort: SortOption) { + sort = newSort; + save(SORT_KEY, newSort); + }, + + setFilters(filters: FilterCriteria) { + activeFilters = filters; + }, + + updateFilter(key: K, value: FilterCriteria[K]) { + activeFilters = { ...activeFilters, [key]: value }; + }, + + clearFilters() { + activeFilters = {}; + }, + + saveFilter(name: string) { + const filter: SavedFilter = { + id: crypto.randomUUID(), + name, + criteria: { ...activeFilters }, + createdAt: new Date().toISOString(), + }; + savedFilters = [...savedFilters, filter]; + save(FILTERS_KEY, savedFilters); + }, + + loadFilter(id: string) { + const filter = savedFilters.find((f) => f.id === id); + if (filter) { + activeFilters = { ...filter.criteria }; + } + }, + + deleteSavedFilter(id: string) { + savedFilters = savedFilters.filter((f) => f.id !== id); + save(FILTERS_KEY, savedFilters); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/times/types.ts b/apps/manacore/apps/web/src/lib/modules/times/types.ts new file mode 100644 index 000000000..71a5a86a9 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/types.ts @@ -0,0 +1,246 @@ +/** + * Times module types for the unified app. + * + * Mirrors @times/shared types but uses BaseRecord for local-first storage. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Shared Types (inlined from @times/shared) ──────────── + +export interface BillingRate { + amount: number; + currency: string; + per: 'hour' | 'day'; +} + +export type ProjectVisibility = 'private' | 'guild'; +export type EntrySource = 'todo' | 'calendar' | 'manual' | 'timer'; +export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest'; +export type ViewMode = 'day' | 'week' | 'month'; +export type SortField = 'date' | 'duration' | 'project' | 'client' | 'createdAt'; +export type SortDirection = 'asc' | 'desc'; + +export interface EntrySourceRef { + app: EntrySource; + refId?: string; +} + +export interface ProjectBudget { + type: 'hours' | 'fixed'; + amount: number; + currency?: string; +} + +export interface SortOption { + field: SortField; + direction: SortDirection; +} + +export interface FilterCriteria { + search?: string; + projectId?: string; + clientId?: string; + tagIds?: string[]; + isBillable?: boolean; + dateFrom?: string; + dateTo?: string; +} + +export interface SavedFilter { + id: string; + name: string; + criteria: FilterCriteria; + createdAt: string; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface Client { + id: string; + name: string; + shortCode?: string; + contactId?: string; + email?: string; + color: string; + isArchived: boolean; + billingRate?: BillingRate; + notes?: string; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + clientId?: string; + name: string; + description?: string; + color: string; + isArchived: boolean; + isBillable: boolean; + billingRate?: BillingRate; + budget?: ProjectBudget; + visibility: ProjectVisibility; + guildId?: string; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface TimeEntry { + id: string; + projectId?: string; + clientId?: string; + description: string; + date: string; + startTime?: string; + endTime?: string; + duration: number; + isBillable: boolean; + isRunning: boolean; + tags: string[]; + billingRate?: BillingRate; + visibility: ProjectVisibility; + guildId?: string; + source?: EntrySourceRef; + createdAt: string; + updatedAt: string; +} + +export interface Tag { + id: string; + name: string; + color: string; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface EntryTemplate { + id: string; + name: string; + projectId?: string; + clientId?: string; + description: string; + isBillable: boolean; + tags: string[]; + usageCount: number; + lastUsedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface TimesSettings { + id: string; + defaultBillingRate?: BillingRate; + workingHoursPerDay: number; + workingDaysPerWeek: number; + roundingIncrement: number; + roundingMethod: RoundingMethod; + defaultVisibility: ProjectVisibility; + weekStartsOn: 0 | 1; + timerReminderMinutes: number; + autoStopTimerHours: number; + createdAt: string; + updatedAt: string; +} + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalClient extends BaseRecord { + name: string; + shortCode?: string | null; + contactId?: string | null; + email?: string | null; + color: string; + isArchived: boolean; + billingRate?: BillingRate | null; + notes?: string | null; + order: number; +} + +export interface LocalProject extends BaseRecord { + clientId?: string | null; + name: string; + description?: string | null; + color: string; + isArchived: boolean; + isBillable: boolean; + billingRate?: BillingRate | null; + budget?: { + type: 'hours' | 'fixed'; + amount: number; + currency?: string; + } | null; + visibility: ProjectVisibility; + guildId?: string | null; + order: number; +} + +export interface LocalTimeEntry extends BaseRecord { + projectId?: string | null; + clientId?: string | null; + description: string; + date: string; + startTime?: string | null; + endTime?: string | null; + duration: number; + isBillable: boolean; + isRunning: boolean; + tags: string[]; + billingRate?: BillingRate | null; + visibility: ProjectVisibility; + guildId?: string | null; + source?: EntrySourceRef | null; +} + +export interface LocalTag extends BaseRecord { + name: string; + color: string; + order: number; +} + +export interface LocalTemplate extends BaseRecord { + name: string; + projectId?: string | null; + clientId?: string | null; + description: string; + isBillable: boolean; + tags: string[]; + usageCount: number; + lastUsedAt?: string | null; +} + +export interface LocalSettings extends BaseRecord { + defaultBillingRate?: BillingRate | null; + workingHoursPerDay: number; + workingDaysPerWeek: number; + roundingIncrement: number; + roundingMethod: 'none' | 'up' | 'down' | 'nearest'; + defaultVisibility: ProjectVisibility; + weekStartsOn: 0 | 1; + timerReminderMinutes: number; + autoStopTimerHours: number; +} + +// ─── Constants ──────────────────────────────────────────── + +export const PROJECT_COLORS: string[] = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#eab308', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#0ea5e9', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#d946ef', + '#ec4899', + '#f43f5e', +]; diff --git a/apps/manacore/apps/web/src/lib/modules/times/utils/entry-parser.ts b/apps/manacore/apps/web/src/lib/modules/times/utils/entry-parser.ts new file mode 100644 index 000000000..e18210c91 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/utils/entry-parser.ts @@ -0,0 +1,316 @@ +/** + * Time Entry Parser for Times module + * + * Parses natural language time tracking input: + * - Duration: 2h, 30min, 1.5h, 1h30m + * - Project: @ProjectName + * - Tags: #tag1 #tag2 + * - Billable: $, billable, abrechenbar + * - Date: heute, morgen, gestern, montag (via shared base parser) + * - Time range: 9-12, 14:00-16:30 + * + * Examples: + * - "Meeting 2h @ClientX #billable" + * - "Code Review 1.5h @Projekt-A" + * - "9-12 Workshop @Schulung; 13-15 Nachbereitung" + */ + +import { + parseBaseInput, + extractAtReference, + extractTags, + combineDateAndTime, + formatDatePreview, + type ParserLocale, +} from '@manacore/shared-utils'; + +export interface ParsedEntry { + description: string; + duration?: number; // seconds + date?: Date; + startTime?: string; // HH:mm + endTime?: string; // HH:mm + projectName?: string; + tagNames: string[]; + isBillable?: boolean; +} + +interface Project { + id: string; + name: string; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedEntryWithIds { + description: string; + duration?: number; + date?: string; // ISO + startTime?: string; + endTime?: string; + projectId?: string; + tagIds: string[]; + isBillable?: boolean; +} + +// ─── Duration Extraction ─────────────────────────────────── + +const DURATION_PATTERNS: { pattern: RegExp; getSeconds: (m: RegExpMatchArray) => number }[] = [ + // 2h30m, 1h 30min + { + pattern: /\b(\d+)\s*h\s*(\d+)\s*(?:m(?:in)?)\b/i, + getSeconds: (m) => parseInt(m[1]) * 3600 + parseInt(m[2]) * 60, + }, + // 1.5h, 2,5h + { + pattern: /\b(\d+(?:[.,]\d+)?)\s*h\b/i, + getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600), + }, + // 30min, 45 Minuten + { + pattern: /\b(\d+)\s*min(?:uten?)?\b/i, + getSeconds: (m) => parseInt(m[1]) * 60, + }, + // 1.5 Stunden + { + pattern: /\b(\d+(?:[.,]\d+)?)\s*(?:stunden?)\b/i, + getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600), + }, +]; + +function extractDuration(text: string): { duration?: number; remaining: string } { + for (const { pattern, getSeconds } of DURATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + const seconds = getSeconds(match); + if (seconds > 0) { + return { + duration: seconds, + remaining: text + .replace(match[0], '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + } + return { remaining: text }; +} + +// ─── Time Range Extraction ───────────────────────────────── + +const TIME_RANGE_PATTERN = + /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*[-–]\s*(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i; + +function extractTimeRange(text: string): { + startTime?: string; + endTime?: string; + duration?: number; + remaining: string; +} { + const match = text.match(TIME_RANGE_PATTERN); + if (match) { + const sh = parseInt(match[1]); + const sm = match[2] ? parseInt(match[2]) : 0; + const eh = parseInt(match[3]); + const em = match[4] ? parseInt(match[4]) : 0; + + if (sh >= 0 && sh <= 23 && eh >= 0 && eh <= 23) { + const startMinutes = sh * 60 + sm; + const endMinutes = eh * 60 + em; + const durationSeconds = (endMinutes - startMinutes) * 60; + + return { + startTime: `${String(sh).padStart(2, '0')}:${String(sm).padStart(2, '0')}`, + endTime: `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`, + duration: durationSeconds > 0 ? durationSeconds : undefined, + remaining: text.replace(TIME_RANGE_PATTERN, '').trim(), + }; + } + } + return { remaining: text }; +} + +// ─── Billable Detection ──────────────────────────────────── + +const BILLABLE_PATTERNS = [/\$/, /\bbillable\b/i, /\babrechenbar\b/i]; + +function extractBillable(text: string): { isBillable?: boolean; remaining: string } { + for (const pattern of BILLABLE_PATTERNS) { + if (pattern.test(text)) { + return { + isBillable: true, + remaining: text + .replace(pattern, '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + return { remaining: text }; +} + +// ─── Multi-Entry Splitting ───────────────────────────────── + +const ENTRY_SPLITTERS = + /\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend)\s+)/i; + +// ─── Main Parser ─────────────────────────────────────────── + +export function parseEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry { + let text = input.trim(); + + // Extract billable flag + const billableResult = extractBillable(text); + text = billableResult.remaining; + const isBillable = billableResult.isBillable; + + // Extract time range (before duration, since "9-12" could conflict) + const timeRangeResult = extractTimeRange(text); + text = timeRangeResult.remaining; + + // Extract duration (if no time range gave us one) + let duration = timeRangeResult.duration; + let startTime = timeRangeResult.startTime; + let endTime = timeRangeResult.endTime; + + if (!duration) { + const durationResult = extractDuration(text); + text = durationResult.remaining; + duration = durationResult.duration; + } + + // Extract @project + const projectResult = extractAtReference(text); + text = projectResult.remaining; + const projectName = projectResult.value; + + // Extract #tags + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + // Use base parser for date extraction + const base = parseBaseInput(text, locale); + const date = base.date ? combineDateAndTime(base.date, undefined) : undefined; + + return { + description: base.title, + duration, + date, + startTime, + endTime, + projectName, + tagNames, + isBillable, + }; +} + +/** + * Parse input with multiple entries separated by keywords/semicolons. + * Subsequent entries inherit date and project from the first. + */ +export function parseMultiEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry[] { + const parts = input.split(ENTRY_SPLITTERS).filter((s) => s.trim().length > 0); + + if (parts.length <= 1) { + return [parseEntryInput(input, locale)]; + } + + const results: ParsedEntry[] = []; + let contextDate: Date | undefined; + let contextProject: string | undefined; + + for (let i = 0; i < parts.length; i++) { + const parsed = parseEntryInput(parts[i].trim(), locale); + + if (i === 0) { + contextDate = parsed.date; + contextProject = parsed.projectName; + } else { + if (!parsed.date && contextDate) parsed.date = contextDate; + if (!parsed.projectName && contextProject) parsed.projectName = contextProject; + } + + results.push(parsed); + } + + return results; +} + +// ─── ID Resolution ───────────────────────────────────────── + +export function resolveEntryIds( + parsed: ParsedEntry, + projects: Project[], + tags: Tag[] +): ParsedEntryWithIds { + let projectId: string | undefined; + const tagIds: string[] = []; + + if (parsed.projectName) { + const project = projects.find( + (p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase() + ); + if (project) projectId = project.id; + } + + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) tagIds.push(tag.id); + } + + return { + description: parsed.description, + duration: parsed.duration, + date: parsed.date?.toISOString(), + startTime: parsed.startTime, + endTime: parsed.endTime, + projectId, + tagIds, + isBillable: parsed.isBillable, + }; +} + +// ─── Preview Formatting ──────────────────────────────────── + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0 && m > 0) return `${h}h ${m}min`; + if (h > 0) return `${h}h`; + return `${m}min`; +} + +export function formatParsedEntryPreview(parsed: ParsedEntry): string { + const parts: string[] = []; + + if (parsed.date) { + parts.push(formatDatePreview(parsed.date)); + } + + if (parsed.startTime && parsed.endTime) { + parts.push(`${parsed.startTime}--${parsed.endTime}`); + } + + if (parsed.duration) { + parts.push(formatDuration(parsed.duration)); + } + + if (parsed.projectName) { + parts.push(parsed.projectName); + } + + if (parsed.isBillable) { + parts.push('$'); + } + + if (parsed.tagNames.length > 0) { + parts.push(parsed.tagNames.join(', ')); + } + + return parts.join(' · '); +} diff --git a/apps/manacore/apps/web/src/lib/modules/times/utils/export.ts b/apps/manacore/apps/web/src/lib/modules/times/utils/export.ts new file mode 100644 index 000000000..30a144ab0 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/utils/export.ts @@ -0,0 +1,61 @@ +/** + * CSV Export utility for time entries + */ + +import type { TimeEntry, Project, Client } from '$lib/modules/times/types'; + +export function exportEntriesToCSV( + entries: TimeEntry[], + projects: Project[], + clients: Client[] +): void { + const projectMap = new Map(projects.map((p) => [p.id, p])); + const clientMap = new Map(clients.map((c) => [c.id, c])); + + const headers = [ + 'Datum', + 'Beschreibung', + 'Projekt', + 'Kunde', + 'Dauer (h)', + 'Dauer (min)', + 'Abrechenbar', + 'Tags', + 'Startzeit', + 'Endzeit', + ]; + + const rows = entries.map((e) => { + const project = e.projectId ? projectMap.get(e.projectId) : undefined; + const client = e.clientId ? clientMap.get(e.clientId) : undefined; + const hours = Math.floor(e.duration / 3600); + const minutes = Math.floor((e.duration % 3600) / 60); + + return [ + e.date, + `"${(e.description || '').replace(/"/g, '""')}"`, + `"${(project?.name || '').replace(/"/g, '""')}"`, + `"${(client?.name || '').replace(/"/g, '""')}"`, + hours.toString(), + (hours * 60 + minutes).toString(), + e.isBillable ? 'Ja' : 'Nein', + `"${e.tags.join(', ')}"`, + e.startTime + ? new Date(e.startTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + : '', + e.endTime + ? new Date(e.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + : '', + ]; + }); + + const csv = [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n'); + const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility + const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/manacore/apps/web/src/lib/modules/times/utils/rounding.ts b/apps/manacore/apps/web/src/lib/modules/times/utils/rounding.ts new file mode 100644 index 000000000..b7988ef08 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/utils/rounding.ts @@ -0,0 +1,36 @@ +/** + * Duration rounding utility + * + * Applies rounding based on user settings (increment + method). + */ + +import type { RoundingMethod } from '$lib/modules/times/types'; + +/** + * Round a duration in seconds based on settings. + * @param seconds - Duration in seconds + * @param increment - Rounding increment in minutes (0 = no rounding) + * @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest' + * @returns Rounded duration in seconds + */ +export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number { + if (increment <= 0 || method === 'none') return seconds; + + const incrementSeconds = increment * 60; + const remainder = seconds % incrementSeconds; + + if (remainder === 0) return seconds; + + switch (method) { + case 'up': + return seconds - remainder + incrementSeconds; + case 'down': + return seconds - remainder; + case 'nearest': + return remainder >= incrementSeconds / 2 + ? seconds - remainder + incrementSeconds + : seconds - remainder; + default: + return seconds; + } +} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/+page.svelte new file mode 100644 index 000000000..2f895eefb --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/+page.svelte @@ -0,0 +1,169 @@ + + + + {$_('app.name')} - {$_('app.tagline')} + + +
+
+

{$_('cities.title')}

+

{$_('cities.subtitle')}

+
+ {#if authStore.isAuthenticated} + + + + {/if} +
+ + +{#if platformStats.totalCities > 0} +
+
+ 🏙️ +
+

{platformStats.totalCities}

+

{$_('nav.cities')}

+
+
+
+ 📍 +
+

{platformStats.totalLocations}

+

{$_('home.title')}

+
+
+ {#if platformStats.totalContributors > 0} +
+ 👥 +
+

{platformStats.totalContributors}

+

+ {$_('cities.totalContributors', { + values: { count: platformStats.totalContributors }, + })} +

+
+
+ {/if} +
+{/if} + + +
+ +
+ +{#if filtered.length === 0} +
+ 🏙️ +

{$_('cities.empty')}

+ {#if authStore.isAuthenticated} + + {$_('cities.add')} + + {/if} +
+{:else} + +{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte new file mode 100644 index 000000000..df01f9a43 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte @@ -0,0 +1,254 @@ + + + + {$_('cityAdd.title')} - CityCorners + + +
+
+ + + +

{$_('cityAdd.title')}

+
+

{$_('cityAdd.subtitle')}

+
+ +{#if !authStore.isAuthenticated} +
+ 🏙️ +

{$_('cityAdd.loginRequired')}

+ + {$_('settings.login')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + + {#if slug && slugExists} +

{$_('cityAdd.slugExists')}

+ {:else if slug} +

/{slug}

+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + (imageError = false)} + placeholder={$_('cityAdd.imageUrlPlaceholder')} + class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> + {#if imageUrl.trim() && !imageError} +
+ Preview (imageError = true)} + /> +
+ {/if} +
+ + {#if geocoding} +

{$_('cityAdd.geocoding')}

+ {:else if latitude !== undefined && longitude !== undefined} +

{$_('cityAdd.coordinatesFound')}

+ {/if} + +
+ + {$_('edit.cancel')} + + +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/add/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/add/+page.svelte new file mode 100644 index 000000000..d7dafe339 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/add/+page.svelte @@ -0,0 +1,9 @@ + diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte new file mode 100644 index 000000000..10f9e72ba --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+layout.svelte @@ -0,0 +1,37 @@ + + +{#if currentCity} + {@render children()} +{:else if allCities.value.length > 0} +
+ 🔍 +

Stadt nicht gefunden.

+ + Zurück zu allen Städten + +
+{:else} + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte new file mode 100644 index 000000000..5a7ec9366 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte @@ -0,0 +1,250 @@ + + + + {city?.name || ''} - CityCorners + + +
+
+
+ + + +

{city?.name}

+
+

+ {#if city?.state} + {city.state}, {city.country} + {:else} + {city?.country} + {/if} +

+ {#if city?.description} +

{city.description}

+ {/if} +
+ + + +
+ + +{#if stats.locationCount > 0} +
+
+ +
+
+ +
+
+

{stats.locationCount}

+

+ {$_('cities.locationsCount', { values: { count: stats.locationCount } })} +

+
+
+ + + {#if stats.hasCoordinates > 0} +
+
+ +
+
+

{stats.hasCoordinates}

+

+ {$_('cities.onMap', { values: { count: stats.hasCoordinates } })} +

+
+
+ {/if} + + + {#if stats.contributorCount > 0} +
+
+ +
+
+

{stats.contributorCount}

+

+ {$_('cities.contributors', { values: { count: stats.contributorCount } })} +

+
+
+ {/if} +
+ + + {#if stats.topCategories.length > 1} +
+ {#each stats.topCategories as { category, count }} + + {$_(`categories.${category}`)} + {count} + + {/each} +
+ {/if} +
+{/if} + + +
+ + {#each CATEGORY_KEYS as cat} + {@const count = categoryCounts[cat] || 0} + {#if count > 0} + + {/if} + {/each} +
+ +{#if filtered.length === 0} +
+ 📍 +

+ {#if selectedCategory} + {$_('home.noResultsCategory', { + values: { category: $_(`categories.${selectedCategory}`) }, + })} + {:else} + {$_('home.noResults')} + {/if} +

+ + {$_('home.addFirst')} + +
+{:else} + +{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte new file mode 100644 index 000000000..af6072293 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte @@ -0,0 +1,304 @@ + + + + {$_('add.title')} - {city?.name || 'CityCorners'} + + +
+
+ + + +

{$_('add.title')}

+
+

{$_('add.subtitle')} — {city?.name}

+
+ +{#if !authStore.isAuthenticated} +
+ 📍 +

{$_('add.loginRequired')}

+ + {$_('settings.login')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + +
+ +
+ +
+ {#each categories as cat} + + {/each} +
+
+ +
+ + +

{$_('add.minChars')}

+
+ +
+ + + {#if geocoding} +

{$_('add.geocoding')}

+ {:else if latitude !== undefined && longitude !== undefined} +

+ {$_('add.coordinatesFound')} +

+ {/if} +
+ +
+ + (imageError = false)} + placeholder={$_('add.imageUrlPlaceholder')} + class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> + {#if imageUrl.trim() && !imageError} +
+ {$_('add.imagePreview')} (imageError = true)} + /> +
+ {:else if imageError} +
+

{$_('add.imageLoadError')}

+ +
+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + {$_('edit.cancel')} + + +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte new file mode 100644 index 000000000..ec78c2207 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte @@ -0,0 +1,530 @@ + + + + {location?.name || 'Location'} - {city?.name || 'CityCorners'} + + + +{#if loading} +
+
+
+{:else if !location} +
+ 🔍 +

{$_('detail.notFound')}

+ {$_('detail.back')} +
+{:else} + {@const images = allImages()} + + +
+ {#if images.length > 0} + {location.name} + {:else} +
+ 📍 +
+ {/if} + + +
+ + + +
+ + +
+ + + {#if authStore.isAuthenticated} + + {/if} +
+ + {#if images.length > 1} +
+ {selectedImageIndex + 1} / {images.length} +
+ {/if} + +
+ + {$_(`category.${location.category}`)} + + {#if isOpenNow(location.openingHours) === true} + + {$_('detail.openNow')} + + {:else if isOpenNow(location.openingHours) === false} + + {$_('detail.closedNow')} + + {/if} +
+
+ + +
+
+

{location.name}

+ {#if location.address} +

+ + {location.address} +

+ {/if} +
+ +

{location.description}

+ + + {#if location.website || location.phone} +
+ {#if location.website} + + {/if} + {#if location.phone} +
+ {$_('detail.phone')}: + + {location.phone} + +
+ {/if} +
+ {/if} + + + {#if location.openingHours && Object.keys(location.openingHours).length > 0} +
+

{$_('detail.openingHours')}

+
+ + + {#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day} + {#if location.openingHours[day]} + + + + + {/if} + {/each} + +
{$_(`days.${day}`)} + {location.openingHours[day] === 'closed' + ? $_('detail.closed') + : location.openingHours[day]} +
+
+
+ {/if} + + + {#if isOwner} +
+ + + {$_('detail.edit')} + + +
+ {/if} + + + {#if showDeleteConfirm} +
+

{$_('detail.deleteConfirm')}

+
+ + +
+
+ {/if} + + + {#if location.latitude && location.longitude} + + {/if} + + + {#if location.timeline && location.timeline.length > 0} +
+

{$_('detail.history')}

+
+ {#each location.timeline as entry, i} +
+ {#if i < location.timeline!.length - 1} +
+ {/if} +
+
+
+
+ {entry.year} +

{entry.event}

+
+
+ {/each} +
+
+ {/if} + + + {#if nearbyLocations.length > 0} + + {/if} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte new file mode 100644 index 000000000..dedf11632 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte @@ -0,0 +1,269 @@ + + + + {$_('edit.title')} - {city?.name || 'CityCorners'} + + +
+
+ + + +

{$_('edit.title')}

+
+

{$_('edit.subtitle')}

+
+ +{#if loading} +
+
+
+{:else if forbidden} +
+ 🔒 +

{$_('edit.forbidden')}

+ + {$_('detail.back')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + +
+ +
+ +
+ {#each categories as cat} + + {/each} +
+
+ +
+ + +

{$_('add.minChars')}

+
+ +
+ + +
+ +
+ + (imageError = false)} + placeholder={$_('add.imageUrlPlaceholder')} + class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> + {#if imageUrl.trim() && !imageError} +
+ {$_('add.imagePreview')} (imageError = true)} + /> +
+ {:else if imageError} +
+

{$_('add.imageLoadError')}

+ +
+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + {$_('edit.cancel')} + + +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte new file mode 100644 index 000000000..55b1147b3 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte @@ -0,0 +1,255 @@ + + + + {$_('map.title')} - {city?.name || 'CityCorners'} + + + + + +
+
+
+
+ + + +

{$_('map.title')}

+
+

{city?.name} - {$_('map.subtitle')}

+
+ +
+ + {#if locationError} +
{locationError}
+ {/if} + +
+ + {#each CATEGORY_KEYS as cat} + + {/each} +
+ +
+
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte new file mode 100644 index 000000000..f98b541ea --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte @@ -0,0 +1,86 @@ + + + + {$_('favorites.title')} - CityCorners + + +
+

{$_('favorites.title')}

+

{$_('favorites.subtitle')}

+
+ +{#if !authStore.isAuthenticated} +
+

{$_('favorites.loginRequired')}

+ + {$_('settings.login')} + +
+{:else if favoriteLocations.length === 0} +
+ 💙 +

{$_('favorites.empty')}

+
+{:else} + +{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte new file mode 100644 index 000000000..6731d1b82 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/+page.svelte @@ -0,0 +1,10 @@ + diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte new file mode 100644 index 000000000..a70fb30b0 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/locations/[id]/edit/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/apps/manacore/apps/web/src/routes/(app)/citycorners/map/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/citycorners/map/+page.svelte new file mode 100644 index 000000000..d7dafe339 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/citycorners/map/+page.svelte @@ -0,0 +1,9 @@ + diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/+layout.svelte new file mode 100644 index 000000000..b1f9ee25a --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/+layout.svelte @@ -0,0 +1,30 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/+page.svelte new file mode 100644 index 000000000..98c903117 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/+page.svelte @@ -0,0 +1,133 @@ + + + + Inventar - ManaCore + + +
+ +
+
+

Sammlungen

+

+ {totalCollections} Sammlungen · {totalItems} Items +

+
+ + + Neue Sammlung + +
+ + + {#if sortedCollections.length === 0} +
+ 📦 +

Keine Sammlungen

+

+ Erstelle deine erste Sammlung, um loszulegen. +

+ + Neue Sammlung + +
+ {:else} +
+ {#each sortedCollections as collection (collection.id)} +
handleCollectionClick(collection)} + onkeydown={(e) => e.key === 'Enter' && handleCollectionClick(collection)} + class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]" + > +
+
+ {collection.icon || '📁'} +
+

{collection.name}

+ {#if collection.description} +

+ {collection.description} +

+ {/if} +
+
+ +
+ +
+ + {getItemCount(collection.id)} Items + +
+ {#each collection.schema.fields.slice(0, 3) as field} + + {field.name} + + {/each} + {#if collection.schema.fields.length > 3} + + +{collection.schema.fields.length - 3} + + {/if} +
+
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/categories/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/categories/+page.svelte new file mode 100644 index 000000000..1c709533a --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/categories/+page.svelte @@ -0,0 +1,145 @@ + + + + Kategorien - Inventar - ManaCore + + +
+
+

Kategorien

+ +
+ + {#if showForm} +
+
+ + e.key === 'Enter' && save()} + /> + + + +
+
+ {/if} + + {#if sortedCategories.length === 0} +
+ 🏷️ +

Keine Kategorien vorhanden

+
+ {:else} +
+ {#each sortedCategories as category (category.id)} +
+ {category.icon || '🏷️'} + {#if category.color} + + {/if} + {category.name} + + +
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/+page.svelte new file mode 100644 index 000000000..93f968ecd --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/+page.svelte @@ -0,0 +1,292 @@ + + + + {collection?.name || 'Sammlung'} - Inventar - ManaCore + + +{#if !collection} +
+

Sammlung nicht gefunden

+ Zuruck +
+{:else} +
+ +
+
+ + + + {collection.icon || '📁'} +
+

{collection.name}

+ {#if collection.description} +

{collection.description}

+ {/if} +
+
+
+ viewStore.setViewMode(m)} /> + + + + +
+
+ + + {#if showNewItem} +
+ e.key === 'Enter' && createItem()} + /> + + + {#if collection.schema.fields.length > 0} +
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field} +
+ + (newItemFields = { ...newItemFields, [field.id]: v })} + /> +
+ {/each} +
+ {/if} + +
+ + +
+
+ {/if} + + + {#if sortedItems.length === 0} +
+ 📭 +

Keine Items vorhanden

+ +
+ {:else if viewStore.viewMode === 'grid'} + +
+ {#each sortedItems as item (item.id)} +
goto(`/inventar/items/${item.id}`)} + onkeydown={(e) => e.key === 'Enter' && goto(`/inventar/items/${item.id}`)} + role="button" + tabindex="0" + class="group cursor-pointer rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left" + > +
+

{item.name}

+ +
+ {#if collection.schema.fields.length > 0} +
+ {#each collection.schema.fields.slice(0, 3) as field} + {#if item.fieldValues[field.id] !== undefined} +
+ {field.name}: + +
+ {/if} + {/each} +
+ {/if} + +
+ {/each} +
+ {:else if viewStore.viewMode === 'table'} + +
+ + + + + + {#each collection.schema.fields as field} + + {/each} + + + + + {#each sortedItems as item (item.id)} + goto(`/inventar/items/${item.id}`)} + > + + + {#each collection.schema.fields as field} + + {/each} + + + {/each} + +
NameStatus{field.name}
{item.name} + +
+
+ {:else} + +
+ {#each sortedItems as item (item.id)} +
goto(`/inventar/items/${item.id}`)} + onkeydown={(e) => e.key === 'Enter' && goto(`/inventar/items/${item.id}`)} + role="button" + tabindex="0" + class="group flex w-full cursor-pointer items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]" + > +
+
+

{item.name}

+ +
+ {#if collection.schema.fields.length > 0} +
+ {#each collection.schema.fields.slice(0, 4) as field} + {#if item.fieldValues[field.id] !== undefined} + {field.name}: + {/if} + {/each} +
+ {/if} +
+ {#if item.quantity > 1} + ×{item.quantity} + {/if} + +
+ {/each} +
+ {/if} +
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/edit/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/edit/+page.svelte new file mode 100644 index 000000000..85e1f6373 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/[id]/edit/+page.svelte @@ -0,0 +1,112 @@ + + + + Sammlung bearbeiten - Inventar - ManaCore + + +{#if !collection} +

Sammlung nicht gefunden

+{:else} +
+
+ +

Sammlung bearbeiten

+
+ +
+
+ + +
+ + + +
+

Eigene Felder

+ (schema = { fields })} /> +
+ +
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/collections/new/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/new/+page.svelte new file mode 100644 index 000000000..a50c966ac --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/collections/new/+page.svelte @@ -0,0 +1,145 @@ + + + + Neue Sammlung - Inventar - ManaCore + + +
+
+ +

Neue Sammlung

+
+ + {#if step === 'template'} + +
+

Vorlage wahlen

+
+ {#each DEFAULT_TEMPLATES as template} + + {/each} +
+
+ {:else} + +
+
+
+ +
+ +
+ + + + +
+

Eigene Felder

+ (schema = { fields })} /> +
+ + +
+ + +
+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/items/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/items/[id]/+page.svelte new file mode 100644 index 000000000..85c601109 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/items/[id]/+page.svelte @@ -0,0 +1,327 @@ + + + + {item?.name || 'Item'} - Inventar - ManaCore + + +{#if !item} +
+

Item nicht gefunden

+ Zuruck +
+{:else} +
+ +
+
+ + {#if !editing} +
+

{item.name}

+ {#if collection} +

+ {collection.icon} + {collection.name} +

+ {/if} +
+ {/if} +
+
+ {#if editing} + + + {:else} + + + {/if} +
+
+ + {#if editing} + +
+ + + +
+
+ + +
+
+ + +
+ {#if locationsCtx.value.length > 0} +
+ + +
+ {/if} + {#if categoriesCtx.value.length > 0} +
+ + +
+ {/if} +
+ + {#if collection} +
+

Eigene Felder

+
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field} +
+ + (editFields = { ...editFields, [field.id]: v })} + /> +
+ {/each} +
+
+ {/if} +
+ {:else} + +
+ +
+ + {#if item.quantity > 1} + ×{item.quantity} + {/if} + {#if item.locationId} + {@const loc = getLocationById(locationsCtx.value, item.locationId)} + {#if loc} + + 📍 {getLocationFullPath(locationsCtx.value, loc.id)} + + {/if} + {/if} + {#if item.categoryId} + {@const cat = getCategoryById(categoriesCtx.value, item.categoryId)} + {#if cat} + {cat.icon || '🏷️'} {cat.name} + {/if} + {/if} +
+ + {#if item.description} +

{item.description}

+ {/if} + + + {#if collection && collection.schema.fields.length > 0} +
+

Details

+
+ {#each collection.schema.fields.sort((a, b) => a.order - b.order) as field} +
+ {field.name}: + +
+ {/each} +
+
+ {/if} + + +
+

+ Notizen ({item.notes.length}) +

+
+ {#each item.notes as note (note.id)} +
+
+

{note.content}

+

+ {new Date(note.createdAt).toLocaleDateString('de-DE')} +

+
+ +
+ {/each} +
+
+ e.key === 'Enter' && addNote()} + /> + +
+
+
+ {/if} +
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/locations/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/locations/+page.svelte new file mode 100644 index 000000000..60aa4e0fb --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/locations/+page.svelte @@ -0,0 +1,155 @@ + + + + Standorte - Inventar - ManaCore + + +
+
+

Standorte

+ +
+ + {#if showForm} +
+

+ {editingId ? 'Standort bearbeiten' : 'Neuer Standort'} +

+
+ + e.key === 'Enter' && save()} + /> + + +
+
+ {/if} + + {#if tree.length === 0} +
+ 📍 +

Keine Standorte vorhanden

+
+ {:else} +
+ {#snippet renderTree(locations: Location[], depth: number)} + {#each locations as location (location.id)} +
+
+ {location.icon || '📍'} + {location.name} + + + +
+ {#if location.children?.length} + {@render renderTree(location.children, depth + 1)} + {/if} +
+ {/each} + {/snippet} + {@render renderTree(tree, 0)} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/inventar/search/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/inventar/search/+page.svelte new file mode 100644 index 000000000..af262059c --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/inventar/search/+page.svelte @@ -0,0 +1,62 @@ + + + + Suche - Inventar - ManaCore + + +
+

Suche

+ +
+ + +
+ + {#if query.length >= 2} +

{results.length} Ergebnisse

+
+ {#each results as item (item.id)} + + {/each} +
+ {:else if query.length > 0} +

Mindestens 2 Zeichen eingeben...

+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/+layout.svelte new file mode 100644 index 000000000..8ba64c5c5 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/+page.svelte new file mode 100644 index 000000000..ba516dceb --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/+page.svelte @@ -0,0 +1,118 @@ + + + + Gallery | Photos - ManaCore + + + + + diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/albums/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/albums/+page.svelte new file mode 100644 index 000000000..04dc08957 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/albums/+page.svelte @@ -0,0 +1,83 @@ + + + + Albums | Photos - ManaCore + + +
+ + + {#if albums.length === 0} +
+ +

No albums yet

+

Create an album to organize your photos.

+ +
+ {:else} + + {/if} + + {#if showCreateModal} + (showCreateModal = false)} onCreate={handleCreateAlbum} /> + {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/albums/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/albums/[id]/+page.svelte new file mode 100644 index 000000000..7b08efe32 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/albums/[id]/+page.svelte @@ -0,0 +1,111 @@ + + + + {currentAlbum?.name || 'Album'} | Photos - ManaCore + + +
+ {#if !currentAlbum} +
+
Loading...
+
+ {:else} + + + {#if albumPhotos.length === 0} +
+

No photos in this album yet.

+
+ {:else} + {}} + /> + {/if} + {/if} + + {#if photoStore.selectedPhoto} + + {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/favorites/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/favorites/+page.svelte new file mode 100644 index 000000000..777756ef7 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/favorites/+page.svelte @@ -0,0 +1,80 @@ + + + + Favorites | Photos - ManaCore + + +
+ + + {#if favorites.length === 0} +
+ +

No favorites yet

+

Heart a photo to add it to your favorites.

+
+ {:else} + {}} + /> + {/if} + + {#if photoStore.selectedPhoto} + + {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/photos/upload/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/photos/upload/+page.svelte new file mode 100644 index 000000000..118755b49 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/photos/upload/+page.svelte @@ -0,0 +1,294 @@ + + + + Upload | Photos - ManaCore + + +
+ + + + + {#if files.length > 0} +
+
+ + {files.length} + {files.length === 1 ? 'file' : 'files'} + +
+ {#if files.some((f) => f.status === 'success')} + + {/if} + +
+
+ +
+ {#each files as file, index} +
+ +
+ {#if file.status === 'pending'} + + {:else if file.status === 'uploading'} +
+ + + + +
+ {:else if file.status === 'success'} +
+ + + +
+ {:else if file.status === 'error'} +
+ +
+ {/if} +
+
{file.file.name}
+
+ {/each} +
+
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/planta/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/planta/+page.svelte new file mode 100644 index 000000000..6ceaedf01 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/planta/+page.svelte @@ -0,0 +1,135 @@ + + + + Meine Pflanzen - Planta + + +
+
+

Meine Pflanzen

+ + Pflanze hinzufuegen +
+ + {#if plants.length === 0} +
+
🌱
+

Noch keine Pflanzen

+

+ Fuege deine erste Pflanze hinzu und lass sie von der KI analysieren. +

+ Erste Pflanze hinzufuegen +
+ {:else} +
+ {#each plants as plant (plant.id)} + {@const primaryPhoto = getPrimaryPhoto(allPlantPhotos.value, plant.id)} + + {/if} + + {/each} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/planta/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/planta/[id]/+page.svelte new file mode 100644 index 000000000..da99f6424 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/planta/[id]/+page.svelte @@ -0,0 +1,221 @@ + + + + {plant?.name || 'Pflanze'} - Planta + + +{#if !plant} +
+

Pflanze nicht gefunden

+ Zurueck zur Uebersicht +
+{:else} +
+ +
+
+

{plant.name}

+ {#if plant.scientificName} +

{plant.scientificName}

+ {/if} + {#if plant.commonName && plant.commonName !== plant.name} +

{plant.commonName}

+ {/if} +
+ + {getHealthText(plant.healthStatus)} + +
+ + + {#if photos.length > 0} +
+ {#each photos as photo (photo.id)} + {plant.name} + {/each} +
+ {/if} + + +
+

Pflege

+
+
+

Licht

+

☀️ {getLightText(plant.lightRequirements)}

+
+
+

Giessen

+

+ 💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'} +

+
+
+

Luftfeuchtigkeit

+

💨 {getHumidityText(plant.humidity)}

+
+
+

Temperatur

+

🌡️ {plant.temperature || '-'}

+
+
+ {#if plant.careNotes} +
+

Pflegehinweise

+

{plant.careNotes}

+
+ {/if} +
+ + +
+
+

Giessplan

+ +
+ + {#if wateringSchedule} +
+
+

Zuletzt gegossen

+

{formatDate(wateringSchedule.lastWateredAt)}

+
+
+

Naechstes Giessen

+

{formatDate(wateringSchedule.nextWateringAt)}

+
+
+ {/if} + + {#if wateringHistory.length > 0} +
+

Letzte Giessvorgaenge

+
    + {#each wateringHistory.slice(0, 5) as log (log.id)} +
  • + 💧 Gegossen + {formatDate(log.wateredAt)} +
  • + {/each} +
+
+ {/if} +
+ + +
+ Zurueck + +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/planta/add/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/planta/add/+page.svelte new file mode 100644 index 000000000..696a175f8 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/planta/add/+page.svelte @@ -0,0 +1,103 @@ + + + + Pflanze hinzufuegen - Planta + + +
+

Pflanze hinzufuegen

+ + {#if error} +
+ {error} +
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
diff --git a/apps/manacore/apps/web/src/routes/(app)/planta/tags/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/planta/tags/+page.svelte new file mode 100644 index 000000000..3e94fba25 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/planta/tags/+page.svelte @@ -0,0 +1,43 @@ + + + + Tags | Planta + + +
+

Tags verwalten

+

+ Tags sind app-uebergreifend -- Aenderungen gelten in allen ManaCore-Apps. +

+ + {#if tagsCtx.value.length === 0} +

Keine Tags vorhanden.

+ {:else} +
+ {#each tagsCtx.value as tag} +
+ + {tag.name} +
+ {/each} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/skilltree/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/skilltree/+page.svelte new file mode 100644 index 000000000..5ae3e9272 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/skilltree/+page.svelte @@ -0,0 +1,403 @@ + + + + SkillTree + + +
+ +
+
+
+
+ +

SkillTree

+
+
+ + + + {#if achievementStats.unlocked > 0} + + {achievementStats.unlocked} + + {/if} + + + + + + + + + + + + +
+
+
+
+ +
+ + + + +
+
+ + {#each Object.entries(BRANCH_INFO) as [branch, info]} + {@const count = skills.filter((s) => s.branch === branch).length} + {#if count > 0 || branch !== 'custom'} + + {/if} + {/each} +
+
+ + + {#if filteredSkills.length === 0} +
+
+ +
+

Noch keine Skills

+

Füge deinen ersten Skill hinzu und beginne dein Abenteuer!

+ +
+ {:else} +
+ {#each filteredSkills as skill (skill.id)} + openAddXpModal(skill)} + onEdit={() => openEditModal(skill)} + onDelete={() => skillStore.deleteSkill(skill.id)} + /> + {/each} +
+ {/if} + + + {#if getRecentActivities(activities).length > 0} +
+

+ + Letzte Aktivitäten +

+
+ {#each getRecentActivities(activities).slice(0, 5) as activity} + {@const skill = getSkillById(skills, activity.skillId)} + {#if skill} +
+
+
+ +{activity.xpEarned} +
+
+ {skill.name} + - {activity.description} +
+
+ + {new Date(activity.timestamp).toLocaleDateString('de-DE')} + +
+ {/if} + {/each} +
+
+ {/if} +
+
+ + +{#if showAddSkillModal} + (showAddSkillModal = false)} + onSave={async (skill) => { + await skillStore.addSkill(skill); + showAddSkillModal = false; + await checkAchievementsLocal(); + }} + /> +{/if} + +{#if showAddXpModal && selectedSkill} + +{/if} + +{#if showEditSkillModal && selectedSkill} + { + if (selectedSkill) { + await skillStore.updateSkill(selectedSkill.id, updates); + } + }} + onDelete={() => { + if (selectedSkill) { + skillStore.deleteSkill(selectedSkill.id); + } + }} + /> +{/if} + +{#if showLevelUp} + (showLevelUp = false)} + /> +{/if} + +{#if showTemplatesModal} + (showTemplatesModal = false)} + onAddSkill={async (skill) => { + await skillStore.addSkill(skill); + await checkAchievementsLocal(); + }} + /> +{/if} + +{#if showAchievementCelebration && currentAchievementUnlock} + +{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/skilltree/achievements/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/skilltree/achievements/+page.svelte new file mode 100644 index 000000000..8d3f25727 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/skilltree/achievements/+page.svelte @@ -0,0 +1,156 @@ + + + + Achievements - SkillTree + + +
+ +
+
+
+
+ + + + +

Achievements

+
+ + +
+
+ + + {stats.unlocked} / {stats.total} + +
+
+
+
+
+ +
+ +
+
+

Fortschritt

+ {completion}% +
+
+
+
+
+ {#each Object.entries(RARITY_INFO) as [rarity, info]} + {@const count = achievements.filter((a) => a.rarity === rarity && a.unlocked).length} + {@const total = achievements.filter((a) => a.rarity === rarity).length} + + + {info.name}: {count}/{total} + + {/each} +
+
+ + +
+ + {#each categoryEntries as [category, info]} + {@const count = achievements.filter((a) => a.category === category).length} + + {/each} + +
+ +
+
+ + + {#if filteredAchievements().length === 0} +
+
+ +
+

Keine Achievements gefunden

+

+ {showOnlyUnlocked + ? 'Du hast in dieser Kategorie noch keine Achievements freigeschaltet.' + : 'Keine Achievements in dieser Kategorie.'} +

+
+ {:else} +
+ {#each filteredAchievements() as achievement (achievement.id)} + + {/each} +
+ {/if} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/skilltree/tree/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/skilltree/tree/+page.svelte new file mode 100644 index 000000000..4fadb5b8c --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/skilltree/tree/+page.svelte @@ -0,0 +1,273 @@ + + + + Skill Tree View - SkillTree + + +
+ +
+
+
+ + + Zurück + +

Skill Tree Visualisierung

+
+
+
+ +
+ {#if skills.length === 0} +
+

Noch keine Skills vorhanden. Erstelle zuerst einige Skills!

+ + Skills erstellen + +
+ {:else} + +
+ {#each Object.entries(BRANCH_INFO) as [branch, info]} + {@const count = skills.filter((s) => s.branch === branch).length} + {#if count > 0} +
+ + {info.name} ({count}) +
+ {/if} + {/each} +
+ + +
+ + + + + + + + + + + + YOU + + + + {#each branches as branch, i} + {@const pos = getBranchPosition(i, branches.length)} + {@const branchSkills = skills.filter((s) => s.branch === branch)} + {#if branchSkills.length > 0} + + + + + + {BRANCH_INFO[branch].name} + + + + {#each branchSkills as skill, j} + {@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)} + {@const size = getNodeSize(skill.level)} + + + + + + + + {#if skill.level >= 4} + + {/if} + + + + + + + {skill.level} + + + + {skill.name} (Level {skill.level} - {skill.totalXp} XP) + + + + + {skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name} + + {/each} + {/if} + {/each} + +
+ + +
+ {#each LEVEL_NAMES as name, level} +
+
+ {level} +
+ {name} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/times/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/+page.svelte new file mode 100644 index 000000000..6d085aff4 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/+page.svelte @@ -0,0 +1,77 @@ + + + + Timer | Times + + +
+ + + + +
+
+

{$_('common.total')}

+

+ {formatDurationCompact(todayTotal)} +

+
+
+

{$_('entry.billable')}

+

+ {formatDurationCompact(todayBillable)} +

+
+
+ + + + + +
+
+

+ {$_('entry.today')} ({formatDurationCompact(todayTotal)}) +

+ +
+ + +
+
+ + + (showEntryForm = false)} /> + (showEntryForm = true)} /> diff --git a/apps/manacore/apps/web/src/routes/(app)/times/clients/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/clients/+page.svelte new file mode 100644 index 000000000..cc057d1ae --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/clients/+page.svelte @@ -0,0 +1,357 @@ + + + + {$_('nav.clients')} | Times + + +
+
+

{$_('nav.clients')}

+ +
+ + {#if showCreateForm} +
{ + e.preventDefault(); + handleCreate(); + }} + class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3" + > +
+ + +
+
+ +
+ + /h +
+
+
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ + +
+
+ {/if} + + {#if activeClients.length === 0 && !showCreateForm} +
+

{$_('client.noClients')}

+
+ {:else} +
+ {#each activeClients as client (client.id)} + {@const projects = getClientProjects(client.id)} + {@const hours = getClientHours(client.id)} +
+ {#if editingClientId === client.id} +
+
+ { + editName = (e.target as HTMLInputElement).value; + autoSave({ name: editName }); + }} + class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm" + /> + { + editShortCode = (e.target as HTMLInputElement).value; + autoSave({ shortCode: editShortCode || null }); + }} + placeholder={$_('client.shortCode')} + class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm" + /> +
+
+ { + editEmail = (e.target as HTMLInputElement).value; + autoSave({ email: editEmail || null }); + }} + placeholder={$_('client.email')} + class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm" + /> +
+ { + editRate = parseInt((e.target as HTMLInputElement).value) || 0; + autoSave({ + billingRate: + editRate > 0 ? { amount: editRate, currency: 'EUR', per: 'hour' } : null, + }); + }} + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-center" + /> + /h +
+
+
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ + + +
+
+ {:else} + + {/if} +
+ {/each} +
+ {/if} + + {#if archivedClients.length > 0} +
+ + {#if showArchived} +
+ {#each archivedClients as client} +
+
+
+ {client.shortCode || client.name.charAt(0)} +
+ {client.name} +
+ +
+ {/each} +
+ {/if} +
+ {/if} +
+ + (deleteConfirmId = null)} +/> diff --git a/apps/manacore/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte new file mode 100644 index 000000000..526410c64 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte @@ -0,0 +1,153 @@ + + + + {client?.name || 'Kunde'} | Times + + +{#if !client} +
+

Kunde nicht gefunden.

+ {$_('common.back')} +
+{:else} +
+ +
+ + + {$_('nav.clients')} + + +
+
+ {client.shortCode || client.name.charAt(0).toUpperCase()} +
+
+

{client.name}

+

+ {#if client.shortCode}{client.shortCode} · + {/if} + {#if client.email}{client.email} · + {/if} + {#if client.billingRate} + {client.billingRate.amount} {client.billingRate.currency}/h + {/if} +

+
+
+
+ + +
+
+

{$_('report.totalHours')}

+

+ {formatDurationDecimal(totalDuration)}h +

+
+
+

{$_('report.billableHours')}

+

+ {formatDurationDecimal(billableDuration)}h +

+
+
+

{$_('nav.projects')}

+

{clientProjects.length}

+
+ {#if billingValue() !== null} +
+

Wert

+

+ {billingValue()!.toFixed(0)} + {client.billingRate!.currency} +

+
+ {/if} +
+ + + {#if clientProjects.length > 0} +
+

+ {$_('nav.projects')} +

+
+ {#each clientProjects as proj} + {@const hours = getProjectHours(proj.id)} + +
+
+

{proj.name}

+ {#if proj.isBillable} + {$_('project.billable')} + {/if} +
+ + {formatDurationCompact(hours)} + +
+ {/each} +
+
+ {/if} + + +
+

+ {$_('nav.entries')} ({formatDurationCompact(totalDuration)}) +

+ +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/times/entries/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/entries/+page.svelte new file mode 100644 index 000000000..f3a7bcf85 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/entries/+page.svelte @@ -0,0 +1,113 @@ + + + + {$_('nav.entries')} | Times + + +
+ +
+

{$_('nav.entries')}

+ +
+ + +
+ {#each ['week', 'month', 'all'] as period} + + {/each} + + +
+ + {$_('common.total')}: + {formatDurationCompact(totalDuration)} + + + {$_('entry.billable')}: + {formatDurationCompact(billableDuration)} + +
+
+ + + +
+ + + (showEntryForm = false)} /> diff --git a/apps/manacore/apps/web/src/routes/(app)/times/projects/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/projects/+page.svelte new file mode 100644 index 000000000..060b02cd8 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/projects/+page.svelte @@ -0,0 +1,366 @@ + + + + {$_('nav.projects')} | Times + + +
+ +
+

{$_('nav.projects')}

+ +
+ + + {#if showCreateForm} +
{ + e.preventDefault(); + handleCreate(); + }} + class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3" + > + +
+ + +
+ +
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ + +
+
+ {/if} + + + {#if activeProjects.length === 0 && !showCreateForm} +
+

{$_('project.noProjects')}

+
+ {:else} +
+ {#each activeProjects as project (project.id)} + {@const client = project.clientId + ? allClients.value.find((c) => c.id === project.clientId) + : undefined} + {@const hours = getProjectHours(project.id)} + {@const budgetPct = getBudgetPercent(project)} +
+ +
+ + {#if editingProjectId === project.id} + +
+ { + editName = (e.target as HTMLInputElement).value; + autoSaveProject({ name: editName }); + }} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none" + /> + +
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ +
+ + + +
+
+
+ {:else} + + + {/if} +
+ {/each} +
+ {/if} + + + {#if archivedProjects.length > 0} +
+ + + {#if showArchived} +
+ {#each archivedProjects as project} +
+
+
+ {project.name} +
+ +
+ {/each} +
+ {/if} +
+ {/if} +
+ + (deleteConfirmId = null)} +/> diff --git a/apps/manacore/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte new file mode 100644 index 000000000..64ea94941 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte @@ -0,0 +1,324 @@ + + + + {project?.name || 'Projekt'} | Times + + +{#if !project} +
+

Projekt nicht gefunden.

+ {$_('common.back')} +
+{:else} +
+ +
+ + + {$_('nav.projects')} + + +
+
+
+
+

{project.name}

+

+ {client?.name || $_('project.internal')} + {#if project.isBillable} + + {$_('project.billable')} + + {/if} + {#if project.isArchived} + + {$_('project.archived')} + + {/if} +

+
+
+
+ + +
+
+
+ + + {#if isEditing} +
+ { + editName = (e.target as HTMLInputElement).value; + save({ name: editName }); + }} + placeholder={$_('project.name')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none" + /> + { + editDescription = (e.target as HTMLInputElement).value; + save({ description: editDescription || null }); + }} + placeholder={$_('project.description')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]" + /> +
+ + +
+
+
+ + { + editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0; + save({ + budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null, + }); + }} + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm" + /> +
+
+ + { + editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0; + save({ + billingRate: + editRateAmount > 0 + ? { amount: editRateAmount, currency: 'EUR', per: 'hour' } + : null, + }); + }} + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm" + /> + /h +
+
+
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ {/if} + + +
+
+

{$_('report.totalHours')}

+

+ {formatDurationDecimal(totalDuration)}h +

+
+
+

{$_('report.billableHours')}

+

+ {formatDurationDecimal(billableDuration)}h +

+
+ {#if budgetHoursTotal} +
+

{$_('project.budget')}

+

+ {budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h +

+
+ {/if} +
+

{$_('nav.entries')}

+

{projectEntries.length}

+
+
+ + + {#if budgetPercent() !== null} +
+
+ {$_('project.budget')} + {budgetPercent()}% +
+
+
+
+ {#if project.billingRate} +

+ {project.billingRate.amount} + {project.billingRate.currency}/h · Wert: {( + (billableDuration / 3600) * + project.billingRate.amount + ).toFixed(0)} + {project.billingRate.currency} +

+ {/if} +
+ {/if} + + +
+

+ {$_('nav.entries')} ({formatDurationCompact(totalDuration)}) +

+ +
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/times/reports/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/reports/+page.svelte new file mode 100644 index 000000000..3868ffc43 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/reports/+page.svelte @@ -0,0 +1,213 @@ + + + + {$_('nav.reports')} | Times + + +
+ +
+

{$_('nav.reports')}

+
+
+ {#each ['week', 'month'] as p} + + {/each} +
+ +
+
+ + +
+
+

{$_('report.totalHours')}

+

+ {formatDurationDecimal(totalDuration)}h +

+
+
+

{$_('report.billableHours')}

+

+ {formatDurationDecimal(billableDuration)}h +

+
+
+

{$_('report.avgPerDay')}

+

+ {formatDurationCompact(Math.round(avgPerDay))} +

+
+
+

{$_('nav.entries')}

+

{entryCount}

+
+
+ + + {#if totalDuration > 0} +
+

+ {$_('entry.billable')} vs. {$_('entry.notBillable')} +

+
+
+
+
+
+ {$_('entry.billable')}: {formatDurationCompact(billableDuration)} + {$_('entry.notBillable')}: {formatDurationCompact(nonBillableDuration)} +
+
+ {/if} + + + {#if projectBreakdown().length > 0} +
+

+ {$_('report.byProject')} +

+
+ {#each projectBreakdown() as item} +
+
+
+
+ {item.name} +
+ + {formatDurationCompact(item.duration)} + +
+
+
+
+
+ {/each} +
+
+ {/if} + + + {#if period === 'week' && dailyBreakdown().length > 0} +
+

{$_('report.byDay')}

+
+ {#each dailyBreakdown() as day} +
+
+
+
+ {day.label} +
+ {/each} +
+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/times/templates/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/times/templates/+page.svelte new file mode 100644 index 000000000..2c1b4fc69 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/times/templates/+page.svelte @@ -0,0 +1,191 @@ + + + + {$_('nav.templates')} | Times + + +
+
+

{$_('nav.templates')}

+ +
+ + {#if showCreateForm} +
{ + e.preventDefault(); + handleCreate(); + }} + class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3" + > + + +
+ + +
+
+ + +
+
+ {/if} + + {#if sortedTemplates.length === 0 && !showCreateForm} +
+

{$_('template.noTemplates')}

+
+ {:else} +
+ {#each sortedTemplates as template (template.id)} + {@const project = template.projectId + ? allProjects.value.find((p) => p.id === template.projectId) + : undefined} +
+ {#if project} +
+ {:else} +
+ {/if} +
+

{template.name}

+

+ {template.description || $_('timer.noDescription')} + {#if project} + · {project.name}{/if} + {#if template.isBillable} + · ${/if} + {#if template.usageCount > 0} + · {template.usageCount}x{/if} +

+
+ + +
+ {/each} +
+ {/if} +