diff --git a/CLAUDE.md b/CLAUDE.md index 01b44928d..71159db41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/ | **citycorners** | City guide for Konstanz | Web, Landing | | **inventar** | Inventory management | Web | | **traces** | City exploration | Backend, Mobile | +| **taktik** | Time tracking | Web | | **playground** | LLM playground | Web | ### Archived Projects (`apps-archived/`) @@ -541,7 +542,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg ← WebSocket push ← ``` -### Migrated Apps (19/22) +### Migrated Apps (20/23) | App | Collections | Status | |-----|------------|--------| @@ -564,6 +565,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg | Photos | albums, albumItems, favorites, tags, photoTags | Done | | SkilltTree | skills, activities, achievements | Done | | CityCorners | locations, favorites | Done | +| Taktik | clients, projects, timeEntries, tags, templates, settings | Done | **Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless) diff --git a/apps/taktik/CLAUDE.md b/apps/taktik/CLAUDE.md new file mode 100644 index 000000000..25172062a --- /dev/null +++ b/apps/taktik/CLAUDE.md @@ -0,0 +1,73 @@ +# Taktik + +Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht. + +**Web App Port:** 5197 + +## Project Overview + +Taktik is a professional time tracking app with timer, manual entry, projects, clients, reports, and guild (team) integration. Built local-first for offline capability and instant UI. + +### Tech Stack + +| Layer | Technology | +|-------|------------| +| Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 | +| Data | @manacore/local-store (Dexie.js + mana-sync) | +| Icons | @manacore/shared-icons (Phosphor) | +| PWA | @vite-pwa/sveltekit + Workbox | +| i18n | svelte-i18n (de, en) | + +## Key Concepts + +- **Timer**: Start/stop time tracking with live counter, persists in IndexedDB +- **Time Entries**: Core entity with duration, project, client, tags, billable flag +- **Projects**: Client projects or internal, with budgets and billing rates +- **Clients**: Customer management with billing rates and short codes +- **Templates**: Saved entry patterns for quick start +- **Reports**: Charts and statistics (ActivityHeatmap, DonutChart, TrendLineChart) +- **Gilden**: Team time tracking with shared projects and visibility controls + +## Development + +```bash +# From monorepo root +pnpm dev:taktik:web # Start web app on port 5197 +pnpm dev:taktik:full # Start with auth + sync server +``` + +## Data Collections + +| Collection | Purpose | +|------------|---------| +| clients | Customer/client management | +| projects | Project tracking with budgets | +| timeEntries | Core time entry records | +| tags | Entry categorization | +| templates | Quick-start entry templates | +| settings | App configuration | + +## Project Structure + +``` +apps/taktik/ +├── apps/ +│ └── web/ # SvelteKit web client +│ ├── src/ +│ │ ├── routes/ +│ │ │ ├── (auth)/ # Login flow +│ │ │ └── (app)/ # Authenticated app +│ │ │ ├── entries/ +│ │ │ ├── projects/ +│ │ │ ├── clients/ +│ │ │ ├── reports/ +│ │ │ └── settings/ +│ │ └── lib/ +│ │ ├── stores/ # Svelte 5 rune stores +│ │ ├── components/ # UI components +│ │ ├── i18n/ # Translations (de, en) +│ │ └── data/ # Local-store, queries, guest seed +│ └── static/ +└── packages/ + └── shared/ # Shared types & constants +``` diff --git a/apps/taktik/apps/web/package.json b/apps/taktik/apps/web/package.json new file mode 100644 index 000000000..976c4072b --- /dev/null +++ b/apps/taktik/apps/web/package.json @@ -0,0 +1,52 @@ +{ + "name": "@taktik/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:unit": "vitest" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.1.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^22.15.29", + "svelte": "^5.41.0", + "svelte-check": "^4.2.1", + "tailwindcss": "^4.1.7", + "typescript": "^5.9.3", + "vite": "^6.3.5", + "vitest": "^3.2.1", + "@vite-pwa/sveltekit": "^1.1.0", + "@manacore/shared-vite-config": "workspace:*", + "@manacore/shared-tailwind": "workspace:*" + }, + "dependencies": { + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-auth-stores": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-error-tracking": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "@manacore/local-store": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-landing-ui": "workspace:*", + "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-stores": "workspace:*", + "@manacore/subscriptions": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-types": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "@manacore/shared-utils": "workspace:*", + "@taktik/shared": "workspace:*", + "date-fns": "^4.1.0", + "svelte-i18n": "^4.0.1" + }, + "type": "module" +} diff --git a/apps/taktik/apps/web/src/app.css b/apps/taktik/apps/web/src/app.css new file mode 100644 index 000000000..398fb3cd3 --- /dev/null +++ b/apps/taktik/apps/web/src/app.css @@ -0,0 +1,43 @@ +@import 'tailwindcss'; +@import '@manacore/shared-tailwind/themes.css'; + +/* Scan shared packages for Tailwind classes */ +@source "../../../packages/shared/src"; +@source "../../../../../packages/shared-ui/src"; +@source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; + +:root { + --primary: 38 92% 50%; + --primary-foreground: 0 0% 100%; + --accent: 38 92% 50%; + --accent-foreground: 0 0% 100%; +} + +/* Timer card glow */ +.timer-active { + box-shadow: 0 0 20px rgba(245, 158, 11, 0.15); +} + +/* Entry item transitions */ +.entry-item { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.entry-item:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* Duration display monospace */ +.duration-display { + font-variant-numeric: tabular-nums; +} + +/* Project color dot */ +.project-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/apps/taktik/apps/web/src/app.html b/apps/taktik/apps/web/src/app.html new file mode 100644 index 000000000..a5df47bb5 --- /dev/null +++ b/apps/taktik/apps/web/src/app.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Taktik + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/taktik/apps/web/src/lib/components/EntryForm.svelte b/apps/taktik/apps/web/src/lib/components/EntryForm.svelte new file mode 100644 index 000000000..548042100 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/EntryForm.svelte @@ -0,0 +1,240 @@ + + +{#if visible} + + +{/if} diff --git a/apps/taktik/apps/web/src/lib/components/EntryItem.svelte b/apps/taktik/apps/web/src/lib/components/EntryItem.svelte new file mode 100644 index 000000000..4f0a52d32 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/EntryItem.svelte @@ -0,0 +1,199 @@ + + +
+ + + + + {#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} +
diff --git a/apps/taktik/apps/web/src/lib/components/EntryList.svelte b/apps/taktik/apps/web/src/lib/components/EntryList.svelte new file mode 100644 index 000000000..6aed4789a --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/EntryList.svelte @@ -0,0 +1,79 @@ + + +{#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/taktik/apps/web/src/lib/components/QuickStart.svelte b/apps/taktik/apps/web/src/lib/components/QuickStart.svelte new file mode 100644 index 000000000..0b4e35fa8 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/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/taktik/apps/web/src/lib/components/TimerCard.svelte b/apps/taktik/apps/web/src/lib/components/TimerCard.svelte new file mode 100644 index 000000000..603647aa1 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/TimerCard.svelte @@ -0,0 +1,199 @@ + + +
+ +
+
+ {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/taktik/apps/web/src/lib/data/guest-seed.ts b/apps/taktik/apps/web/src/lib/data/guest-seed.ts new file mode 100644 index 000000000..ccad98d30 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/data/guest-seed.ts @@ -0,0 +1,183 @@ +/** + * Guest seed data for the Taktik app. + * + * Provides demo clients, projects, and time entries for the guest experience. + */ + +import type { + LocalClient, + LocalProject, + LocalTimeEntry, + LocalTag, + LocalSettings, +} from './local-store'; + +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 guestClients: LocalClient[] = [ + { + id: DEMO_CLIENT_ID, + name: 'Acme Corp', + shortCode: 'ACME', + email: 'kontakt@acme.de', + color: '#3b82f6', + isArchived: false, + billingRate: { amount: 95, currency: 'EUR', per: 'hour' }, + order: 0, + }, + { + id: 'demo-client-startup', + name: 'TechStartup GmbH', + shortCode: 'TS', + color: '#8b5cf6', + isArchived: false, + billingRate: { amount: 85, currency: 'EUR', per: 'hour' }, + order: 1, + }, +]; + +export const guestProjects: LocalProject[] = [ + { + 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' }, + budget: { type: 'hours', amount: 120 }, + visibility: 'private', + order: 0, + }, + { + id: DEMO_INTERNAL_PROJECT_ID, + name: 'Intern / Meetings', + description: 'Interne Meetings, Orga, Admin', + color: '#6b7280', + isArchived: false, + isBillable: false, + visibility: 'private', + 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', amount: 200 }, + visibility: 'private', + order: 2, + }, +]; + +export const guestTimeEntries: LocalTimeEntry[] = [ + { + id: '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', + source: { app: 'manual' }, + }, + { + id: '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', + source: { app: 'manual' }, + }, + { + id: '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', + source: { app: 'timer' }, + }, + { + id: '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', + source: { app: 'timer' }, + }, + { + id: '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', + source: { app: 'manual' }, + }, +]; + +export const guestTags: LocalTag[] = [ + { id: 'tag-design', name: 'design', color: '#f59e0b', order: 0 }, + { id: 'tag-dev', name: 'development', color: '#3b82f6', order: 1 }, + { id: 'tag-meeting', name: 'meeting', color: '#6b7280', order: 2 }, + { id: 'tag-review', name: 'review', color: '#22c55e', order: 3 }, +]; + +export const guestSettings: LocalSettings[] = [ + { + id: 'default-settings', + workingHoursPerDay: 8, + workingDaysPerWeek: 5, + roundingIncrement: 0, + roundingMethod: 'none', + defaultVisibility: 'private', + weekStartsOn: 1, + timerReminderMinutes: 0, + autoStopTimerHours: 0, + }, +]; diff --git a/apps/taktik/apps/web/src/lib/data/local-store.ts b/apps/taktik/apps/web/src/lib/data/local-store.ts new file mode 100644 index 000000000..22e5c759e --- /dev/null +++ b/apps/taktik/apps/web/src/lib/data/local-store.ts @@ -0,0 +1,153 @@ +/** + * Taktik — Local-First Data Layer + * + * IndexedDB (Dexie.js) with sync support for time tracking. + * Clients, projects, time entries, tags, templates, and settings. + */ + +import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import { + guestClients, + guestProjects, + guestTimeEntries, + guestTags, + guestSettings, +} from './guest-seed'; +import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@taktik/shared'; + +// ─── Types ────────────────────────────────────────────────── + +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; +} + +// ─── Store ────────────────────────────────────────────────── + +const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + +export const taktikStore = createLocalStore({ + appId: 'taktik', + collections: [ + { + name: 'clients', + indexes: ['order', 'isArchived', 'shortCode'], + guestSeed: guestClients, + }, + { + name: 'projects', + indexes: ['clientId', 'isArchived', 'isBillable', 'guildId', 'visibility', 'order'], + guestSeed: guestProjects, + }, + { + name: 'timeEntries', + indexes: [ + 'projectId', + 'clientId', + 'date', + 'isRunning', + '[date+projectId]', + '[date+clientId]', + 'guildId', + 'visibility', + ], + guestSeed: guestTimeEntries, + }, + { + name: 'tags', + indexes: ['name', 'order'], + guestSeed: guestTags, + }, + { + name: 'templates', + indexes: ['usageCount', 'lastUsedAt', 'projectId'], + }, + { + name: 'settings', + indexes: [], + guestSeed: guestSettings, + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +// Typed collection accessors +export const clientCollection = taktikStore.collection('clients'); +export const projectCollection = taktikStore.collection('projects'); +export const timeEntryCollection = taktikStore.collection('timeEntries'); +export const tagCollection = taktikStore.collection('tags'); +export const templateCollection = taktikStore.collection('templates'); +export const settingsCollection = taktikStore.collection('settings'); diff --git a/apps/taktik/apps/web/src/lib/data/queries.ts b/apps/taktik/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..e9a4d7970 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/data/queries.ts @@ -0,0 +1,338 @@ +/** + * Reactive Queries & Pure Helpers for Taktik + * + * Uses Dexie liveQuery to automatically re-render when IndexedDB changes + * (local writes, sync updates, other tabs). + */ + +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { + clientCollection, + projectCollection, + timeEntryCollection, + tagCollection, + templateCollection, + settingsCollection, + type LocalClient, + type LocalProject, + type LocalTimeEntry, + type LocalTag, + type LocalTemplate, + type LocalSettings, +} from './local-store'; +import type { + Client, + Project, + TimeEntry, + Tag, + EntryTemplate, + TaktikSettings, + FilterCriteria, + SortOption, +} from '@taktik/shared'; + +// ─── 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): TaktikSettings { + 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 Query Hooks ────────────────────────────────────── + +export function useAllClients() { + return useLiveQueryWithDefault(async () => { + const locals = await clientCollection.getAll(); + return locals.map(toClient); + }, [] as Client[]); +} + +export function useAllProjects() { + return useLiveQueryWithDefault(async () => { + const locals = await projectCollection.getAll(); + return locals.map(toProject); + }, [] as Project[]); +} + +export function useAllTimeEntries() { + return useLiveQueryWithDefault(async () => { + const locals = await timeEntryCollection.getAll(); + return locals.map(toTimeEntry); + }, [] as TimeEntry[]); +} + +export function useAllTags() { + return useLiveQueryWithDefault(async () => { + const locals = await tagCollection.getAll(); + return locals.map(toTag); + }, [] as Tag[]); +} + +export function useAllTemplates() { + return useLiveQueryWithDefault(async () => { + const locals = await templateCollection.getAll(); + return locals.map(toTemplate); + }, [] as EntryTemplate[]); +} + +export function useSettings() { + return useLiveQueryWithDefault( + async () => { + const locals = await settingsCollection.getAll(); + return locals.length > 0 ? toSettings(locals[0]) : null; + }, + null as TaktikSettings | 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/taktik/apps/web/src/lib/i18n/index.ts b/apps/taktik/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..bf8800877 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/i18n/index.ts @@ -0,0 +1,38 @@ +import { browser } from '$app/environment'; +import { init, register, locale, waitLocale } from 'svelte-i18n'; + +export const supportedLocales = ['de', 'en'] as const; +export type SupportedLocale = (typeof supportedLocales)[number]; + +const defaultLocale = 'de'; + +register('de', () => import('./locales/de.json')); +register('en', () => import('./locales/en.json')); + +function getInitialLocale(): SupportedLocale { + if (browser) { + const stored = localStorage.getItem('taktik_locale'); + if (stored && supportedLocales.includes(stored as SupportedLocale)) { + return stored as SupportedLocale; + } + const browserLang = navigator.language.split('-')[0]; + if (supportedLocales.includes(browserLang as SupportedLocale)) { + return browserLang as SupportedLocale; + } + } + return defaultLocale; +} + +init({ + fallbackLocale: defaultLocale, + initialLocale: getInitialLocale(), +}); + +export function setLocale(newLocale: SupportedLocale) { + locale.set(newLocale); + if (browser) { + localStorage.setItem('taktik_locale', newLocale); + } +} + +export { waitLocale }; diff --git a/apps/taktik/apps/web/src/lib/i18n/locales/de.json b/apps/taktik/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..2cd17850a --- /dev/null +++ b/apps/taktik/apps/web/src/lib/i18n/locales/de.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Taktik", + "loading": "Laden...", + "tagline": "Dein Arbeitsrhythmus, messbar gemacht." + }, + "nav": { + "timer": "Timer", + "entries": "Einträge", + "projects": "Projekte", + "clients": "Kunden", + "reports": "Reports", + "settings": "Einstellungen", + "templates": "Vorlagen" + }, + "timer": { + "start": "Timer starten", + "stop": "Timer stoppen", + "resume": "Fortsetzen", + "running": "Läuft", + "noDescription": "Keine Beschreibung", + "whatAreYouWorkingOn": "Woran arbeitest du?" + }, + "entry": { + "create": "Eintrag erstellen", + "edit": "Eintrag bearbeiten", + "delete": "Eintrag löschen", + "description": "Beschreibung", + "date": "Datum", + "startTime": "Startzeit", + "endTime": "Endzeit", + "duration": "Dauer", + "billable": "Abrechenbar", + "notBillable": "Nicht abrechenbar", + "noEntries": "Keine Einträge", + "today": "Heute", + "thisWeek": "Diese Woche", + "thisMonth": "Dieser Monat", + "manual": "Manuell erfassen" + }, + "project": { + "create": "Projekt erstellen", + "edit": "Projekt bearbeiten", + "delete": "Projekt löschen", + "name": "Name", + "description": "Beschreibung", + "client": "Kunde", + "billable": "Abrechenbar", + "budget": "Budget", + "noProjects": "Keine Projekte", + "archived": "Archiviert", + "internal": "Intern" + }, + "client": { + "create": "Kunde erstellen", + "edit": "Kunde bearbeiten", + "delete": "Kunde löschen", + "name": "Name", + "shortCode": "Kürzel", + "email": "E-Mail", + "billingRate": "Stundensatz", + "noClients": "Keine Kunden" + }, + "report": { + "title": "Reports", + "totalHours": "Gesamtstunden", + "billableHours": "Abrechenbare Stunden", + "avgPerDay": "Durchschnitt/Tag", + "topProject": "Top-Projekt", + "byProject": "Nach Projekt", + "byClient": "Nach Kunde", + "byDay": "Nach Tag", + "export": "Exportieren", + "dateRange": "Zeitraum" + }, + "template": { + "create": "Vorlage erstellen", + "edit": "Vorlage bearbeiten", + "delete": "Vorlage löschen", + "noTemplates": "Keine Vorlagen", + "useTemplate": "Vorlage verwenden" + }, + "settings": { + "title": "Einstellungen", + "workingHours": "Arbeitsstunden/Tag", + "workingDays": "Arbeitstage/Woche", + "rounding": "Rundung", + "roundingMethod": "Rundungsmethode", + "none": "Keine", + "up": "Aufrunden", + "down": "Abrunden", + "nearest": "Nächster Wert", + "currency": "Währung", + "billingRate": "Standard-Stundensatz", + "weekStart": "Woche beginnt am", + "monday": "Montag", + "sunday": "Sonntag", + "timerReminder": "Timer-Erinnerung (Min.)", + "autoStop": "Auto-Stop (Std.)" + }, + "auth": { + "login": "Anmelden", + "logout": "Abmelden", + "register": "Registrieren", + "email": "E-Mail", + "password": "Passwort", + "forgotPassword": "Passwort vergessen?" + }, + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "add": "Hinzufügen", + "close": "Schließen", + "search": "Suchen", + "error": "Fehler", + "success": "Erfolgreich", + "loading": "Laden...", + "noResults": "Keine Ergebnisse", + "confirm": "Bestätigen", + "back": "Zurück", + "next": "Weiter", + "create": "Erstellen", + "archive": "Archivieren", + "unarchive": "Wiederherstellen", + "total": "Gesamt", + "hours": "Stunden", + "minutes": "Minuten" + }, + "error": { + "notFound": "Seite nicht gefunden", + "backToHome": "Zurück zur Startseite" + } +} diff --git a/apps/taktik/apps/web/src/lib/i18n/locales/en.json b/apps/taktik/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..58f7847cc --- /dev/null +++ b/apps/taktik/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Taktik", + "loading": "Loading...", + "tagline": "Your work rhythm, made measurable." + }, + "nav": { + "timer": "Timer", + "entries": "Entries", + "projects": "Projects", + "clients": "Clients", + "reports": "Reports", + "settings": "Settings", + "templates": "Templates" + }, + "timer": { + "start": "Start Timer", + "stop": "Stop Timer", + "resume": "Resume", + "running": "Running", + "noDescription": "No description", + "whatAreYouWorkingOn": "What are you working on?" + }, + "entry": { + "create": "Create Entry", + "edit": "Edit Entry", + "delete": "Delete Entry", + "description": "Description", + "date": "Date", + "startTime": "Start Time", + "endTime": "End Time", + "duration": "Duration", + "billable": "Billable", + "notBillable": "Not Billable", + "noEntries": "No entries", + "today": "Today", + "thisWeek": "This Week", + "thisMonth": "This Month", + "manual": "Manual Entry" + }, + "project": { + "create": "Create Project", + "edit": "Edit Project", + "delete": "Delete Project", + "name": "Name", + "description": "Description", + "client": "Client", + "billable": "Billable", + "budget": "Budget", + "noProjects": "No projects", + "archived": "Archived", + "internal": "Internal" + }, + "client": { + "create": "Create Client", + "edit": "Edit Client", + "delete": "Delete Client", + "name": "Name", + "shortCode": "Short Code", + "email": "Email", + "billingRate": "Billing Rate", + "noClients": "No clients" + }, + "report": { + "title": "Reports", + "totalHours": "Total Hours", + "billableHours": "Billable Hours", + "avgPerDay": "Avg/Day", + "topProject": "Top Project", + "byProject": "By Project", + "byClient": "By Client", + "byDay": "By Day", + "export": "Export", + "dateRange": "Date Range" + }, + "template": { + "create": "Create Template", + "edit": "Edit Template", + "delete": "Delete Template", + "noTemplates": "No templates", + "useTemplate": "Use Template" + }, + "settings": { + "title": "Settings", + "workingHours": "Working Hours/Day", + "workingDays": "Working Days/Week", + "rounding": "Rounding", + "roundingMethod": "Rounding Method", + "none": "None", + "up": "Round Up", + "down": "Round Down", + "nearest": "Nearest", + "currency": "Currency", + "billingRate": "Default Billing Rate", + "weekStart": "Week Starts On", + "monday": "Monday", + "sunday": "Sunday", + "timerReminder": "Timer Reminder (min)", + "autoStop": "Auto-Stop (hours)" + }, + "auth": { + "login": "Login", + "logout": "Logout", + "register": "Register", + "email": "Email", + "password": "Password", + "forgotPassword": "Forgot password?" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "close": "Close", + "search": "Search", + "error": "Error", + "success": "Success", + "loading": "Loading...", + "noResults": "No results", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "create": "Create", + "archive": "Archive", + "unarchive": "Restore", + "total": "Total", + "hours": "Hours", + "minutes": "Minutes" + }, + "error": { + "notFound": "Page not found", + "backToHome": "Back to home" + } +} diff --git a/apps/taktik/apps/web/src/lib/stores/auth.svelte.ts b/apps/taktik/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..5df932791 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,7 @@ +/** + * Auth Store — uses centralized Mana auth factory. + */ + +import { createManaAuthStore } from '@manacore/shared-auth-stores'; + +export const authStore = createManaAuthStore(); diff --git a/apps/taktik/apps/web/src/lib/stores/navigation.ts b/apps/taktik/apps/web/src/lib/stores/navigation.ts new file mode 100644 index 000000000..a0dd7b724 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/navigation.ts @@ -0,0 +1,6 @@ +import { createSimpleNavigationStores } from '@manacore/shared-stores'; + +export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({ + withToolbar: true, + toolbarCollapsedDefault: true, +}); diff --git a/apps/taktik/apps/web/src/lib/stores/theme.ts b/apps/taktik/apps/web/src/lib/stores/theme.ts new file mode 100644 index 000000000..d5ded30af --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/theme.ts @@ -0,0 +1,6 @@ +import { createThemeStore } from '@manacore/shared-theme'; + +export const theme = createThemeStore({ + appId: 'taktik', + defaultVariant: 'ocean', +}); diff --git a/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts b/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts new file mode 100644 index 000000000..140db31d8 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts @@ -0,0 +1,164 @@ +/** + * Timer Store — manages the active time tracking timer. + * + * The timer state persists in IndexedDB via the timeEntries collection. + * 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 { timeEntryCollection, type LocalTimeEntry } from '$lib/data/local-store'; + +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 timeEntryCollection.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 timeEntryCollection.getAll(); + const running = entries.find((e) => e.isRunning); + 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 timeEntryCollection.insert(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; + + await timeEntryCollection.update(runningEntry.id, { + isRunning: false, + endTime: now.toISOString(), + duration: finalDuration, + }); + + const stoppedEntry = { + ...runningEntry, + isRunning: false, + endTime: now.toISOString(), + duration: finalDuration, + }; + stopTicking(); + runningEntry = null; + elapsedSeconds = 0; + return stoppedEntry as LocalTimeEntry; + }, + + /** Discard the running timer without saving */ + async discard() { + if (!runningEntry) return; + await timeEntryCollection.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 timeEntryCollection.update(runningEntry.id, updates); + runningEntry = { ...runningEntry, ...updates }; + }, + + /** Cleanup on unmount */ + destroy() { + stopTicking(); + }, +}; diff --git a/apps/taktik/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/taktik/apps/web/src/lib/stores/user-settings.svelte.ts new file mode 100644 index 000000000..73ffe777f --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/user-settings.svelte.ts @@ -0,0 +1,18 @@ +import { browser } from '$app/environment'; +import { createUserSettingsStore } from '@manacore/shared-theme'; +import { authStore } from './auth.svelte'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + if (injectedUrl) return injectedUrl; + } + return import.meta.env.DEV ? 'http://localhost:3001' : ''; +} + +export const userSettings = createUserSettingsStore({ + appId: 'taktik', + authUrl: getAuthUrl, + getAccessToken: () => authStore.getAccessToken(), +}); diff --git a/apps/taktik/apps/web/src/lib/stores/view.svelte.ts b/apps/taktik/apps/web/src/lib/stores/view.svelte.ts new file mode 100644 index 000000000..d0b94a1a9 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/stores/view.svelte.ts @@ -0,0 +1,106 @@ +import { browser } from '$app/environment'; +import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@taktik/shared'; + +const VIEW_KEY = 'taktik_view_mode'; +const SORT_KEY = 'taktik_sort'; +const FILTERS_KEY = 'taktik_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/taktik/apps/web/src/lib/version.ts b/apps/taktik/apps/web/src/lib/version.ts new file mode 100644 index 000000000..93d9ace5f --- /dev/null +++ b/apps/taktik/apps/web/src/lib/version.ts @@ -0,0 +1,7 @@ +declare const __BUILD_TIME__: string; +declare const __BUILD_HASH__: string; + +export const APP_VERSION = '1.0.0'; +export const BUILD_TIME: string = + typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString(); +export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev'; diff --git a/apps/taktik/apps/web/src/routes/(app)/+layout.svelte b/apps/taktik/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 000000000..c90473a78 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,203 @@ + + + +
+ + {#if showNav} + + {/if} + + +
+ {@render children()} +
+
+ + + + + (showGuestWelcome = false)} + onLogin={() => goto('/login')} + onRegister={() => goto('/register')} + locale="de" + /> + +
diff --git a/apps/taktik/apps/web/src/routes/(app)/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/+page.svelte new file mode 100644 index 000000000..5bdafdeef --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/+page.svelte @@ -0,0 +1,84 @@ + + + + Timer | Taktik + + +
+ + + + +
+
+

{$_('common.total')}

+

+ {formatDurationCompact(todayTotal)} +

+
+
+

{$_('entry.billable')}

+

+ {formatDurationCompact(todayBillable)} +

+
+
+ + + + + +
+
+

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

+ +
+ + +
+
+ + + (showEntryForm = false)} /> diff --git a/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte new file mode 100644 index 000000000..8c629edbe --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte @@ -0,0 +1,347 @@ + + + + {$_('nav.clients')} | Taktik + + +
+
+

{$_('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} +
diff --git a/apps/taktik/apps/web/src/routes/(app)/entries/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/entries/+page.svelte new file mode 100644 index 000000000..4d2fccb47 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/entries/+page.svelte @@ -0,0 +1,113 @@ + + + + {$_('nav.entries')} | Taktik + + +
+ +
+

{$_('nav.entries')}

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

Feedback

+

Feedback-Formular kommt bald.

+
diff --git a/apps/taktik/apps/web/src/routes/(app)/help/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/help/+page.svelte new file mode 100644 index 000000000..14e51499b --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/help/+page.svelte @@ -0,0 +1,12 @@ + + + + Hilfe | Taktik + + +
+

Hilfe

+

Hilfe & Dokumentation.

+
diff --git a/apps/taktik/apps/web/src/routes/(app)/mana/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/mana/+page.svelte new file mode 100644 index 000000000..8e964a054 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/mana/+page.svelte @@ -0,0 +1,12 @@ + + + + Mana | Taktik + + +
+

Mana

+

Mana Credits & Abo-Verwaltung.

+
diff --git a/apps/taktik/apps/web/src/routes/(app)/profile/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/profile/+page.svelte new file mode 100644 index 000000000..c3a07ad34 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/profile/+page.svelte @@ -0,0 +1,12 @@ + + + + Profil | Taktik + + +
+

Profil

+

Profil-Einstellungen.

+
diff --git a/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte new file mode 100644 index 000000000..0655c662e --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte @@ -0,0 +1,356 @@ + + + + {$_('nav.projects')} | Taktik + + +
+ +
+

{$_('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} +
diff --git a/apps/taktik/apps/web/src/routes/(app)/reports/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/reports/+page.svelte new file mode 100644 index 000000000..3a875e77f --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/reports/+page.svelte @@ -0,0 +1,206 @@ + + + + {$_('nav.reports')} | Taktik + + +
+ +
+

{$_('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/taktik/apps/web/src/routes/(app)/settings/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 000000000..90bf2e12c --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,12 @@ + + + + {$_('nav.settings')} | Taktik + + +
+

{$_('nav.settings')}

+

Einstellungen kommen bald.

+
diff --git a/apps/taktik/apps/web/src/routes/(app)/themes/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/themes/+page.svelte new file mode 100644 index 000000000..8a8878ce4 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/themes/+page.svelte @@ -0,0 +1,12 @@ + + + + Themes | Taktik + + +
+

Themes

+

Theme-Auswahl.

+
diff --git a/apps/taktik/apps/web/src/routes/(auth)/+layout.svelte b/apps/taktik/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..a54cfdcb7 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/apps/taktik/apps/web/src/routes/(auth)/login/+page.svelte b/apps/taktik/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..74438d766 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,270 @@ + + + + {showRegister ? $_('auth.register') : $_('auth.login')} | Taktik + + +
+
+ +
+
+ + + +
+

Taktik

+

Zeiterfassung

+
+ + {#if verificationSent} +
+

+ {showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'} +

+

Bitte prüfe dein Postfach.

+ +
+ {:else if show2FA} +
+

+ Zwei-Faktor-Authentifizierung +

+ e.key === 'Enter' && handle2FA()} + /> + {#if error}

{error}

{/if} + +
+ {:else} +
+

+ {showForgotPassword + ? 'Passwort zurücksetzen' + : showRegister + ? $_('auth.register') + : $_('auth.login')} +

+ +
+ + {#if !showForgotPassword} + + e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())} + /> + {/if} +
+ + {#if error}

{error}

{/if} + + + + {#if !showForgotPassword && authStore.isPasskeyAvailable()} + + {/if} + +
+ {#if showForgotPassword} + + {:else} + + + {/if} +
+
+ {/if} + + +
+ {#each getPillAppItems() as app} + {#if app.id !== 'taktik'} + + {app.name} + + {/if} + {/each} +
+
+
diff --git a/apps/taktik/apps/web/src/routes/+error.svelte b/apps/taktik/apps/web/src/routes/+error.svelte new file mode 100644 index 000000000..6ef4029e4 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/+error.svelte @@ -0,0 +1,19 @@ + + +
+
+

{$page.status}

+

+ {$page.error?.message || $_('error.notFound')} +

+ + {$_('error.backToHome')} + +
+
diff --git a/apps/taktik/apps/web/src/routes/+layout.svelte b/apps/taktik/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..69d701b16 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/+layout.svelte @@ -0,0 +1,35 @@ + + +{#if ready} +
+ {@render children()} +
+{:else} +
+
+
+

Laden...

+
+
+{/if} diff --git a/apps/taktik/apps/web/src/routes/+layout.ts b/apps/taktik/apps/web/src/routes/+layout.ts new file mode 100644 index 000000000..ad6cddb06 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/+layout.ts @@ -0,0 +1,2 @@ +// Disable SSR — all data is local-first (IndexedDB + mana-sync) +export const ssr = false; diff --git a/apps/taktik/apps/web/src/routes/health/+server.ts b/apps/taktik/apps/web/src/routes/health/+server.ts new file mode 100644 index 000000000..b558386f2 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/health/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return json({ + status: 'ok', + timestamp: new Date().toISOString(), + service: 'taktik-web', + }); +}; diff --git a/apps/taktik/apps/web/src/routes/offline/+page.svelte b/apps/taktik/apps/web/src/routes/offline/+page.svelte new file mode 100644 index 000000000..4c1ddb1d9 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/offline/+page.svelte @@ -0,0 +1,39 @@ + + + + Offline | Taktik + + +
+
+
+ + + +
+

Offline

+

+ Du bist gerade nicht mit dem Internet verbunden. +

+ +
+
diff --git a/apps/taktik/apps/web/svelte.config.js b/apps/taktik/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/taktik/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/taktik/apps/web/tsconfig.json b/apps/taktik/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/taktik/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/taktik/apps/web/vite.config.ts b/apps/taktik/apps/web/vite.config.ts new file mode 100644 index 000000000..a0d688e48 --- /dev/null +++ b/apps/taktik/apps/web/vite.config.ts @@ -0,0 +1,64 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { SvelteKitPWA } from '@vite-pwa/sveltekit'; +import { defineConfig } from 'vitest/config'; +import { getBuildDefines } from '@manacore/shared-vite-config'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit(), + SvelteKitPWA({ + registerType: 'autoUpdate', + manifest: { + name: 'Taktik', + short_name: 'Taktik', + description: 'Zeiterfassung & Timetracking', + theme_color: '#f59e0b', + background_color: '#0f172a', + display: 'standalone', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + shortcuts: [ + { + name: 'Timer starten', + short_name: 'Timer', + url: '/?action=start', + icons: [{ src: 'icons/icon.svg', sizes: '96x96' }], + }, + { + name: 'Neuer Eintrag', + short_name: 'Eintrag', + url: '/entries?action=new', + icons: [{ src: 'icons/icon.svg', sizes: '96x96' }], + }, + ], + }, + workbox: { + globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'], + }, + }), + ], + server: { + port: 5197, + strictPort: true, + }, + preview: { + port: 5197, + }, + define: getBuildDefines(), + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + environment: 'jsdom', + }, +}); diff --git a/apps/taktik/package.json b/apps/taktik/package.json new file mode 100644 index 000000000..279d3617b --- /dev/null +++ b/apps/taktik/package.json @@ -0,0 +1,14 @@ +{ + "name": "taktik", + "version": "1.0.0", + "private": true, + "description": "Taktik - Zeiterfassung & Timetracking", + "scripts": { + "dev": "pnpm --filter @taktik/web dev", + "dev:web": "pnpm --filter @taktik/web dev" + }, + "devDependencies": { + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/apps/taktik/packages/shared/package.json b/apps/taktik/packages/shared/package.json new file mode 100644 index 000000000..c7cf82295 --- /dev/null +++ b/apps/taktik/packages/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@taktik/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types/index.ts", + "./constants": "./src/constants/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/shared-types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/taktik/packages/shared/src/constants/index.ts b/apps/taktik/packages/shared/src/constants/index.ts new file mode 100644 index 000000000..62052c39c --- /dev/null +++ b/apps/taktik/packages/shared/src/constants/index.ts @@ -0,0 +1,40 @@ +export const CURRENCIES = [ + { code: 'EUR', symbol: '€', name: 'Euro' }, + { code: 'CHF', symbol: 'CHF', name: 'Schweizer Franken' }, + { code: 'USD', symbol: '$', name: 'US Dollar' }, + { code: 'GBP', symbol: '£', name: 'Britisches Pfund' }, +] as const; + +export const DEFAULT_CURRENCY = 'EUR'; + +export const ROUNDING_INCREMENTS = [0, 1, 5, 6, 10, 15] as const; + +export const PROJECT_COLORS: string[] = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#eab308', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#0ea5e9', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#d946ef', + '#ec4899', + '#f43f5e', +]; + +export const 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/taktik/packages/shared/src/index.ts b/apps/taktik/packages/shared/src/index.ts new file mode 100644 index 000000000..98583d97c --- /dev/null +++ b/apps/taktik/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './types/index.js'; +export * from './constants/index.js'; diff --git a/apps/taktik/packages/shared/src/types/index.ts b/apps/taktik/packages/shared/src/types/index.ts new file mode 100644 index 000000000..acdd8d3ce --- /dev/null +++ b/apps/taktik/packages/shared/src/types/index.ts @@ -0,0 +1,154 @@ +// ─── Billing Rate ──────────────────────────────────────── + +export interface BillingRate { + amount: number; + currency: string; + per: 'hour' | 'day'; +} + +// ─── Client ────────────────────────────────────────────── + +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; +} + +// ─── Project ───────────────────────────────────────────── + +export interface ProjectBudget { + type: 'hours' | 'fixed'; + amount: number; + currency?: string; +} + +export type ProjectVisibility = 'private' | 'guild'; + +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; +} + +// ─── Time Entry ────────────────────────────────────────── + +export type EntrySource = 'todo' | 'calendar' | 'manual' | 'timer'; + +export interface EntrySourceRef { + app: EntrySource; + refId?: 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; +} + +// ─── Tag ───────────────────────────────────────────────── + +export interface Tag { + id: string; + name: string; + color: string; + order: number; + createdAt: string; + updatedAt: string; +} + +// ─── Template ──────────────────────────────────────────── + +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; +} + +// ─── Settings ──────────────────────────────────────────── + +export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest'; + +export interface TaktikSettings { + 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; +} + +// ─── View & Filter ─────────────────────────────────────── + +export type ViewMode = 'day' | 'week' | 'month'; +export type SortField = 'date' | 'duration' | 'project' | 'client' | 'createdAt'; +export type SortDirection = 'asc' | 'desc'; + +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; +} diff --git a/apps/taktik/packages/shared/tsconfig.json b/apps/taktik/packages/shared/tsconfig.json new file mode 100644 index 000000000..d2e7ec71f --- /dev/null +++ b/apps/taktik/packages/shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index 9594299a6..b20e32a09 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,9 @@ "dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"", "inventar:dev": "turbo run dev --filter=inventar...", "dev:inventar:web": "pnpm --filter @inventar/web dev", + "taktik:dev": "turbo run dev --filter=taktik...", + "dev:taktik:web": "pnpm --filter @taktik/web dev", + "dev:taktik:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:taktik:web\"", "moodlit:dev": "turbo run dev --filter=moodlit...", "dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev", "dev:moodlit:web": "pnpm --filter @moodlit/web dev", diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 8ebf90513..5ceaf69b0 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -75,6 +75,9 @@ const playgroundSvg = ``; +// Taktik icon (clock with play button, amber gradient) +const taktikSvg = ``; + // Context icon (document/knowledge with sky blue gradient) const contextSvg = ``; @@ -106,6 +109,7 @@ export const APP_ICONS = { playground: svgToDataUrl(playgroundSvg), context: svgToDataUrl(contextSvg), citycorners: svgToDataUrl(citycornersSvg), + taktik: svgToDataUrl(taktikSvg), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 059a65e00..fd5b385a5 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -340,6 +340,22 @@ export const MANA_APPS: ManaApp[] = [ comingSoon: false, status: 'development', }, + { + id: 'taktik', + name: 'Taktik', + description: { + de: 'Zeiterfassung & Timetracking', + en: 'Time Tracking', + }, + longDescription: { + de: 'Professionelle Zeiterfassung mit Timer, Projekten, Kunden, Reports und Gilden-Integration.', + en: 'Professional time tracking with timer, projects, clients, reports, and guild integration.', + }, + icon: APP_ICONS.taktik, + color: '#f59e0b', + comingSoon: false, + status: 'development', + }, { id: 'citycorners', name: 'CityCorners', @@ -447,6 +463,7 @@ export const APP_URLS: Record = { playground: { dev: 'http://localhost:5190', prod: 'https://playground.mana.how' }, context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' }, citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' }, + taktik: { dev: 'http://localhost:5197', prod: 'https://taktik.mana.how' }, }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 068e5f1f9..18a29c8f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,14 +78,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1)) @@ -94,13 +94,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) + version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -1491,7 +1491,7 @@ importers: version: 3.6.3(@types/node@24.10.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(lightningcss@1.30.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.44.1) '@astrojs/tailwind': specifier: ^5.1.0 - version: 5.1.5(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + version: 5.1.5(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@iconify-json/heroicons': specifier: ^1.2.2 version: 1.2.3 @@ -1509,7 +1509,7 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) astro-icon: specifier: ^1.1.5 version: 1.1.5 @@ -4146,6 +4146,122 @@ importers: specifier: ^5.7.3 version: 5.9.3 + apps/taktik: + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + apps/taktik/apps/web: + dependencies: + '@manacore/local-store': + specifier: workspace:* + version: link:../../../../packages/local-store + '@manacore/shared-auth': + specifier: workspace:* + version: link:../../../../packages/shared-auth + '@manacore/shared-auth-stores': + specifier: workspace:* + version: link:../../../../packages/shared-auth-stores + '@manacore/shared-auth-ui': + specifier: workspace:* + version: link:../../../../packages/shared-auth-ui + '@manacore/shared-branding': + specifier: workspace:* + version: link:../../../../packages/shared-branding + '@manacore/shared-error-tracking': + specifier: workspace:* + version: link:../../../../packages/shared-error-tracking + '@manacore/shared-icons': + specifier: workspace:* + version: link:../../../../packages/shared-icons + '@manacore/shared-landing-ui': + specifier: workspace:* + version: link:../../../../packages/shared-landing-ui + '@manacore/shared-profile-ui': + specifier: workspace:* + version: link:../../../../packages/shared-profile-ui + '@manacore/shared-stores': + specifier: workspace:* + version: link:../../../../packages/shared-stores + '@manacore/shared-theme': + specifier: workspace:* + version: link:../../../../packages/shared-theme + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types + '@manacore/shared-ui': + specifier: workspace:* + version: link:../../../../packages/shared-ui + '@manacore/shared-utils': + specifier: workspace:* + version: link:../../../../packages/shared-utils + '@manacore/subscriptions': + specifier: workspace:* + version: link:../../../../packages/subscriptions + '@taktik/shared': + specifier: workspace:* + version: link:../../packages/shared + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + svelte-i18n: + specifier: ^4.0.1 + version: 4.0.1(svelte@5.44.0) + devDependencies: + '@manacore/shared-tailwind': + specifier: workspace:* + version: link:../../../../packages/shared-tailwind + '@manacore/shared-vite-config': + specifier: workspace:* + version: link:../../../../packages/shared-vite-config + '@sveltejs/adapter-node': + specifier: ^5.2.12 + version: 5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))) + '@sveltejs/kit': + specifier: ^2.21.0 + version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.1.0 + version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@tailwindcss/vite': + specifier: ^4.1.7 + version: 4.1.17(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@types/node': + specifier: ^22.15.29 + version: 22.19.1 + '@vite-pwa/sveltekit': + specifier: ^1.1.0 + version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + svelte: + specifier: ^5.41.0 + version: 5.44.0 + svelte-check: + specifier: ^4.2.1 + version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + tailwindcss: + specifier: ^4.1.7 + version: 4.1.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: + specifier: ^3.2.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + + apps/taktik/packages/shared: + dependencies: + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/todo: devDependencies: typescript: @@ -21416,9 +21532,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/tailwind@5.1.5(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': + '@astrojs/tailwind@5.1.5(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: - astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) autoprefixer: 10.4.22(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -21426,6 +21542,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': + dependencies: + astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + autoprefixer: 10.4.22(postcss@8.5.6) + postcss: 8.5.6 + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.1) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) @@ -23578,6 +23704,11 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))': + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -23942,7 +24073,7 @@ snapshots: ws: 8.18.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.5(oinrqag3kg73e5vim3pjq4pqwa) + expo-router: 55.0.5(3s5jslrd73ksoqlrblc4nkbaxq) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) transitivePeerDependencies: - '@expo/dom-webview' @@ -24108,7 +24239,6 @@ snapshots: - supports-color - typescript - utf-8-validate - optional: true '@expo/code-signing-certificates@0.0.5': dependencies: @@ -24263,7 +24393,6 @@ snapshots: optionalDependencies: react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true '@expo/dom-webview@55.0.3(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': dependencies: @@ -24289,7 +24418,6 @@ snapshots: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true '@expo/env@2.0.7': dependencies: @@ -24416,7 +24544,6 @@ snapshots: react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) stacktrace-parser: 0.1.11 - optional: true '@expo/mcp-tunnel@0.1.0': dependencies: @@ -24537,7 +24664,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) transitivePeerDependencies: - bufferutil - supports-color @@ -24718,7 +24845,7 @@ snapshots: '@expo/json-file': 10.0.12 '@react-native/normalize-colors': 0.83.2 debug: 4.4.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -24785,7 +24912,6 @@ snapshots: react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - supports-color - optional: true '@expo/schema-utils@0.1.7': {} @@ -24847,7 +24973,6 @@ snapshots: expo-font: 55.0.4(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true '@expo/ws-tunnel@1.0.6': {} @@ -27252,8 +27377,7 @@ snapshots: '@react-native/assets-registry@0.83.2': {} - '@react-native/assets-registry@0.84.1': - optional: true + '@react-native/assets-registry@0.84.1': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.28.5)': dependencies: @@ -27410,7 +27534,6 @@ snapshots: nullthrows: 1.1.1 tinyglobby: 0.2.15 yargs: 17.7.2 - optional: true '@react-native/community-cli-plugin@0.81.4': dependencies: @@ -27467,7 +27590,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true '@react-native/debugger-frontend@0.81.4': {} @@ -27475,8 +27597,7 @@ snapshots: '@react-native/debugger-frontend@0.83.2': {} - '@react-native/debugger-frontend@0.84.1': - optional: true + '@react-native/debugger-frontend@0.84.1': {} '@react-native/debugger-shell@0.83.2': dependencies: @@ -27490,7 +27611,6 @@ snapshots: fb-dotslash: 0.5.8 transitivePeerDependencies: - supports-color - optional: true '@react-native/dev-middleware@0.81.4': dependencies: @@ -27565,7 +27685,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true '@react-native/gradle-plugin@0.81.4': {} @@ -27573,8 +27692,7 @@ snapshots: '@react-native/gradle-plugin@0.83.2': {} - '@react-native/gradle-plugin@0.84.1': - optional: true + '@react-native/gradle-plugin@0.84.1': {} '@react-native/js-polyfills@0.81.4': {} @@ -27582,8 +27700,7 @@ snapshots: '@react-native/js-polyfills@0.83.2': {} - '@react-native/js-polyfills@0.84.1': - optional: true + '@react-native/js-polyfills@0.84.1': {} '@react-native/normalize-colors@0.74.89': {} @@ -27593,8 +27710,7 @@ snapshots: '@react-native/normalize-colors@0.83.2': {} - '@react-native/normalize-colors@0.84.1': - optional: true + '@react-native/normalize-colors@0.84.1': {} '@react-native/virtualized-lists@0.81.4(@types/react@19.2.14)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': dependencies: @@ -27649,7 +27765,6 @@ snapshots: react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) optionalDependencies: '@types/react': 19.2.14 - optional: true '@react-navigation/bottom-tabs@7.15.5(@react-navigation/native@7.1.33(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.7.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-screens@4.24.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': dependencies: @@ -30999,11 +31114,11 @@ snapshots: - vite optional: true - '@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)': + '@vitest/browser@3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 @@ -31133,6 +31248,15 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + optional: true + '@vitest/mocker@4.0.14(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.14 @@ -31315,7 +31439,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) optional: true '@vitest/ui@4.0.14(vitest@4.0.14)': @@ -31827,6 +31951,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 3.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.0 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.5.0 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.3.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.5.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.6.0 + unist-util-visit: 5.0.0 + unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2) + vfile: 6.0.3 + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -31929,108 +32155,6 @@ snapshots: - uploadthing - yaml - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): - dependencies: - '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/markdown-remark': 6.3.9 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.53.3) - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.3.1 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.0 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.5.0 - diff: 5.2.0 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.3.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.1 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.5.0 - piccolore: 0.1.3 - picomatch: 4.0.3 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.3 - shiki: 3.15.0 - smol-toml: 1.5.2 - svgo: 4.0.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.6.0 - unist-util-visit: 5.0.0 - unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2) - vfile: 6.0.3 - vite: 6.4.1(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -34255,6 +34379,11 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34323,6 +34452,10 @@ snapshots: dependencies: eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34484,6 +34617,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.48.0 + astro-eslint-parser: 1.2.2 + eslint: 9.39.1(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -34915,6 +35062,47 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.39.1(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.1(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -35170,7 +35358,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript - optional: true expo-audio@55.0.8(expo-asset@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: @@ -35280,7 +35467,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript - optional: true expo-dev-client@6.0.18(expo@55.0.5): dependencies: @@ -35340,7 +35526,6 @@ snapshots: dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true expo-font@14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: @@ -35383,7 +35568,6 @@ snapshots: fontfaceobserver: 2.3.0 react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true expo-glass-effect@55.0.8(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: @@ -35417,11 +35601,11 @@ snapshots: expo-image-loader@55.0.0(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-image-picker@55.0.12(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-image-loader: 55.0.0(expo@55.0.5) expo-image@55.0.6(expo@54.0.25)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): @@ -35478,7 +35662,6 @@ snapshots: dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 - optional: true expo-linear-gradient@15.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: @@ -35644,7 +35827,6 @@ snapshots: invariant: 2.2.4 react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - optional: true expo-notifications@55.0.12(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: @@ -36114,7 +36296,7 @@ snapshots: expo-secure-store@55.0.8(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) expo-server@1.0.4: {} @@ -36519,7 +36701,6 @@ snapshots: - supports-color - typescript - utf-8-validate - optional: true exponential-backoff@3.1.3: {} @@ -37296,8 +37477,7 @@ snapshots: hermes-compiler@0.14.1: {} - hermes-compiler@250829098.0.9: - optional: true + hermes-compiler@250829098.0.9: {} hermes-estree@0.29.1: {} @@ -42293,7 +42473,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true react-refresh@0.14.2: {} @@ -44709,6 +44888,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 + vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.1 + vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -44743,23 +44939,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 - vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.1 - vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -44811,6 +44990,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): + optionalDependencies: + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -44819,10 +45002,6 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - vitefu@1.1.1(vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): - optionalDependencies: - vite: 6.4.1(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - vitefu@1.1.1(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -45096,7 +45275,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.1 - '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4) + '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 29.0.1(@noble/hashes@2.0.1) transitivePeerDependencies: