mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat: add Taktik time tracking app with full MVP
New app for professional time tracking with timer, projects, clients, and reports. Local-first architecture with IndexedDB + mana-sync. - Timer store with start/stop/resume, auto-save every 10s - 6 local-store collections: clients, projects, timeEntries, tags, templates, settings - TimerCard with live counter, project selector, billable toggle - EntryItem with inline-expand editing, EntryList with day grouping - EntryForm modal with quick-duration buttons (15m-4h) - QuickStart pills from recent entries - Projects page: card grid, color coding, budget progress, inline CRUD - Clients page: billing rates, project rollup, inline CRUD - Reports page: stats grid, billable breakdown, project/daily charts - i18n: German + English - Registered in shared-branding with icon, URLs, dev scripts - Guest seed: 2 clients, 3 projects, 5 time entries, 4 tags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d935e07cbd
commit
3f0e330884
55 changed files with 4738 additions and 174 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
73
apps/taktik/CLAUDE.md
Normal file
73
apps/taktik/CLAUDE.md
Normal file
|
|
@ -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
|
||||
```
|
||||
52
apps/taktik/apps/web/package.json
Normal file
52
apps/taktik/apps/web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
43
apps/taktik/apps/web/src/app.css
Normal file
43
apps/taktik/apps/web/src/app.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
18
apps/taktik/apps/web/src/app.html
Normal file
18
apps/taktik/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f59e0b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<title>Taktik</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
240
apps/taktik/apps/web/src/lib/components/EntryForm.svelte
Normal file
240
apps/taktik/apps/web/src/lib/components/EntryForm.svelte
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { timeEntryCollection } from '$lib/data/local-store';
|
||||
import type { Project, Client } from '@taktik/shared';
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let description = $state('');
|
||||
let projectId = $state('');
|
||||
let date = $state(new Date().toISOString().split('T')[0]);
|
||||
let durationHours = $state(1);
|
||||
let durationMinutes = $state(0);
|
||||
let isBillable = $state(false);
|
||||
|
||||
let activeProjects = $derived(
|
||||
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
description = '';
|
||||
projectId = '';
|
||||
date = new Date().toISOString().split('T')[0];
|
||||
durationHours = 1;
|
||||
durationMinutes = 0;
|
||||
isBillable = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const totalSeconds = durationHours * 3600 + durationMinutes * 60;
|
||||
if (totalSeconds <= 0) return;
|
||||
|
||||
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
|
||||
|
||||
await timeEntryCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
projectId: projectId || null,
|
||||
clientId: project?.clientId ?? null,
|
||||
description,
|
||||
date,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
duration: totalSeconds,
|
||||
isBillable,
|
||||
isRunning: false,
|
||||
tags: [],
|
||||
billingRate: null,
|
||||
visibility: 'private',
|
||||
guildId: null,
|
||||
source: { app: 'manual' },
|
||||
});
|
||||
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleProjectChange(id: string) {
|
||||
projectId = id;
|
||||
const project = allProjects.value.find((p) => p.id === id);
|
||||
if (project) {
|
||||
isBillable = project.isBillable;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick duration buttons
|
||||
const quickDurations = [
|
||||
{ label: '15m', h: 0, m: 15 },
|
||||
{ label: '30m', h: 0, m: 30 },
|
||||
{ label: '1h', h: 1, m: 0 },
|
||||
{ label: '1.5h', h: 1, m: 30 },
|
||||
{ label: '2h', h: 2, m: 0 },
|
||||
{ label: '4h', h: 4, m: 0 },
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-sm sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg rounded-t-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl sm:rounded-2xl"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('entry.manual')}
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Description -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$_('entry.description')}
|
||||
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))] focus:border-[hsl(var(--primary))] focus:outline-none"
|
||||
/>
|
||||
|
||||
<!-- Project -->
|
||||
<select
|
||||
value={projectId}
|
||||
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each activeProjects as proj}
|
||||
<option value={proj.id}>
|
||||
{proj.name}
|
||||
{#if proj.clientId}
|
||||
{@const client = allClients.value.find((c) => c.id === proj.clientId)}
|
||||
{#if client}
|
||||
· {client.name}{/if}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Date -->
|
||||
<input
|
||||
type="date"
|
||||
bind:value={date}
|
||||
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))]"
|
||||
/>
|
||||
|
||||
<!-- Quick Duration Buttons -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('entry.duration')}
|
||||
</label>
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each quickDurations as qd}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
durationHours = qd.h;
|
||||
durationMinutes = qd.m;
|
||||
}}
|
||||
class="rounded-lg border px-3 py-1.5 text-xs transition-colors {durationHours ===
|
||||
qd.h && durationMinutes === qd.m
|
||||
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
|
||||
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))] hover:border-[hsl(var(--primary)/0.5)]'}"
|
||||
>
|
||||
{qd.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Custom Duration -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={durationHours}
|
||||
min="0"
|
||||
max="24"
|
||||
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">h</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={durationMinutes}
|
||||
min="0"
|
||||
max="59"
|
||||
step="5"
|
||||
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billable -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isBillable = !isBillable)}
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors {isBillable
|
||||
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
|
||||
: 'text-[hsl(var(--muted-foreground))]'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{isBillable ? $_('entry.billable') : $_('entry.notBillable')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
{$_('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
199
apps/taktik/apps/web/src/lib/components/EntryItem.svelte
Normal file
199
apps/taktik/apps/web/src/lib/components/EntryItem.svelte
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { timeEntryCollection } from '$lib/data/local-store';
|
||||
import { formatDurationCompact } from '$lib/data/queries';
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
|
||||
let {
|
||||
entry,
|
||||
isExpanded = false,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
}: {
|
||||
entry: TimeEntry;
|
||||
isExpanded?: boolean;
|
||||
onExpand?: () => void;
|
||||
onCollapse?: () => void;
|
||||
} = $props();
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let editDescription = $state(entry.description);
|
||||
let editProjectId = $state(entry.projectId ?? '');
|
||||
let editIsBillable = $state(entry.isBillable);
|
||||
let editDurationMinutes = $state(Math.round(entry.duration / 60));
|
||||
|
||||
// Sync when entry changes
|
||||
$effect(() => {
|
||||
editDescription = entry.description;
|
||||
editProjectId = entry.projectId ?? '';
|
||||
editIsBillable = entry.isBillable;
|
||||
editDurationMinutes = Math.round(entry.duration / 60);
|
||||
});
|
||||
|
||||
let project = $derived(
|
||||
entry.projectId ? allProjects.value.find((p) => p.id === entry.projectId) : undefined
|
||||
);
|
||||
let client = $derived(
|
||||
entry.clientId ? allClients.value.find((c) => c.id === entry.clientId) : undefined
|
||||
);
|
||||
|
||||
let saveDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function autoSave(updates: Record<string, unknown>) {
|
||||
if (saveDebounce) clearTimeout(saveDebounce);
|
||||
saveDebounce = setTimeout(async () => {
|
||||
await timeEntryCollection.update(entry.id, updates);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleDescriptionChange(value: string) {
|
||||
editDescription = value;
|
||||
autoSave({ description: value });
|
||||
}
|
||||
|
||||
function handleProjectChange(projectId: string) {
|
||||
editProjectId = projectId;
|
||||
const proj = allProjects.value.find((p) => p.id === projectId);
|
||||
autoSave({
|
||||
projectId: projectId || null,
|
||||
clientId: proj?.clientId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function handleBillableToggle() {
|
||||
editIsBillable = !editIsBillable;
|
||||
autoSave({ isBillable: editIsBillable });
|
||||
}
|
||||
|
||||
function handleDurationChange(minutes: number) {
|
||||
editDurationMinutes = minutes;
|
||||
autoSave({ duration: minutes * 60 });
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await timeEntryCollection.delete(entry.id);
|
||||
onCollapse?.();
|
||||
}
|
||||
|
||||
let startTimeStr = $derived(
|
||||
entry.startTime
|
||||
? new Date(entry.startTime).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: ''
|
||||
);
|
||||
let endTimeStr = $derived(
|
||||
entry.endTime
|
||||
? new Date(entry.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="entry-item rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] transition-all {isExpanded
|
||||
? 'ring-1 ring-[hsl(var(--primary)/0.3)]'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Compact row (always visible) -->
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
onclick={() => (isExpanded ? onCollapse?.() : onExpand?.())}
|
||||
>
|
||||
{#if project}
|
||||
<div class="project-dot" style="background-color: {project.color}"></div>
|
||||
{:else}
|
||||
<div class="project-dot" style="background-color: #9ca3af"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{entry.description || $_('timer.noDescription')}
|
||||
</p>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{project?.name || $_('project.internal')}
|
||||
{#if client}· {client.name}{/if}
|
||||
{#if startTimeStr && endTimeStr}
|
||||
· {startTimeStr} – {endTimeStr}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(entry.duration)}
|
||||
</p>
|
||||
{#if entry.isBillable}
|
||||
<span class="text-xs text-[hsl(var(--primary))]">$</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Expanded edit form -->
|
||||
{#if isExpanded}
|
||||
<div class="border-t border-[hsl(var(--border))] px-4 py-3 space-y-3">
|
||||
<!-- Description -->
|
||||
<input
|
||||
type="text"
|
||||
value={editDescription}
|
||||
oninput={(e) => 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"
|
||||
/>
|
||||
|
||||
<!-- Project + Duration row -->
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={editProjectId}
|
||||
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each allProjects.value.filter((p) => !p.isArchived) as proj}
|
||||
<option value={proj.id}>{proj.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={editDurationMinutes}
|
||||
oninput={(e) =>
|
||||
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))]"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billable + Delete row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
onclick={handleBillableToggle}
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors {editIsBillable
|
||||
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{editIsBillable ? $_('entry.billable') : $_('entry.notBillable')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="rounded-lg px-3 py-1.5 text-xs text-red-500 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
{$_('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
79
apps/taktik/apps/web/src/lib/components/EntryList.svelte
Normal file
79
apps/taktik/apps/web/src/lib/components/EntryList.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import EntryItem from './EntryItem.svelte';
|
||||
import { groupEntriesByDate, getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||
import type { TimeEntry } from '@taktik/shared';
|
||||
|
||||
let { entries }: { entries: TimeEntry[] } = $props();
|
||||
|
||||
let expandedEntryId = $state<string | null>(null);
|
||||
|
||||
let groupedEntries = $derived(() => {
|
||||
const groups = groupEntriesByDate(entries);
|
||||
// Sort dates descending (newest first)
|
||||
return [...groups.entries()].sort(([a], [b]) => b.localeCompare(a));
|
||||
});
|
||||
|
||||
function formatDateHeader(dateStr: string): string {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||||
|
||||
if (dateStr === today) return $_('entry.today');
|
||||
if (dateStr === yesterday) return 'Gestern';
|
||||
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if entries.length === 0}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-3 h-10 w-10 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p>{$_('entry.noEntries')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each groupedEntries() as [date, dayEntries]}
|
||||
<div>
|
||||
<!-- Day Header -->
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{formatDateHeader(date)}
|
||||
</h3>
|
||||
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(getTotalDuration(dayEntries))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Entries for this day -->
|
||||
<div class="space-y-2">
|
||||
{#each dayEntries as entry (entry.id)}
|
||||
<EntryItem
|
||||
{entry}
|
||||
isExpanded={expandedEntryId === entry.id}
|
||||
onExpand={() => (expandedEntryId = entry.id)}
|
||||
onCollapse={() => (expandedEntryId = null)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
59
apps/taktik/apps/web/src/lib/components/QuickStart.svelte
Normal file
59
apps/taktik/apps/web/src/lib/components/QuickStart.svelte
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import type { TimeEntry, Project } from '@taktik/shared';
|
||||
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
|
||||
// Get unique recent entries (by description+project, deduplicated)
|
||||
let recentEntries = $derived(() => {
|
||||
const seen = new Set<string>();
|
||||
return allTimeEntries.value
|
||||
.filter((e) => !e.isRunning && e.description)
|
||||
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
|
||||
.filter((e) => {
|
||||
const key = `${e.description}|${e.projectId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
async function startFromEntry(entry: TimeEntry) {
|
||||
await timerStore.start({
|
||||
projectId: entry.projectId,
|
||||
clientId: entry.clientId,
|
||||
description: entry.description,
|
||||
isBillable: entry.isBillable,
|
||||
tags: entry.tags,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recentEntries().length > 0}
|
||||
<div>
|
||||
<h3 class="mb-2 text-xs font-medium text-[hsl(var(--muted-foreground))]">Quick Start</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each recentEntries() as entry}
|
||||
{@const project = entry.projectId
|
||||
? allProjects.value.find((p) => p.id === entry.projectId)
|
||||
: undefined}
|
||||
<button
|
||||
onclick={() => startFromEntry(entry)}
|
||||
disabled={timerStore.isRunning}
|
||||
class="flex items-center gap-1.5 rounded-full border border-[hsl(var(--border))] px-3 py-1.5 text-xs transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:bg-[hsl(var(--primary)/0.05)] disabled:opacity-50"
|
||||
>
|
||||
{#if project}
|
||||
<div class="h-2 w-2 rounded-full" style="background-color: {project.color}"></div>
|
||||
{/if}
|
||||
<span class="max-w-[150px] truncate text-[hsl(var(--foreground))]"
|
||||
>{entry.description}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
199
apps/taktik/apps/web/src/lib/components/TimerCard.svelte
Normal file
199
apps/taktik/apps/web/src/lib/components/TimerCard.svelte
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import { formatDuration } from '$lib/data/queries';
|
||||
import type { Project, Client } from '@taktik/shared';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let description = $state('');
|
||||
let selectedProjectId = $state<string | null>(null);
|
||||
let isBillable = $state(false);
|
||||
|
||||
// Sync description with running entry
|
||||
$effect(() => {
|
||||
if (timerStore.runningEntry) {
|
||||
description = timerStore.runningEntry.description || '';
|
||||
selectedProjectId = timerStore.runningEntry.projectId ?? null;
|
||||
isBillable = timerStore.runningEntry.isBillable;
|
||||
}
|
||||
});
|
||||
|
||||
let activeProjects = $derived(
|
||||
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
|
||||
let selectedProject = $derived(
|
||||
selectedProjectId ? allProjects.value.find((p) => p.id === selectedProjectId) : null
|
||||
);
|
||||
|
||||
let selectedClient = $derived(
|
||||
selectedProject?.clientId
|
||||
? allClients.value.find((c) => c.id === selectedProject!.clientId)
|
||||
: null
|
||||
);
|
||||
|
||||
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
|
||||
|
||||
async function handleStartStop() {
|
||||
if (timerStore.isRunning) {
|
||||
await timerStore.stop();
|
||||
description = '';
|
||||
selectedProjectId = null;
|
||||
isBillable = false;
|
||||
} else {
|
||||
const clientId = selectedProject?.clientId ?? undefined;
|
||||
await timerStore.start({
|
||||
description,
|
||||
projectId: selectedProjectId ?? undefined,
|
||||
clientId,
|
||||
isBillable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let descriptionDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleDescriptionChange(value: string) {
|
||||
description = value;
|
||||
if (!timerStore.isRunning) return;
|
||||
if (descriptionDebounce) clearTimeout(descriptionDebounce);
|
||||
descriptionDebounce = setTimeout(() => {
|
||||
timerStore.updateRunning({ description: value });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleProjectChange(projectId: string | null) {
|
||||
selectedProjectId = projectId;
|
||||
if (!timerStore.isRunning) return;
|
||||
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
|
||||
await timerStore.updateRunning({
|
||||
projectId: projectId ?? undefined,
|
||||
clientId: project?.clientId ?? undefined,
|
||||
isBillable: project?.isBillable ?? isBillable,
|
||||
});
|
||||
if (project) {
|
||||
isBillable = project.isBillable;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBillableToggle() {
|
||||
isBillable = !isBillable;
|
||||
if (timerStore.isRunning) {
|
||||
await timerStore.updateRunning({ isBillable });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 {timerStore.isRunning
|
||||
? 'timer-active'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Timer Display -->
|
||||
<div class="mb-4 text-center">
|
||||
<div
|
||||
class="duration-display text-5xl font-bold {timerStore.isRunning
|
||||
? 'text-[hsl(var(--primary))]'
|
||||
: 'text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Input -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
oninput={(e) => 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))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Project & Billable Row -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<!-- Project Selector -->
|
||||
<div class="flex-1">
|
||||
<select
|
||||
value={selectedProjectId ?? ''}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
handleProjectChange(val || null);
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="">{$_('project.noProjects')}</option>
|
||||
{#each activeProjects as project}
|
||||
<option value={project.id}>
|
||||
{project.name}
|
||||
{#if project.clientId}
|
||||
{@const client = allClients.value.find((c) => c.id === project.clientId)}
|
||||
{#if client}
|
||||
· {client.name}{/if}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Billable Toggle -->
|
||||
<button
|
||||
onclick={handleBillableToggle}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg border transition-colors {isBillable
|
||||
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
|
||||
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
title={isBillable ? $_('entry.billable') : $_('entry.notBillable')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Start/Stop Button -->
|
||||
<button
|
||||
onclick={handleStartStop}
|
||||
class="w-full rounded-xl py-3 text-lg font-medium transition-all {timerStore.isRunning
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
|
||||
>
|
||||
{#if timerStore.isRunning}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
{$_('timer.stop')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
{$_('timer.start')}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Running info -->
|
||||
{#if timerStore.isRunning && selectedProject}
|
||||
<div
|
||||
class="mt-3 flex items-center justify-center gap-2 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<div class="project-dot" style="background-color: {selectedProject.color}"></div>
|
||||
<span>{selectedProject.name}</span>
|
||||
{#if selectedClient}
|
||||
<span>· {selectedClient.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
183
apps/taktik/apps/web/src/lib/data/guest-seed.ts
Normal file
183
apps/taktik/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
153
apps/taktik/apps/web/src/lib/data/local-store.ts
Normal file
153
apps/taktik/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -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<LocalClient>('clients');
|
||||
export const projectCollection = taktikStore.collection<LocalProject>('projects');
|
||||
export const timeEntryCollection = taktikStore.collection<LocalTimeEntry>('timeEntries');
|
||||
export const tagCollection = taktikStore.collection<LocalTag>('tags');
|
||||
export const templateCollection = taktikStore.collection<LocalTemplate>('templates');
|
||||
export const settingsCollection = taktikStore.collection<LocalSettings>('settings');
|
||||
338
apps/taktik/apps/web/src/lib/data/queries.ts
Normal file
338
apps/taktik/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -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<string, TimeEntry[]> {
|
||||
const groups = new Map<string, TimeEntry[]>();
|
||||
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<string, TimeEntry[]> {
|
||||
const groups = new Map<string, TimeEntry[]>();
|
||||
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);
|
||||
}
|
||||
38
apps/taktik/apps/web/src/lib/i18n/index.ts
Normal file
38
apps/taktik/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -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 };
|
||||
135
apps/taktik/apps/web/src/lib/i18n/locales/de.json
Normal file
135
apps/taktik/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
135
apps/taktik/apps/web/src/lib/i18n/locales/en.json
Normal file
135
apps/taktik/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
7
apps/taktik/apps/web/src/lib/stores/auth.svelte.ts
Normal file
7
apps/taktik/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore();
|
||||
6
apps/taktik/apps/web/src/lib/stores/navigation.ts
Normal file
6
apps/taktik/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed, isToolbarCollapsed } = createSimpleNavigationStores({
|
||||
withToolbar: true,
|
||||
toolbarCollapsedDefault: true,
|
||||
});
|
||||
6
apps/taktik/apps/web/src/lib/stores/theme.ts
Normal file
6
apps/taktik/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'taktik',
|
||||
defaultVariant: 'ocean',
|
||||
});
|
||||
164
apps/taktik/apps/web/src/lib/stores/timer.svelte.ts
Normal file
164
apps/taktik/apps/web/src/lib/stores/timer.svelte.ts
Normal file
|
|
@ -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<LocalTimeEntry | null>(null);
|
||||
let elapsedSeconds = $state(0);
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let autoSaveInterval: ReturnType<typeof setInterval> | 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<LocalTimeEntry | null> {
|
||||
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<LocalTimeEntry, 'projectId' | 'clientId' | 'description' | 'isBillable' | 'tags'>
|
||||
>
|
||||
) {
|
||||
if (!runningEntry) return;
|
||||
await timeEntryCollection.update(runningEntry.id, updates);
|
||||
runningEntry = { ...runningEntry, ...updates };
|
||||
},
|
||||
|
||||
/** Cleanup on unmount */
|
||||
destroy() {
|
||||
stopTicking();
|
||||
},
|
||||
};
|
||||
18
apps/taktik/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
18
apps/taktik/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -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(),
|
||||
});
|
||||
106
apps/taktik/apps/web/src/lib/stores/view.svelte.ts
Normal file
106
apps/taktik/apps/web/src/lib/stores/view.svelte.ts
Normal file
|
|
@ -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<T>(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<ViewMode>('week');
|
||||
let sort = $state<SortOption>({ field: 'date', direction: 'desc' });
|
||||
let activeFilters = $state<FilterCriteria>({});
|
||||
let savedFilters = $state<SavedFilter[]>([]);
|
||||
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<ViewMode>(VIEW_KEY, 'week');
|
||||
sort = load<SortOption>(SORT_KEY, { field: 'date', direction: 'desc' });
|
||||
savedFilters = load<SavedFilter[]>(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<K extends keyof FilterCriteria>(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);
|
||||
},
|
||||
};
|
||||
7
apps/taktik/apps/web/src/lib/version.ts
Normal file
7
apps/taktik/apps/web/src/lib/version.ts
Normal file
|
|
@ -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';
|
||||
203
apps/taktik/apps/web/src/routes/(app)/+layout.svelte
Normal file
203
apps/taktik/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { setContext } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { SyncIndicator } from '@manacore/shared-ui';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { taktikStore } from '$lib/data/local-store';
|
||||
import {
|
||||
useAllClients,
|
||||
useAllProjects,
|
||||
useAllTimeEntries,
|
||||
useAllTags,
|
||||
useAllTemplates,
|
||||
useSettings,
|
||||
} from '$lib/data/queries';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let showNav = $state(true);
|
||||
let initialized = $state(false);
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
// Live queries
|
||||
const allClients = useAllClients();
|
||||
const allProjects = useAllProjects();
|
||||
const allTimeEntries = useAllTimeEntries();
|
||||
const allTags = useAllTags();
|
||||
const allTemplates = useAllTemplates();
|
||||
const settings = useSettings();
|
||||
|
||||
// Provide data to child components
|
||||
setContext('clients', allClients);
|
||||
setContext('projects', allProjects);
|
||||
setContext('timeEntries', allTimeEntries);
|
||||
setContext('tags', allTags);
|
||||
setContext('templates', allTemplates);
|
||||
setContext('settings', settings);
|
||||
|
||||
async function handleAuthReady() {
|
||||
await taktikStore.initialize();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
taktikStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
viewStore.initialize();
|
||||
initialized = true;
|
||||
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('taktik')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: $_('nav.timer'), icon: 'play-circle' },
|
||||
{ href: '/entries', label: $_('nav.entries'), icon: 'list' },
|
||||
{ href: '/projects', label: $_('nav.projects'), icon: 'folder' },
|
||||
{ href: '/clients', label: $_('nav.clients'), icon: 'buildings' },
|
||||
{ href: '/reports', label: $_('nav.reports'), icon: 'chart-bar' },
|
||||
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
|
||||
{ href: '/mana', label: 'Mana', icon: 'star' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
function handleLogout() {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<!-- Top Navigation -->
|
||||
{#if showNav}
|
||||
<nav
|
||||
class="sticky top-0 z-40 border-b border-[hsl(var(--border))] bg-[hsl(var(--background))/0.95] backdrop-blur"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-2">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-[hsl(var(--primary))]"
|
||||
>
|
||||
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Taktik</span>
|
||||
</a>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {$page.url.pathname ===
|
||||
item.href ||
|
||||
($page.url.pathname.startsWith(item.href) && item.href !== '/')
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)] hover:text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="rounded-lg border border-[hsl(var(--border))] bg-transparent px-2 py-1 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
{#each supportedLocales as loc}
|
||||
<option value={loc}>{loc.toUpperCase()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
title="Abmelden"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile nav -->
|
||||
<div class="flex gap-1 overflow-x-auto px-4 pb-2 md:hidden">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="shrink-0 rounded-full px-3 py-1 text-xs transition-colors {$page.url
|
||||
.pathname === item.href
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- FAB for mobile - Start Timer -->
|
||||
<button
|
||||
onclick={() => goto('/?action=start')}
|
||||
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] shadow-lg transition-transform hover:scale-105 md:hidden"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<GuestWelcomeModal
|
||||
appId="taktik"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale="de"
|
||||
/>
|
||||
<SyncIndicator />
|
||||
</AuthGate>
|
||||
84
apps/taktik/apps/web/src/routes/(app)/+page.svelte
Normal file
84
apps/taktik/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
import {
|
||||
getEntriesByDate,
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
} from '$lib/data/queries';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import TimerCard from '$lib/components/TimerCard.svelte';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import EntryForm from '$lib/components/EntryForm.svelte';
|
||||
import QuickStart from '$lib/components/QuickStart.svelte';
|
||||
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
let todayEntries = $derived(
|
||||
getEntriesByDate(allTimeEntries.value, today).filter((e) => !e.isRunning)
|
||||
);
|
||||
let todayTotal = $derived(getTotalDuration(todayEntries));
|
||||
let todayBillable = $derived(getBillableDuration(todayEntries));
|
||||
|
||||
let showEntryForm = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await timerStore.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
timerStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Timer | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Timer Card -->
|
||||
<TimerCard />
|
||||
|
||||
<!-- Today's Summary -->
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.total')}</p>
|
||||
<p class="duration-display text-2xl font-bold text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(todayTotal)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('entry.billable')}</p>
|
||||
<p class="duration-display text-2xl font-bold text-[hsl(var(--primary))]">
|
||||
{formatDurationCompact(todayBillable)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start -->
|
||||
<QuickStart />
|
||||
|
||||
<!-- Today's Entries -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('entry.today')} ({formatDurationCompact(todayTotal)})
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => (showEntryForm = true)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
+ {$_('entry.manual')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EntryList entries={todayEntries} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Entry Form -->
|
||||
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
|
||||
347
apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte
Normal file
347
apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { clientCollection } from '$lib/data/local-store';
|
||||
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||
import type { Client, Project, TimeEntry } from '@taktik/shared';
|
||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
||||
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let editingClientId = $state<string | null>(null);
|
||||
let showArchived = $state(false);
|
||||
|
||||
let newName = $state('');
|
||||
let newShortCode = $state('');
|
||||
let newEmail = $state('');
|
||||
let newColor = $state(PROJECT_COLORS[4]);
|
||||
let newRate = $state(0);
|
||||
|
||||
let activeClients = $derived(
|
||||
allClients.value.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
let archivedClients = $derived(allClients.value.filter((c) => c.isArchived));
|
||||
|
||||
function getClientProjects(clientId: string): Project[] {
|
||||
return allProjects.value.filter((p) => p.clientId === clientId && !p.isArchived);
|
||||
}
|
||||
|
||||
function getClientHours(clientId: string): number {
|
||||
return getTotalDuration(
|
||||
allTimeEntries.value.filter((e) => e.clientId === clientId && !e.isRunning)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
await clientCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
name: newName.trim(),
|
||||
shortCode: newShortCode || null,
|
||||
contactId: null,
|
||||
email: newEmail || null,
|
||||
color: newColor,
|
||||
isArchived: false,
|
||||
billingRate: newRate > 0 ? { amount: newRate, currency: 'EUR', per: 'hour' } : null,
|
||||
notes: null,
|
||||
order: activeClients.length,
|
||||
});
|
||||
newName = '';
|
||||
newShortCode = '';
|
||||
newEmail = '';
|
||||
newRate = 0;
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
async function handleArchive(id: string, archive: boolean) {
|
||||
await clientCollection.update(id, { isArchived: archive });
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await clientCollection.delete(id);
|
||||
editingClientId = null;
|
||||
}
|
||||
|
||||
// Edit state
|
||||
let editName = $state('');
|
||||
let editShortCode = $state('');
|
||||
let editEmail = $state('');
|
||||
let editColor = $state('');
|
||||
let editRate = $state(0);
|
||||
|
||||
function startEditing(client: Client) {
|
||||
editingClientId = client.id;
|
||||
editName = client.name;
|
||||
editShortCode = client.shortCode ?? '';
|
||||
editEmail = client.email ?? '';
|
||||
editColor = client.color;
|
||||
editRate = client.billingRate?.amount ?? 0;
|
||||
}
|
||||
|
||||
let editDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
function autoSave(updates: Record<string, unknown>) {
|
||||
if (!editingClientId) return;
|
||||
if (editDebounce) clearTimeout(editDebounce);
|
||||
const id = editingClientId;
|
||||
editDebounce = setTimeout(() => {
|
||||
clientCollection.update(id, updates);
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.clients')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.clients')}</h1>
|
||||
<button
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
+ {$_('client.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder={$_('client.name')}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newShortCode}
|
||||
placeholder={$_('client.shortCode')}
|
||||
class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder={$_('client.email')}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={newRate}
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder="0"
|
||||
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-center text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROJECT_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (newColor = color)}
|
||||
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
|
||||
? 'scale-125 border-white'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = false)}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('common.cancel')}</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>{$_('common.create')}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if activeClients.length === 0 && !showCreateForm}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<p>{$_('client.noClients')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each activeClients as client (client.id)}
|
||||
{@const projects = getClientProjects(client.id)}
|
||||
{@const hours = getClientHours(client.id)}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
|
||||
>
|
||||
{#if editingClientId === client.id}
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editShortCode}
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={editEmail}
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={editRate}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each PROJECT_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editColor = color;
|
||||
autoSave({ color });
|
||||
}}
|
||||
class="h-5 w-5 rounded-full border-2 {editColor === color
|
||||
? 'border-white scale-110'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => handleArchive(client.id, true)}
|
||||
class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.archive')}</button
|
||||
>
|
||||
<button onclick={() => handleDelete(client.id)} class="text-xs text-red-500"
|
||||
>{$_('common.delete')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => (editingClientId = null)}
|
||||
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 p-4 text-left"
|
||||
onclick={() => startEditing(client)}
|
||||
>
|
||||
<div
|
||||
class="h-10 w-10 shrink-0 rounded-lg flex items-center justify-center text-white text-sm font-bold"
|
||||
style="background-color: {client.color}"
|
||||
>
|
||||
{client.shortCode || client.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-[hsl(var(--foreground))]">{client.name}</p>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{projects.length}
|
||||
{$_('nav.projects')}
|
||||
{#if client.billingRate}
|
||||
· {client.billingRate.amount}
|
||||
{client.billingRate.currency}/{$_('common.hours').toLowerCase().charAt(0)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(hours)}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if archivedClients.length > 0}
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (showArchived = !showArchived)}
|
||||
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {showArchived ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{$_('project.archived')} ({archivedClients.length})
|
||||
</button>
|
||||
{#if showArchived}
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each archivedClients as client}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-6 w-6 rounded flex items-center justify-center text-white text-xs font-bold"
|
||||
style="background-color: {client.color}"
|
||||
>
|
||||
{client.shortCode || client.name.charAt(0)}
|
||||
</div>
|
||||
<span class="text-sm">{client.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => handleArchive(client.id, false)}
|
||||
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
113
apps/taktik/apps/web/src/routes/(app)/entries/+page.svelte
Normal file
113
apps/taktik/apps/web/src/routes/(app)/entries/+page.svelte
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry } from '@taktik/shared';
|
||||
import {
|
||||
getFilteredEntries,
|
||||
getSortedEntries,
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
} from '$lib/data/queries';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import EntryForm from '$lib/components/EntryForm.svelte';
|
||||
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let showEntryForm = $state(false);
|
||||
let dateFilter = $state<'week' | 'month' | 'all'>('week');
|
||||
|
||||
let dateRange = $derived(() => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
|
||||
if (dateFilter === 'week') {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 86400000);
|
||||
return { from: weekAgo.toISOString().split('T')[0], to: today };
|
||||
}
|
||||
if (dateFilter === 'month') {
|
||||
const monthAgo = new Date(now.getTime() - 30 * 86400000);
|
||||
return { from: monthAgo.toISOString().split('T')[0], to: today };
|
||||
}
|
||||
return { from: '', to: '' };
|
||||
});
|
||||
|
||||
let filteredEntries = $derived(() => {
|
||||
const range = dateRange();
|
||||
let entries = allTimeEntries.value.filter((e) => !e.isRunning);
|
||||
|
||||
// Apply date range
|
||||
if (range.from) {
|
||||
entries = entries.filter((e) => e.date >= range.from);
|
||||
}
|
||||
if (range.to) {
|
||||
entries = entries.filter((e) => e.date <= range.to);
|
||||
}
|
||||
|
||||
// Apply view filters
|
||||
entries = getFilteredEntries(entries, viewStore.activeFilters);
|
||||
|
||||
return getSortedEntries(entries, viewStore.sort);
|
||||
});
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(filteredEntries()));
|
||||
let billableDuration = $derived(getBillableDuration(filteredEntries()));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.entries')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.entries')}</h1>
|
||||
<button
|
||||
onclick={() => (showEntryForm = true)}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
+ {$_('entry.manual')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#each ['week', 'month', 'all'] as period}
|
||||
<button
|
||||
onclick={() => (dateFilter = period as any)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {dateFilter === period
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
|
||||
>
|
||||
{period === 'week'
|
||||
? $_('entry.thisWeek')
|
||||
: period === 'month'
|
||||
? $_('entry.thisMonth')
|
||||
: 'Alle'}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="ml-auto flex items-center gap-4 text-sm">
|
||||
<span class="text-[hsl(var(--muted-foreground))]">
|
||||
{$_('common.total')}:
|
||||
<span class="duration-display font-medium text-[hsl(var(--foreground))]"
|
||||
>{formatDurationCompact(totalDuration)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="text-[hsl(var(--muted-foreground))]">
|
||||
{$_('entry.billable')}:
|
||||
<span class="duration-display font-medium text-[hsl(var(--primary))]"
|
||||
>{formatDurationCompact(billableDuration)}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry List -->
|
||||
<EntryList entries={filteredEntries()} />
|
||||
</div>
|
||||
|
||||
<!-- Manual Entry Form -->
|
||||
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
|
||||
12
apps/taktik/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Feedback</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Feedback-Formular kommt bald.</p>
|
||||
</div>
|
||||
12
apps/taktik/apps/web/src/routes/(app)/help/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/help/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hilfe | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Hilfe</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Hilfe & Dokumentation.</p>
|
||||
</div>
|
||||
12
apps/taktik/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mana | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Mana</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Mana Credits & Abo-Verwaltung.</p>
|
||||
</div>
|
||||
12
apps/taktik/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Profil</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Profil-Einstellungen.</p>
|
||||
</div>
|
||||
356
apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte
Normal file
356
apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { projectCollection } from '$lib/data/local-store';
|
||||
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let editingProjectId = $state<string | null>(null);
|
||||
let showArchived = $state(false);
|
||||
|
||||
// New project form
|
||||
let newName = $state('');
|
||||
let newClientId = $state('');
|
||||
let newColor = $state(PROJECT_COLORS[0]);
|
||||
let newIsBillable = $state(true);
|
||||
let newDescription = $state('');
|
||||
|
||||
let activeProjects = $derived(
|
||||
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
let archivedProjects = $derived(
|
||||
allProjects.value.filter((p) => p.isArchived).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
|
||||
function getProjectHours(projectId: string): number {
|
||||
const entries = allTimeEntries.value.filter((e) => e.projectId === projectId && !e.isRunning);
|
||||
return getTotalDuration(entries);
|
||||
}
|
||||
|
||||
function getBudgetPercent(project: Project): number | null {
|
||||
if (!project.budget || project.budget.type !== 'hours') return null;
|
||||
const hoursLogged = getProjectHours(project.id) / 3600;
|
||||
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
const client = newClientId ? allClients.value.find((c) => c.id === newClientId) : null;
|
||||
await projectCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
clientId: newClientId || null,
|
||||
name: newName.trim(),
|
||||
description: newDescription || null,
|
||||
color: newColor,
|
||||
isArchived: false,
|
||||
isBillable: newIsBillable,
|
||||
billingRate: client?.billingRate ?? null,
|
||||
budget: null,
|
||||
visibility: 'private',
|
||||
guildId: null,
|
||||
order: activeProjects.length,
|
||||
});
|
||||
newName = '';
|
||||
newClientId = '';
|
||||
newDescription = '';
|
||||
newColor = PROJECT_COLORS[0];
|
||||
newIsBillable = true;
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
async function handleArchive(id: string, archive: boolean) {
|
||||
await projectCollection.update(id, { isArchived: archive });
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await projectCollection.delete(id);
|
||||
editingProjectId = null;
|
||||
}
|
||||
|
||||
// Inline edit state
|
||||
let editName = $state('');
|
||||
let editClientId = $state('');
|
||||
let editColor = $state('');
|
||||
let editIsBillable = $state(false);
|
||||
let editDescription = $state('');
|
||||
|
||||
function startEditing(project: Project) {
|
||||
editingProjectId = project.id;
|
||||
editName = project.name;
|
||||
editClientId = project.clientId ?? '';
|
||||
editColor = project.color;
|
||||
editIsBillable = project.isBillable;
|
||||
editDescription = project.description ?? '';
|
||||
}
|
||||
|
||||
let editDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
function autoSaveProject(updates: Record<string, unknown>) {
|
||||
if (!editingProjectId) return;
|
||||
if (editDebounce) clearTimeout(editDebounce);
|
||||
const id = editingProjectId;
|
||||
editDebounce = setTimeout(() => {
|
||||
projectCollection.update(id, updates);
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.projects')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.projects')}</h1>
|
||||
<button
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
+ {$_('project.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
{#if showCreateForm}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder={$_('project.name')}
|
||||
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))] focus:border-[hsl(var(--primary))] focus:outline-none"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
bind:value={newClientId}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each allClients.value.filter((c) => !c.isArchived) as client}
|
||||
<option value={client.id}>{client.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label
|
||||
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={newIsBillable}
|
||||
class="accent-[hsl(var(--primary))]"
|
||||
/>
|
||||
{$_('project.billable')}
|
||||
</label>
|
||||
</div>
|
||||
<!-- Color Picker -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROJECT_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (newColor = color)}
|
||||
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
|
||||
? 'scale-125 border-white'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = false)}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('common.cancel')}</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>{$_('common.create')}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Active Projects -->
|
||||
{#if activeProjects.length === 0 && !showCreateForm}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<p>{$_('project.noProjects')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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)}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
<div class="h-1" style="background-color: {project.color}"></div>
|
||||
|
||||
{#if editingProjectId === project.id}
|
||||
<!-- Editing -->
|
||||
<div class="p-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
value={editClientId}
|
||||
onchange={(e) => {
|
||||
editClientId = (e.target as HTMLSelectElement).value;
|
||||
autoSaveProject({ clientId: editClientId || null });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each allClients.value.filter((c) => !c.isArchived) as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each PROJECT_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editColor = color;
|
||||
autoSaveProject({ color });
|
||||
}}
|
||||
class="h-5 w-5 rounded-full border-2 {editColor === color
|
||||
? 'border-white scale-110'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editIsBillable}
|
||||
onchange={() => {
|
||||
editIsBillable = !editIsBillable;
|
||||
autoSaveProject({ isBillable: editIsBillable });
|
||||
}}
|
||||
class="accent-[hsl(var(--primary))]"
|
||||
/>
|
||||
{$_('project.billable')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleArchive(project.id, true)}
|
||||
class="text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>{$_('common.archive')}</button
|
||||
>
|
||||
<button onclick={() => handleDelete(project.id)} class="text-xs text-red-500"
|
||||
>{$_('common.delete')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => (editingProjectId = null)}
|
||||
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Display -->
|
||||
<button class="w-full p-4 text-left" onclick={() => startEditing(project)}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-[hsl(var(--foreground))]">{project.name}</p>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{client?.name || $_('project.internal')}
|
||||
{#if project.isBillable}
|
||||
· {$_('project.billable')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(hours)}
|
||||
</span>
|
||||
</div>
|
||||
{#if budgetPct !== null}
|
||||
<div class="mt-3">
|
||||
<div
|
||||
class="flex items-center justify-between text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<span>{$_('project.budget')}</span>
|
||||
<span>{budgetPct}%</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 rounded-full bg-[hsl(var(--muted))]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {budgetPct > 90
|
||||
? 'bg-red-500'
|
||||
: budgetPct > 75
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[hsl(var(--primary))]'}"
|
||||
style="width: {budgetPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Archived Projects -->
|
||||
{#if archivedProjects.length > 0}
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (showArchived = !showArchived)}
|
||||
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {showArchived ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{$_('project.archived')} ({archivedProjects.length})
|
||||
</button>
|
||||
|
||||
{#if showArchived}
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each archivedProjects as project}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {project.color}"></div>
|
||||
<span class="text-sm text-[hsl(var(--foreground))]">{project.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => handleArchive(project.id, false)}
|
||||
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
206
apps/taktik/apps/web/src/routes/(app)/reports/+page.svelte
Normal file
206
apps/taktik/apps/web/src/routes/(app)/reports/+page.svelte
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
groupEntriesByProject,
|
||||
getEntriesByDateRange,
|
||||
} from '$lib/data/queries';
|
||||
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let period = $state<'week' | 'month'>('week');
|
||||
|
||||
let dateRange = $derived(() => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const daysBack = period === 'week' ? 7 : 30;
|
||||
const from = new Date(now.getTime() - daysBack * 86400000).toISOString().split('T')[0];
|
||||
return { from, to: today, days: daysBack };
|
||||
});
|
||||
|
||||
let entries = $derived(() => {
|
||||
const range = dateRange();
|
||||
return allTimeEntries.value.filter(
|
||||
(e) => !e.isRunning && e.date >= range.from && e.date <= range.to
|
||||
);
|
||||
});
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(entries()));
|
||||
let billableDuration = $derived(getBillableDuration(entries()));
|
||||
let nonBillableDuration = $derived(totalDuration - billableDuration);
|
||||
let entryCount = $derived(entries().length);
|
||||
let avgPerDay = $derived(dateRange().days > 0 ? totalDuration / dateRange().days : 0);
|
||||
|
||||
// Hours by project
|
||||
let projectBreakdown = $derived(() => {
|
||||
const groups = groupEntriesByProject(entries());
|
||||
const result: { projectId: string; name: string; color: string; duration: number }[] = [];
|
||||
for (const [projectId, projectEntries] of groups) {
|
||||
const project = allProjects.value.find((p) => p.id === projectId);
|
||||
result.push({
|
||||
projectId,
|
||||
name: project?.name || $_('project.internal'),
|
||||
color: project?.color || '#9ca3af',
|
||||
duration: getTotalDuration(projectEntries),
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => b.duration - a.duration);
|
||||
});
|
||||
|
||||
let maxProjectDuration = $derived(
|
||||
Math.max(...(projectBreakdown().map((p) => p.duration) || [1]))
|
||||
);
|
||||
|
||||
// Hours by day (last 7 days for week, grouped by week for month)
|
||||
let dailyBreakdown = $derived(() => {
|
||||
const days: { date: string; label: string; duration: number }[] = [];
|
||||
const range = dateRange();
|
||||
const numDays = period === 'week' ? 7 : 30;
|
||||
for (let i = numDays - 1; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 86400000);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dayEntries = entries().filter((e) => e.date === dateStr);
|
||||
days.push({
|
||||
date: dateStr,
|
||||
label: d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' }),
|
||||
duration: getTotalDuration(dayEntries),
|
||||
});
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
let maxDailyDuration = $derived(Math.max(...(dailyBreakdown().map((d) => d.duration) || [1])));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.reports')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.reports')}</h1>
|
||||
<div class="flex gap-1">
|
||||
{#each ['week', 'month'] as p}
|
||||
<button
|
||||
onclick={() => (period = p as any)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {period === p
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
|
||||
>
|
||||
{p === 'week' ? $_('entry.thisWeek') : $_('entry.thisMonth')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
|
||||
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
|
||||
{formatDurationDecimal(totalDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
|
||||
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--primary))]">
|
||||
{formatDurationDecimal(billableDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.avgPerDay')}</p>
|
||||
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(Math.round(avgPerDay))}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
|
||||
<p class="mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">{entryCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billable Breakdown -->
|
||||
{#if totalDuration > 0}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{$_('entry.billable')} vs. {$_('entry.notBillable')}
|
||||
</h3>
|
||||
<div class="flex h-4 overflow-hidden rounded-full">
|
||||
<div
|
||||
class="bg-[hsl(var(--primary))] transition-all"
|
||||
style="width: {(billableDuration / totalDuration) * 100}%"
|
||||
></div>
|
||||
<div
|
||||
class="bg-[hsl(var(--muted))] transition-all"
|
||||
style="width: {(nonBillableDuration / totalDuration) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-xs text-[hsl(var(--muted-foreground))]">
|
||||
<span>{$_('entry.billable')}: {formatDurationCompact(billableDuration)}</span>
|
||||
<span>{$_('entry.notBillable')}: {formatDurationCompact(nonBillableDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hours by Project -->
|
||||
{#if projectBreakdown().length > 0}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{$_('report.byProject')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each projectBreakdown() as item}
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {item.color}"></div>
|
||||
<span class="text-[hsl(var(--foreground))]">{item.name}</span>
|
||||
</div>
|
||||
<span class="duration-display text-[hsl(var(--muted-foreground))]">
|
||||
{formatDurationCompact(item.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-2 rounded-full bg-[hsl(var(--muted))]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
style="width: {(item.duration / maxProjectDuration) *
|
||||
100}%; background-color: {item.color}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Daily Hours -->
|
||||
{#if period === 'week' && dailyBreakdown().length > 0}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">{$_('report.byDay')}</h3>
|
||||
<div class="flex items-end gap-2" style="height: 120px;">
|
||||
{#each dailyBreakdown() as day}
|
||||
<div class="flex flex-1 flex-col items-center gap-1">
|
||||
<div class="w-full flex flex-col justify-end" style="height: 100px;">
|
||||
<div
|
||||
class="w-full rounded-t bg-[hsl(var(--primary))] transition-all"
|
||||
style="height: {maxDailyDuration > 0
|
||||
? (day.duration / maxDailyDuration) * 100
|
||||
: 0}%"
|
||||
title={formatDurationCompact(day.duration)}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-[hsl(var(--muted-foreground))]">{day.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
12
apps/taktik/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.settings')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.settings')}</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Einstellungen kommen bald.</p>
|
||||
</div>
|
||||
12
apps/taktik/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
12
apps/taktik/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Themes | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">Themes</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Theme-Auswahl.</p>
|
||||
</div>
|
||||
5
apps/taktik/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/taktik/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
270
apps/taktik/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
270
apps/taktik/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let showRegister = $state(false);
|
||||
let showForgotPassword = $state(false);
|
||||
let show2FA = $state(false);
|
||||
let twoFactorCode = $state('');
|
||||
let verificationSent = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) return;
|
||||
isLoading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await authStore.signIn(email, password);
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else if (result.error?.includes('two-factor') || result.error?.includes('2FA')) {
|
||||
show2FA = true;
|
||||
} else {
|
||||
error = result.error || 'Login fehlgeschlagen';
|
||||
}
|
||||
} catch {
|
||||
error = 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!email || !password) return;
|
||||
isLoading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await authStore.signUp(email, password);
|
||||
if (result.success && !result.needsVerification) {
|
||||
goto('/');
|
||||
} else if (result.needsVerification) {
|
||||
verificationSent = true;
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
}
|
||||
} catch {
|
||||
error = 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForgotPassword() {
|
||||
if (!email) {
|
||||
error = 'Bitte E-Mail eingeben';
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
error = '';
|
||||
const result = await authStore.resetPassword(email);
|
||||
if (result.success) {
|
||||
verificationSent = true;
|
||||
} else {
|
||||
error = result.error || 'Fehler beim Zurücksetzen';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handle2FA() {
|
||||
if (!twoFactorCode) return;
|
||||
isLoading = true;
|
||||
const result = await authStore.verifyTwoFactor(twoFactorCode, true);
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
error = 'Ungültiger Code';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handlePasskey() {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
const result = await authStore.signInWithPasskey();
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
error = result.error || 'Passkey fehlgeschlagen';
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-[hsl(var(--primary))]"
|
||||
>
|
||||
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Taktik</h1>
|
||||
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Zeiterfassung</p>
|
||||
</div>
|
||||
|
||||
{#if verificationSent}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 text-center"
|
||||
>
|
||||
<p class="text-[hsl(var(--foreground))]">
|
||||
{showForgotPassword ? 'Link zum Zurücksetzen gesendet!' : 'Bestätigungsmail gesendet!'}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">Bitte prüfe dein Postfach.</p>
|
||||
<button
|
||||
onclick={() => {
|
||||
verificationSent = false;
|
||||
showForgotPassword = false;
|
||||
showRegister = false;
|
||||
}}
|
||||
class="mt-4 text-sm text-[hsl(var(--primary))]"
|
||||
>
|
||||
Zurück zum Login
|
||||
</button>
|
||||
</div>
|
||||
{:else if show2FA}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={twoFactorCode}
|
||||
placeholder="Code eingeben"
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
onkeydown={(e) => e.key === 'Enter' && handle2FA()}
|
||||
/>
|
||||
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
|
||||
<button
|
||||
onclick={handle2FA}
|
||||
disabled={isLoading}
|
||||
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Prüfe...' : 'Bestätigen'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
|
||||
{showForgotPassword
|
||||
? 'Passwort zurücksetzen'
|
||||
: showRegister
|
||||
? $_('auth.register')
|
||||
: $_('auth.login')}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={$_('auth.email')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
{#if !showForgotPassword}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder={$_('auth.password')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))]"
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' && (showRegister ? handleRegister() : handleLogin())}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}<p class="mt-2 text-sm text-red-500">{error}</p>{/if}
|
||||
|
||||
<button
|
||||
onclick={showForgotPassword
|
||||
? handleForgotPassword
|
||||
: showRegister
|
||||
? handleRegister
|
||||
: handleLogin}
|
||||
disabled={isLoading}
|
||||
class="mt-4 w-full rounded-lg bg-[hsl(var(--primary))] py-3 font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{isLoading
|
||||
? $_('common.loading')
|
||||
: showForgotPassword
|
||||
? 'Link senden'
|
||||
: showRegister
|
||||
? $_('auth.register')
|
||||
: $_('auth.login')}
|
||||
</button>
|
||||
|
||||
{#if !showForgotPassword && authStore.isPasskeyAvailable()}
|
||||
<button
|
||||
onclick={handlePasskey}
|
||||
disabled={isLoading}
|
||||
class="mt-2 w-full rounded-lg border border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
Mit Passkey anmelden
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
{#if showForgotPassword}
|
||||
<button
|
||||
onclick={() => {
|
||||
showForgotPassword = false;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--primary))]"
|
||||
>
|
||||
Zurück zum Login
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => {
|
||||
showForgotPassword = true;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{$_('auth.forgotPassword')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
showRegister = !showRegister;
|
||||
error = '';
|
||||
}}
|
||||
class="text-[hsl(var(--primary))]"
|
||||
>
|
||||
{showRegister ? $_('auth.login') : $_('auth.register')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- App switcher -->
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-2">
|
||||
{#each getPillAppItems() as app}
|
||||
{#if app.id !== 'taktik'}
|
||||
<a
|
||||
href={app.url}
|
||||
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
|
||||
>
|
||||
{app.name}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
19
apps/taktik/apps/web/src/routes/+error.svelte
Normal file
19
apps/taktik/apps/web/src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-6xl font-bold text-[hsl(var(--primary))]">{$page.status}</h1>
|
||||
<p class="mb-8 text-lg text-[hsl(var(--muted-foreground))]">
|
||||
{$page.error?.message || $_('error.notFound')}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
{$_('error.backToHome')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
35
apps/taktik/apps/web/src/routes/+layout.svelte
Normal file
35
apps/taktik/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await waitLocale();
|
||||
theme.initialize();
|
||||
await authStore.initialize();
|
||||
ready = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
<div class="min-h-screen bg-[hsl(var(--background))] text-[hsl(var(--foreground))]">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))]">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[hsl(var(--primary))] border-t-transparent"
|
||||
></div>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
2
apps/taktik/apps/web/src/routes/+layout.ts
Normal file
2
apps/taktik/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
|
||||
export const ssr = false;
|
||||
10
apps/taktik/apps/web/src/routes/health/+server.ts
Normal file
10
apps/taktik/apps/web/src/routes/health/+server.ts
Normal file
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
39
apps/taktik/apps/web/src/routes/offline/+page.svelte
Normal file
39
apps/taktik/apps/web/src/routes/offline/+page.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Offline | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-[hsl(var(--muted))]"
|
||||
>
|
||||
<svg
|
||||
class="h-10 w-10 text-[hsl(var(--muted-foreground))]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mb-2 text-2xl font-bold text-[hsl(var(--foreground))]">Offline</h1>
|
||||
<p class="mb-6 text-[hsl(var(--muted-foreground))]">
|
||||
Du bist gerade nicht mit dem Internet verbunden.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => window.location.reload()}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-3 font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
14
apps/taktik/apps/web/svelte.config.js
Normal file
14
apps/taktik/apps/web/svelte.config.js
Normal file
|
|
@ -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;
|
||||
14
apps/taktik/apps/web/tsconfig.json
Normal file
14
apps/taktik/apps/web/tsconfig.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
64
apps/taktik/apps/web/vite.config.ts
Normal file
64
apps/taktik/apps/web/vite.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
14
apps/taktik/package.json
Normal file
14
apps/taktik/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
22
apps/taktik/packages/shared/package.json
Normal file
22
apps/taktik/packages/shared/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
40
apps/taktik/packages/shared/src/constants/index.ts
Normal file
40
apps/taktik/packages/shared/src/constants/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
2
apps/taktik/packages/shared/src/index.ts
Normal file
2
apps/taktik/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types/index.js';
|
||||
export * from './constants/index.js';
|
||||
154
apps/taktik/packages/shared/src/types/index.ts
Normal file
154
apps/taktik/packages/shared/src/types/index.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
16
apps/taktik/packages/shared/tsconfig.json
Normal file
16
apps/taktik/packages/shared/tsconfig.json
Normal file
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ const playgroundSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" f
|
|||
// CityCorners icon (map pin with blue gradient)
|
||||
const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#ccGrad)"/><path d="M512 200C408.3 200 324 284.3 324 388C324 536 512 800 512 800C512 800 700 536 700 388C700 284.3 615.7 200 512 200ZM512 468C467.8 468 432 432.2 432 388C432 343.8 467.8 308 512 308C556.2 308 592 343.8 592 388C592 432.2 556.2 468 512 468Z" fill="white"/><circle cx="512" cy="388" r="60" fill="#2563eb" fill-opacity="0.4"/><defs><linearGradient id="ccGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Taktik icon (clock with play button, amber gradient)
|
||||
const taktikSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#taktikGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="taktikGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Context icon (document/knowledge with sky blue gradient)
|
||||
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<AppIconId, { dev: string; prod: string }> = {
|
|||
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' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
525
pnpm-lock.yaml
generated
525
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue