mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
rename(taktik): rebrand to Times
Rename taktik → times across the entire app: package names (@taktik → @times), appId, localStorage keys, export filenames, type names (TaktikSettings → TimesSettings), monorepo scripts, shared-branding, mana-auth trustedOrigins, docker-compose, and documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1eb370eaaa
commit
c33339b0cf
92 changed files with 970 additions and 1263 deletions
207
apps/times/CLAUDE.md
Normal file
207
apps/times/CLAUDE.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Times
|
||||
|
||||
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
|
||||
|
||||
**Web App Port:** 5197
|
||||
|
||||
## Project Overview
|
||||
|
||||
Times is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, 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) |
|
||||
| Auth | @manacore/shared-auth + AuthGate (guest mode supported) |
|
||||
| Icons | @manacore/shared-icons (Phosphor) |
|
||||
| PWA | @vite-pwa/sveltekit + Workbox |
|
||||
| i18n | svelte-i18n (de, en) |
|
||||
| Testing | Vitest |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm dev:times:web # Start web app on port 5197
|
||||
pnpm dev:times:full # Start with auth + sync server
|
||||
|
||||
# Tests
|
||||
pnpm --filter @times/web test # Run all tests
|
||||
pnpm --filter @times/web test:unit # Run in watch mode
|
||||
|
||||
# Type checking
|
||||
pnpm --filter @times/web type-check
|
||||
pnpm --filter @times/shared type-check
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### Timer
|
||||
- Start/stop with one click, live HH:MM:SS counter
|
||||
- Persists in IndexedDB (survives page reload/crash)
|
||||
- Auto-save every 10 seconds
|
||||
- Compact indicator in navbar when running (visible on all pages)
|
||||
- Quick Start from recent entries or templates
|
||||
|
||||
### Time Entries
|
||||
- Manual entry with quick-duration buttons (15m, 30m, 1h, 1.5h, 2h, 4h)
|
||||
- Inline-expand editing (click to expand, auto-save on change)
|
||||
- Day grouping with totals
|
||||
- Filter by week/month/all
|
||||
- CSV export (semicolon-delimited, UTF-8 BOM for Excel)
|
||||
|
||||
### Projects
|
||||
- Color-coded project cards with budget progress bars
|
||||
- Client assignment with inherited billing rates
|
||||
- Billable/non-billable toggle
|
||||
- Archive/unarchive, inline CRUD
|
||||
|
||||
### Clients
|
||||
- Billing rates (per hour/day) with currency selection
|
||||
- Short codes for quick reference
|
||||
- Project and hours rollup
|
||||
|
||||
### Reports
|
||||
- Stats: total hours, billable hours, avg/day, entry count
|
||||
- Billable vs non-billable breakdown bar
|
||||
- Hours by project (horizontal bar chart)
|
||||
- Hours by day (vertical bar chart, last 7 days)
|
||||
- Week/month toggle
|
||||
- CSV export
|
||||
|
||||
### Templates
|
||||
- Save frequent entries as reusable templates
|
||||
- One-click timer start from template
|
||||
- Sorted by usage count
|
||||
|
||||
### Settings
|
||||
- Working hours/day, working days/week
|
||||
- Week start (Monday/Sunday)
|
||||
- Rounding increment (0/1/5/6/10/15 min) and method (none/up/down/nearest)
|
||||
- Default billing rate with currency (EUR/CHF/USD/GBP)
|
||||
- Timer reminder and auto-stop configuration
|
||||
|
||||
### Keyboard Shortcuts
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `s` | Start/Stop timer |
|
||||
| `n` | New manual entry |
|
||||
| `Escape` | Close modal / blur input |
|
||||
|
||||
## Data Collections
|
||||
|
||||
| Collection | Purpose | Key Indexes |
|
||||
|------------|---------|-------------|
|
||||
| clients | Customer management | order, isArchived, shortCode |
|
||||
| projects | Project tracking | clientId, isArchived, isBillable, guildId |
|
||||
| timeEntries | Core time records | projectId, date, isRunning, [date+projectId] |
|
||||
| tags | Entry categorization | name, order |
|
||||
| templates | Quick-start templates | usageCount, lastUsedAt |
|
||||
| settings | App configuration | (single record) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/times/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web client (port 5197)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── (auth)/ # Login/register flow
|
||||
│ │ │ │ └── login/
|
||||
│ │ │ ├── (app)/ # Authenticated app
|
||||
│ │ │ │ ├── +layout.svelte # AuthGate, PillNav, TimerIndicator, contexts
|
||||
│ │ │ │ ├── +page.svelte # Timer home page
|
||||
│ │ │ │ ├── entries/ # Time entry list
|
||||
│ │ │ │ ├── projects/ # Project management
|
||||
│ │ │ │ ├── clients/ # Client management
|
||||
│ │ │ │ ├── reports/ # Dashboard & charts
|
||||
│ │ │ │ ├── templates/ # Entry templates
|
||||
│ │ │ │ ├── settings/ # App configuration
|
||||
│ │ │ │ ├── mana/ # Credits & subscription
|
||||
│ │ │ │ ├── feedback/ # Feedback form
|
||||
│ │ │ │ ├── profile/ # User profile
|
||||
│ │ │ │ ├── themes/ # Theme selection
|
||||
│ │ │ │ └── help/ # Help & docs
|
||||
│ │ │ ├── +layout.svelte # Root layout (i18n, theme, auth init)
|
||||
│ │ │ ├── +layout.ts # SSR disabled
|
||||
│ │ │ ├── +error.svelte # Error page
|
||||
│ │ │ ├── health/+server.ts # Health check
|
||||
│ │ │ └── offline/ # Offline fallback
|
||||
│ │ └── lib/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── local-store.ts # 6 collections + typed accessors
|
||||
│ │ │ ├── queries.ts # Live queries + pure helpers
|
||||
│ │ │ ├── queries.test.ts # Unit tests
|
||||
│ │ │ └── guest-seed.ts # Demo data (2 clients, 3 projects, 5 entries)
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── auth.svelte.ts # Mana auth factory
|
||||
│ │ │ ├── timer.svelte.ts # Timer start/stop/resume/auto-save
|
||||
│ │ │ ├── view.svelte.ts # View mode, filters, sort
|
||||
│ │ │ ├── theme.ts # Theme store (ocean default)
|
||||
│ │ │ ├── navigation.ts # Nav collapse state
|
||||
│ │ │ └── user-settings.svelte.ts
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── TimerCard.svelte # Main timer widget
|
||||
│ │ │ ├── TimerIndicator.svelte # Compact navbar indicator
|
||||
│ │ │ ├── EntryItem.svelte # Inline-expandable entry
|
||||
│ │ │ ├── EntryList.svelte # Day-grouped entry list
|
||||
│ │ │ ├── EntryForm.svelte # Manual entry modal
|
||||
│ │ │ ├── QuickStart.svelte # Recent entry pills
|
||||
│ │ │ └── KeyboardShortcuts.svelte
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── export.ts # CSV export
|
||||
│ │ │ └── export.test.ts # Export tests
|
||||
│ │ ├── i18n/
|
||||
│ │ │ ├── index.ts # svelte-i18n setup
|
||||
│ │ │ └── locales/ # de.json, en.json
|
||||
│ │ └── version.ts
|
||||
│ └── static/
|
||||
├── packages/
|
||||
│ └── shared/ # @times/shared
|
||||
│ └── src/
|
||||
│ ├── types/index.ts # All TypeScript types
|
||||
│ ├── constants/index.ts # Currencies, colors, defaults
|
||||
│ └── index.ts
|
||||
├── CLAUDE.md
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Timer Flow
|
||||
```
|
||||
User clicks Start → timerStore.start() → Insert timeEntry (isRunning=true) → IndexedDB
|
||||
→ Start 1s tick interval (UI counter)
|
||||
→ Start 10s auto-save interval
|
||||
|
||||
User clicks Stop → timerStore.stop() → Update timeEntry (isRunning=false, endTime, duration)
|
||||
→ Stop intervals
|
||||
→ Entry appears in today's list
|
||||
```
|
||||
|
||||
### Data Flow (Local-First)
|
||||
```
|
||||
Guest: App → IndexedDB (Dexie.js) → UI (no sync)
|
||||
Logged in: App → IndexedDB → UI → SyncEngine → mana-sync → PostgreSQL
|
||||
← WebSocket push ←
|
||||
```
|
||||
|
||||
### Context Providers (set in app layout)
|
||||
All data is provided via Svelte context from `(app)/+layout.svelte`:
|
||||
- `clients` - Live query of all clients
|
||||
- `projects` - Live query of all projects
|
||||
- `timeEntries` - Live query of all time entries
|
||||
- `tags` - Live query of all tags
|
||||
- `templates` - Live query of all templates
|
||||
- `settings` - Live query of settings (single record)
|
||||
|
||||
## Gilden Integration (Planned v2)
|
||||
|
||||
- Projects with `visibility: 'guild'` + `guildId` are shared with team
|
||||
- Time entries inherit visibility from project
|
||||
- Team dashboard: hours per member, budget tracking
|
||||
- Manager vs member views
|
||||
- Credit consumption from guild pool for AI/PDF features
|
||||
53
apps/times/apps/web/Dockerfile
Normal file
53
apps/times/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Build stage - inherits pre-built shared packages from sveltekit-base
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
# Copy app-specific packages
|
||||
COPY apps/times/packages/shared ./apps/times/packages/shared
|
||||
COPY apps/times/apps/web ./apps/times/apps/web
|
||||
|
||||
# Install app-specific dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/times/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||
WORKDIR /app/apps/times/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
||||
COPY --from=builder /app/apps/times/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/times/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/times/apps/web/package.json ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5027
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5027
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5027/health || exit 1
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
52
apps/times/apps/web/package.json
Normal file
52
apps/times/apps/web/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "@times/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:*",
|
||||
"@times/shared": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
43
apps/times/apps/web/src/app.css
Normal file
43
apps/times/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/times/apps/web/src/app.html
Normal file
18
apps/times/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>Times</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
56
apps/times/apps/web/src/lib/components/ConfirmDialog.svelte
Normal file
56
apps/times/apps/web/src/lib/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
title = '',
|
||||
message = '',
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
destructive = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="mx-4 w-full max-w-sm rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-[hsl(var(--foreground))]">{title}</h3>
|
||||
{#if message}
|
||||
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">{message}</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex gap-2">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{cancelLabel || $_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={onConfirm}
|
||||
class="flex-1 rounded-lg py-2.5 text-sm font-medium transition-colors {destructive
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
|
||||
>
|
||||
{confirmLabel || $_('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
240
apps/times/apps/web/src/lib/components/EntryForm.svelte
Normal file
240
apps/times/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 '@times/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}
|
||||
215
apps/times/apps/web/src/lib/components/EntryItem.svelte
Normal file
215
apps/times/apps/web/src/lib/components/EntryItem.svelte
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<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 '@times/shared';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
|
||||
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 showDeleteConfirm = $state(false);
|
||||
|
||||
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() {
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
await timeEntryCollection.delete(entry.id);
|
||||
showDeleteConfirm = false;
|
||||
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>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={showDeleteConfirm}
|
||||
title={$_('common.delete')}
|
||||
message={$_('entry.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (showDeleteConfirm = false)}
|
||||
/>
|
||||
79
apps/times/apps/web/src/lib/components/EntryList.svelte
Normal file
79
apps/times/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 '@times/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}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
|
||||
let {
|
||||
onNewEntry,
|
||||
}: {
|
||||
onNewEntry?: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Don't trigger when typing in inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT'
|
||||
) {
|
||||
// Escape still works in inputs
|
||||
if (e.key === 'Escape') {
|
||||
(target as HTMLInputElement).blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
if (timerStore.isRunning) {
|
||||
timerStore.stop();
|
||||
} else {
|
||||
timerStore.start();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
e.preventDefault();
|
||||
onNewEntry?.();
|
||||
break;
|
||||
case 'g':
|
||||
// g then t = go to timer, g then e = entries, etc.
|
||||
break;
|
||||
case '?':
|
||||
// Show keyboard shortcuts help (future)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
59
apps/times/apps/web/src/lib/components/QuickStart.svelte
Normal file
59
apps/times/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 '@times/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/times/apps/web/src/lib/components/TimerCard.svelte
Normal file
199
apps/times/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 '@times/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>
|
||||
62
apps/times/apps/web/src/lib/components/TimerIndicator.svelte
Normal file
62
apps/times/apps/web/src/lib/components/TimerIndicator.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<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 } from '@times/shared';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
|
||||
let project = $derived(
|
||||
timerStore.runningEntry?.projectId
|
||||
? allProjects.value.find((p) => p.id === timerStore.runningEntry!.projectId)
|
||||
: undefined
|
||||
);
|
||||
|
||||
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
|
||||
|
||||
async function handleStop() {
|
||||
await timerStore.stop();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if timerStore.isRunning}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary)/0.1)] px-3 py-1.5 border border-[hsl(var(--primary)/0.2)]"
|
||||
>
|
||||
<!-- Pulsing dot -->
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[hsl(var(--primary))] opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-[hsl(var(--primary))]"></span>
|
||||
</div>
|
||||
|
||||
<!-- Project dot + Description -->
|
||||
{#if project}
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style="background-color: {project.color}"
|
||||
></div>
|
||||
{/if}
|
||||
<span class="hidden sm:inline max-w-[120px] truncate text-xs text-[hsl(var(--foreground))]">
|
||||
{timerStore.runningEntry?.description || $_('timer.running')}
|
||||
</span>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<span class="duration-display text-xs font-medium text-[hsl(var(--primary))]">
|
||||
{formattedTime}
|
||||
</span>
|
||||
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600"
|
||||
title={$_('timer.stop')}
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
183
apps/times/apps/web/src/lib/data/guest-seed.ts
Normal file
183
apps/times/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Guest seed data for the Times 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/times/apps/web/src/lib/data/local-store.ts
Normal file
153
apps/times/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Times — 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 '@times/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 timesStore = createLocalStore({
|
||||
appId: 'times',
|
||||
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 = timesStore.collection<LocalClient>('clients');
|
||||
export const projectCollection = timesStore.collection<LocalProject>('projects');
|
||||
export const timeEntryCollection = timesStore.collection<LocalTimeEntry>('timeEntries');
|
||||
export const tagCollection = timesStore.collection<LocalTag>('tags');
|
||||
export const templateCollection = timesStore.collection<LocalTemplate>('templates');
|
||||
export const settingsCollection = timesStore.collection<LocalSettings>('settings');
|
||||
370
apps/times/apps/web/src/lib/data/queries.test.ts
Normal file
370
apps/times/apps/web/src/lib/data/queries.test.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
getEntriesByDate,
|
||||
getEntriesByDateRange,
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
groupEntriesByDate,
|
||||
groupEntriesByProject,
|
||||
getFilteredEntries,
|
||||
getSortedEntries,
|
||||
getActiveProjects,
|
||||
getActiveClients,
|
||||
getProjectById,
|
||||
getClientById,
|
||||
getProjectsByClient,
|
||||
} from './queries';
|
||||
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||
|
||||
// ─── Test Factories ──────────────────────────────────────
|
||||
|
||||
function makeEntry(overrides: Partial<TimeEntry> = {}): TimeEntry {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
description: 'Test entry',
|
||||
date: '2024-01-15',
|
||||
duration: 3600,
|
||||
isBillable: false,
|
||||
isRunning: false,
|
||||
tags: [],
|
||||
visibility: 'private',
|
||||
createdAt: '2024-01-15T10:00:00Z',
|
||||
updatedAt: '2024-01-15T10:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Test Project',
|
||||
color: '#3b82f6',
|
||||
isArchived: false,
|
||||
isBillable: true,
|
||||
visibility: 'private',
|
||||
order: 0,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(overrides: Partial<Client> = {}): Client {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Test Client',
|
||||
color: '#3b82f6',
|
||||
isArchived: false,
|
||||
order: 0,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Duration Formatting ─────────────────────────────────
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats zero seconds', () => {
|
||||
expect(formatDuration(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('formats seconds only', () => {
|
||||
expect(formatDuration(45)).toBe('00:00:45');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatDuration(125)).toBe('00:02:05');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, seconds', () => {
|
||||
expect(formatDuration(3661)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('formats large durations', () => {
|
||||
expect(formatDuration(36000)).toBe('10:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationCompact', () => {
|
||||
it('formats zero as 0m', () => {
|
||||
expect(formatDurationCompact(0)).toBe('0m');
|
||||
});
|
||||
|
||||
it('formats minutes only', () => {
|
||||
expect(formatDurationCompact(1800)).toBe('30m');
|
||||
});
|
||||
|
||||
it('formats hours only', () => {
|
||||
expect(formatDurationCompact(7200)).toBe('2h');
|
||||
});
|
||||
|
||||
it('formats hours and minutes', () => {
|
||||
expect(formatDurationCompact(5400)).toBe('1h 30m');
|
||||
});
|
||||
|
||||
it('formats partial minutes (rounds down)', () => {
|
||||
expect(formatDurationCompact(3660)).toBe('1h 1m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationDecimal', () => {
|
||||
it('formats 1 hour', () => {
|
||||
expect(formatDurationDecimal(3600)).toBe('1.00');
|
||||
});
|
||||
|
||||
it('formats 1.5 hours', () => {
|
||||
expect(formatDurationDecimal(5400)).toBe('1.50');
|
||||
});
|
||||
|
||||
it('formats 0 hours', () => {
|
||||
expect(formatDurationDecimal(0)).toBe('0.00');
|
||||
});
|
||||
|
||||
it('formats 2h 15m', () => {
|
||||
expect(formatDurationDecimal(8100)).toBe('2.25');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Entry Queries ───────────────────────────────────────
|
||||
|
||||
describe('getEntriesByDate', () => {
|
||||
const entries = [
|
||||
makeEntry({ date: '2024-01-15', startTime: '2024-01-15T10:00:00Z' }),
|
||||
makeEntry({ date: '2024-01-15', startTime: '2024-01-15T09:00:00Z' }),
|
||||
makeEntry({ date: '2024-01-16' }),
|
||||
];
|
||||
|
||||
it('filters by date', () => {
|
||||
const result = getEntriesByDate(entries, '2024-01-15');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sorts by startTime', () => {
|
||||
const result = getEntriesByDate(entries, '2024-01-15');
|
||||
expect(result[0].startTime).toBe('2024-01-15T09:00:00Z');
|
||||
expect(result[1].startTime).toBe('2024-01-15T10:00:00Z');
|
||||
});
|
||||
|
||||
it('returns empty for no matches', () => {
|
||||
expect(getEntriesByDate(entries, '2024-01-20')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntriesByDateRange', () => {
|
||||
const entries = [
|
||||
makeEntry({ date: '2024-01-14' }),
|
||||
makeEntry({ date: '2024-01-15' }),
|
||||
makeEntry({ date: '2024-01-16' }),
|
||||
makeEntry({ date: '2024-01-17' }),
|
||||
];
|
||||
|
||||
it('filters inclusive range', () => {
|
||||
const result = getEntriesByDateRange(entries, '2024-01-15', '2024-01-16');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalDuration', () => {
|
||||
it('sums durations', () => {
|
||||
const entries = [
|
||||
makeEntry({ duration: 3600 }),
|
||||
makeEntry({ duration: 1800 }),
|
||||
makeEntry({ duration: 900 }),
|
||||
];
|
||||
expect(getTotalDuration(entries)).toBe(6300);
|
||||
});
|
||||
|
||||
it('returns 0 for empty array', () => {
|
||||
expect(getTotalDuration([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBillableDuration', () => {
|
||||
it('sums only billable entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ duration: 3600, isBillable: true }),
|
||||
makeEntry({ duration: 1800, isBillable: false }),
|
||||
makeEntry({ duration: 900, isBillable: true }),
|
||||
];
|
||||
expect(getBillableDuration(entries)).toBe(4500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupEntriesByDate', () => {
|
||||
it('groups correctly', () => {
|
||||
const entries = [
|
||||
makeEntry({ date: '2024-01-15' }),
|
||||
makeEntry({ date: '2024-01-15' }),
|
||||
makeEntry({ date: '2024-01-16' }),
|
||||
];
|
||||
const groups = groupEntriesByDate(entries);
|
||||
expect(groups.size).toBe(2);
|
||||
expect(groups.get('2024-01-15')).toHaveLength(2);
|
||||
expect(groups.get('2024-01-16')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupEntriesByProject', () => {
|
||||
it('groups by projectId', () => {
|
||||
const entries = [
|
||||
makeEntry({ projectId: 'p1' }),
|
||||
makeEntry({ projectId: 'p1' }),
|
||||
makeEntry({ projectId: 'p2' }),
|
||||
makeEntry({}),
|
||||
];
|
||||
const groups = groupEntriesByProject(entries);
|
||||
expect(groups.get('p1')).toHaveLength(2);
|
||||
expect(groups.get('p2')).toHaveLength(1);
|
||||
expect(groups.get('no-project')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Filtering ───────────────────────────────────────────
|
||||
|
||||
describe('getFilteredEntries', () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
projectId: 'p1',
|
||||
clientId: 'c1',
|
||||
isBillable: true,
|
||||
description: 'API work',
|
||||
tags: ['dev'],
|
||||
date: '2024-01-15',
|
||||
}),
|
||||
makeEntry({
|
||||
projectId: 'p2',
|
||||
clientId: 'c2',
|
||||
isBillable: false,
|
||||
description: 'Meeting',
|
||||
tags: ['meeting'],
|
||||
date: '2024-01-16',
|
||||
}),
|
||||
makeEntry({
|
||||
projectId: 'p1',
|
||||
isBillable: true,
|
||||
description: 'Testing',
|
||||
tags: ['dev'],
|
||||
date: '2024-01-17',
|
||||
}),
|
||||
];
|
||||
|
||||
it('filters by projectId', () => {
|
||||
expect(getFilteredEntries(entries, { projectId: 'p1' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters by clientId', () => {
|
||||
expect(getFilteredEntries(entries, { clientId: 'c1' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by isBillable', () => {
|
||||
expect(getFilteredEntries(entries, { isBillable: true })).toHaveLength(2);
|
||||
expect(getFilteredEntries(entries, { isBillable: false })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by tags', () => {
|
||||
expect(getFilteredEntries(entries, { tagIds: ['dev'] })).toHaveLength(2);
|
||||
expect(getFilteredEntries(entries, { tagIds: ['meeting'] })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by date range', () => {
|
||||
expect(getFilteredEntries(entries, { dateFrom: '2024-01-16' })).toHaveLength(2);
|
||||
expect(getFilteredEntries(entries, { dateTo: '2024-01-15' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by search text', () => {
|
||||
expect(getFilteredEntries(entries, { search: 'api' })).toHaveLength(1);
|
||||
expect(getFilteredEntries(entries, { search: 'MEETING' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('combines multiple filters', () => {
|
||||
expect(getFilteredEntries(entries, { projectId: 'p1', isBillable: true })).toHaveLength(2);
|
||||
expect(getFilteredEntries(entries, { projectId: 'p1', search: 'test' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns all with empty filters', () => {
|
||||
expect(getFilteredEntries(entries, {})).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sorting ─────────────────────────────────────────────
|
||||
|
||||
describe('getSortedEntries', () => {
|
||||
const entries = [
|
||||
makeEntry({ date: '2024-01-16', duration: 1800, createdAt: '2024-01-16T10:00:00Z' }),
|
||||
makeEntry({ date: '2024-01-15', duration: 3600, createdAt: '2024-01-15T10:00:00Z' }),
|
||||
makeEntry({ date: '2024-01-17', duration: 900, createdAt: '2024-01-17T10:00:00Z' }),
|
||||
];
|
||||
|
||||
it('sorts by date ascending', () => {
|
||||
const result = getSortedEntries(entries, { field: 'date', direction: 'asc' });
|
||||
expect(result[0].date).toBe('2024-01-15');
|
||||
expect(result[2].date).toBe('2024-01-17');
|
||||
});
|
||||
|
||||
it('sorts by date descending', () => {
|
||||
const result = getSortedEntries(entries, { field: 'date', direction: 'desc' });
|
||||
expect(result[0].date).toBe('2024-01-17');
|
||||
});
|
||||
|
||||
it('sorts by duration', () => {
|
||||
const result = getSortedEntries(entries, { field: 'duration', direction: 'desc' });
|
||||
expect(result[0].duration).toBe(3600);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Project/Client Helpers ──────────────────────────────
|
||||
|
||||
describe('getActiveProjects', () => {
|
||||
it('excludes archived and sorts by order', () => {
|
||||
const projects = [
|
||||
makeProject({ name: 'B', isArchived: false, order: 1 }),
|
||||
makeProject({ name: 'A', isArchived: false, order: 0 }),
|
||||
makeProject({ name: 'C', isArchived: true, order: 2 }),
|
||||
];
|
||||
const result = getActiveProjects(projects);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveClients', () => {
|
||||
it('excludes archived and sorts by order', () => {
|
||||
const clients = [
|
||||
makeClient({ name: 'B', isArchived: false, order: 1 }),
|
||||
makeClient({ name: 'A', isArchived: false, order: 0 }),
|
||||
makeClient({ name: 'C', isArchived: true }),
|
||||
];
|
||||
const result = getActiveClients(clients);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectById', () => {
|
||||
const projects = [makeProject({ id: 'p1', name: 'One' }), makeProject({ id: 'p2', name: 'Two' })];
|
||||
|
||||
it('finds by id', () => {
|
||||
expect(getProjectById(projects, 'p1')?.name).toBe('One');
|
||||
});
|
||||
|
||||
it('returns undefined for missing', () => {
|
||||
expect(getProjectById(projects, 'p99')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectsByClient', () => {
|
||||
const projects = [
|
||||
makeProject({ clientId: 'c1' }),
|
||||
makeProject({ clientId: 'c1' }),
|
||||
makeProject({ clientId: 'c2' }),
|
||||
];
|
||||
|
||||
it('filters by clientId', () => {
|
||||
expect(getProjectsByClient(projects, 'c1')).toHaveLength(2);
|
||||
expect(getProjectsByClient(projects, 'c2')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
338
apps/times/apps/web/src/lib/data/queries.ts
Normal file
338
apps/times/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for Times
|
||||
*
|
||||
* 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,
|
||||
TimesSettings,
|
||||
FilterCriteria,
|
||||
SortOption,
|
||||
} from '@times/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): TimesSettings {
|
||||
return {
|
||||
id: local.id,
|
||||
defaultBillingRate: local.defaultBillingRate ?? undefined,
|
||||
workingHoursPerDay: local.workingHoursPerDay,
|
||||
workingDaysPerWeek: local.workingDaysPerWeek,
|
||||
roundingIncrement: local.roundingIncrement,
|
||||
roundingMethod: local.roundingMethod,
|
||||
defaultVisibility: local.defaultVisibility,
|
||||
weekStartsOn: local.weekStartsOn,
|
||||
timerReminderMinutes: local.timerReminderMinutes,
|
||||
autoStopTimerHours: local.autoStopTimerHours,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live 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 TimesSettings | 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);
|
||||
}
|
||||
68
apps/times/apps/web/src/lib/data/types.test.ts
Normal file
68
apps/times/apps/web/src/lib/data/types.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
Client,
|
||||
Project,
|
||||
TimeEntry,
|
||||
Tag,
|
||||
EntryTemplate,
|
||||
TimesSettings,
|
||||
BillingRate,
|
||||
FilterCriteria,
|
||||
SortOption,
|
||||
} from '@times/shared';
|
||||
|
||||
describe('Shared Types', () => {
|
||||
it('BillingRate has correct shape', () => {
|
||||
const rate: BillingRate = { amount: 95, currency: 'EUR', per: 'hour' };
|
||||
expect(rate.amount).toBe(95);
|
||||
expect(rate.per).toBe('hour');
|
||||
});
|
||||
|
||||
it('Client has required fields', () => {
|
||||
const client: Client = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
color: '#000',
|
||||
isArchived: false,
|
||||
order: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
expect(client.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('TimeEntry has required fields', () => {
|
||||
const entry: TimeEntry = {
|
||||
id: '1',
|
||||
description: 'Work',
|
||||
date: '2024-01-15',
|
||||
duration: 3600,
|
||||
isBillable: true,
|
||||
isRunning: false,
|
||||
tags: ['dev'],
|
||||
visibility: 'private',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
expect(entry.duration).toBe(3600);
|
||||
expect(entry.tags).toContain('dev');
|
||||
});
|
||||
|
||||
it('FilterCriteria supports all filter types', () => {
|
||||
const filter: FilterCriteria = {
|
||||
search: 'test',
|
||||
projectId: 'p1',
|
||||
clientId: 'c1',
|
||||
tagIds: ['t1'],
|
||||
isBillable: true,
|
||||
dateFrom: '2024-01-01',
|
||||
dateTo: '2024-12-31',
|
||||
};
|
||||
expect(filter.search).toBe('test');
|
||||
});
|
||||
|
||||
it('SortOption has valid fields', () => {
|
||||
const sort: SortOption = { field: 'date', direction: 'desc' };
|
||||
expect(sort.field).toBe('date');
|
||||
});
|
||||
});
|
||||
38
apps/times/apps/web/src/lib/i18n/index.ts
Normal file
38
apps/times/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('times_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('times_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
138
apps/times/apps/web/src/lib/i18n/locales/de.json
Normal file
138
apps/times/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Times",
|
||||
"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",
|
||||
"deleteConfirm": "Möchtest du diesen Zeiteintrag wirklich löschen? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"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",
|
||||
"deleteConfirm": "Möchtest du dieses Projekt wirklich löschen? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"client": {
|
||||
"create": "Kunde erstellen",
|
||||
"edit": "Kunde bearbeiten",
|
||||
"delete": "Kunde löschen",
|
||||
"name": "Name",
|
||||
"shortCode": "Kürzel",
|
||||
"email": "E-Mail",
|
||||
"billingRate": "Stundensatz",
|
||||
"noClients": "Keine Kunden",
|
||||
"deleteConfirm": "Möchtest du diesen Kunden wirklich löschen? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
138
apps/times/apps/web/src/lib/i18n/locales/en.json
Normal file
138
apps/times/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Times",
|
||||
"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",
|
||||
"deleteConfirm": "Are you sure you want to delete this time entry? This cannot be undone."
|
||||
},
|
||||
"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",
|
||||
"deleteConfirm": "Are you sure you want to delete this project? This cannot be undone."
|
||||
},
|
||||
"client": {
|
||||
"create": "Create Client",
|
||||
"edit": "Edit Client",
|
||||
"delete": "Delete Client",
|
||||
"name": "Name",
|
||||
"shortCode": "Short Code",
|
||||
"email": "Email",
|
||||
"billingRate": "Billing Rate",
|
||||
"noClients": "No clients",
|
||||
"deleteConfirm": "Are you sure you want to delete this client? This cannot be undone."
|
||||
},
|
||||
"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/times/apps/web/src/lib/stores/auth.svelte.ts
Normal file
7
apps/times/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/times/apps/web/src/lib/stores/navigation.ts
Normal file
6
apps/times/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/times/apps/web/src/lib/stores/theme.ts
Normal file
6
apps/times/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'times',
|
||||
defaultVariant: 'ocean',
|
||||
});
|
||||
176
apps/times/apps/web/src/lib/stores/timer.svelte.ts
Normal file
176
apps/times/apps/web/src/lib/stores/timer.svelte.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* 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,
|
||||
settingsCollection,
|
||||
type LocalTimeEntry,
|
||||
} from '$lib/data/local-store';
|
||||
import { roundDuration } from '$lib/utils/rounding';
|
||||
|
||||
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;
|
||||
|
||||
// Apply rounding from settings
|
||||
const settings = await settingsCollection.getAll();
|
||||
const s = settings[0];
|
||||
const roundedDuration = s
|
||||
? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod)
|
||||
: finalDuration;
|
||||
|
||||
await timeEntryCollection.update(runningEntry.id, {
|
||||
isRunning: false,
|
||||
endTime: now.toISOString(),
|
||||
duration: roundedDuration,
|
||||
});
|
||||
|
||||
const stoppedEntry = {
|
||||
...runningEntry,
|
||||
isRunning: false,
|
||||
endTime: now.toISOString(),
|
||||
duration: roundedDuration,
|
||||
};
|
||||
stopTicking();
|
||||
runningEntry = null;
|
||||
elapsedSeconds = 0;
|
||||
return stoppedEntry as LocalTimeEntry;
|
||||
},
|
||||
|
||||
/** Discard the running timer without saving */
|
||||
async discard() {
|
||||
if (!runningEntry) return;
|
||||
await 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/times/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
18
apps/times/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: 'times',
|
||||
authUrl: getAuthUrl,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
106
apps/times/apps/web/src/lib/stores/view.svelte.ts
Normal file
106
apps/times/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 '@times/shared';
|
||||
|
||||
const VIEW_KEY = 'times_view_mode';
|
||||
const SORT_KEY = 'times_sort';
|
||||
const FILTERS_KEY = 'times_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);
|
||||
},
|
||||
};
|
||||
162
apps/times/apps/web/src/lib/utils/export.test.ts
Normal file
162
apps/times/apps/web/src/lib/utils/export.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||
|
||||
// We test the CSV generation logic without triggering DOM download.
|
||||
// This mirrors the core logic from export.ts.
|
||||
|
||||
function generateCSVContent(entries: TimeEntry[], projects: Project[], clients: Client[]): string {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]));
|
||||
const clientMap = new Map(clients.map((c) => [c.id, c]));
|
||||
|
||||
const headers = [
|
||||
'Datum',
|
||||
'Beschreibung',
|
||||
'Projekt',
|
||||
'Kunde',
|
||||
'Dauer (h)',
|
||||
'Dauer (min)',
|
||||
'Abrechenbar',
|
||||
'Tags',
|
||||
'Startzeit',
|
||||
'Endzeit',
|
||||
];
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const project = e.projectId ? projectMap.get(e.projectId) : undefined;
|
||||
const client = e.clientId ? clientMap.get(e.clientId) : undefined;
|
||||
const hours = Math.floor(e.duration / 3600);
|
||||
const minutes = Math.floor((e.duration % 3600) / 60);
|
||||
|
||||
return [
|
||||
e.date,
|
||||
`"${(e.description || '').replace(/"/g, '""')}"`,
|
||||
`"${(project?.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(client?.name || '').replace(/"/g, '""')}"`,
|
||||
hours.toString(),
|
||||
(hours * 60 + minutes).toString(),
|
||||
e.isBillable ? 'Ja' : 'Nein',
|
||||
`"${e.tags.join(', ')}"`,
|
||||
'',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
return [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n');
|
||||
}
|
||||
|
||||
// ─── Test Data ───────────────────────────────────────────
|
||||
|
||||
const projects: Project[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Website Redesign',
|
||||
color: '#3b82f6',
|
||||
isArchived: false,
|
||||
isBillable: true,
|
||||
visibility: 'private',
|
||||
order: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const clients: Client[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'Acme Corp',
|
||||
color: '#3b82f6',
|
||||
isArchived: false,
|
||||
order: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────
|
||||
|
||||
describe('CSV Export', () => {
|
||||
it('generates correct CSV headers', () => {
|
||||
const csv = generateCSVContent([], projects, clients);
|
||||
expect(csv).toContain('Datum;Beschreibung;Projekt;Kunde');
|
||||
});
|
||||
|
||||
it('generates correct row data', () => {
|
||||
const entries: TimeEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
projectId: 'p1',
|
||||
clientId: 'c1',
|
||||
description: 'API work',
|
||||
date: '2024-01-15',
|
||||
duration: 5400,
|
||||
isBillable: true,
|
||||
isRunning: false,
|
||||
tags: ['dev', 'api'],
|
||||
visibility: 'private',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCSVContent(entries, projects, clients);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
|
||||
const row = lines[1];
|
||||
expect(row).toContain('2024-01-15');
|
||||
expect(row).toContain('"API work"');
|
||||
expect(row).toContain('"Website Redesign"');
|
||||
expect(row).toContain('"Acme Corp"');
|
||||
expect(row).toContain('1'); // 1 hour
|
||||
expect(row).toContain('90'); // 90 minutes
|
||||
expect(row).toContain('Ja');
|
||||
expect(row).toContain('"dev, api"');
|
||||
});
|
||||
|
||||
it('escapes quotes in descriptions', () => {
|
||||
const entries: TimeEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
description: 'Fix "bug" in API',
|
||||
date: '2024-01-15',
|
||||
duration: 3600,
|
||||
isBillable: false,
|
||||
isRunning: false,
|
||||
tags: [],
|
||||
visibility: 'private',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCSVContent(entries, projects, clients);
|
||||
expect(csv).toContain('"Fix ""bug"" in API"');
|
||||
});
|
||||
|
||||
it('handles entries without project or client', () => {
|
||||
const entries: TimeEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
description: 'Internal work',
|
||||
date: '2024-01-15',
|
||||
duration: 1800,
|
||||
isBillable: false,
|
||||
isRunning: false,
|
||||
tags: [],
|
||||
visibility: 'private',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const csv = generateCSVContent(entries, projects, clients);
|
||||
expect(csv).toContain('""'); // empty project/client
|
||||
expect(csv).toContain('Nein');
|
||||
});
|
||||
|
||||
it('handles empty entries', () => {
|
||||
const csv = generateCSVContent([], projects, clients);
|
||||
const lines = csv.split('\n');
|
||||
expect(lines).toHaveLength(1); // headers only
|
||||
});
|
||||
});
|
||||
61
apps/times/apps/web/src/lib/utils/export.ts
Normal file
61
apps/times/apps/web/src/lib/utils/export.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* CSV Export utility for time entries
|
||||
*/
|
||||
|
||||
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||
|
||||
export function exportEntriesToCSV(
|
||||
entries: TimeEntry[],
|
||||
projects: Project[],
|
||||
clients: Client[]
|
||||
): void {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]));
|
||||
const clientMap = new Map(clients.map((c) => [c.id, c]));
|
||||
|
||||
const headers = [
|
||||
'Datum',
|
||||
'Beschreibung',
|
||||
'Projekt',
|
||||
'Kunde',
|
||||
'Dauer (h)',
|
||||
'Dauer (min)',
|
||||
'Abrechenbar',
|
||||
'Tags',
|
||||
'Startzeit',
|
||||
'Endzeit',
|
||||
];
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const project = e.projectId ? projectMap.get(e.projectId) : undefined;
|
||||
const client = e.clientId ? clientMap.get(e.clientId) : undefined;
|
||||
const hours = Math.floor(e.duration / 3600);
|
||||
const minutes = Math.floor((e.duration % 3600) / 60);
|
||||
|
||||
return [
|
||||
e.date,
|
||||
`"${(e.description || '').replace(/"/g, '""')}"`,
|
||||
`"${(project?.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(client?.name || '').replace(/"/g, '""')}"`,
|
||||
hours.toString(),
|
||||
(hours * 60 + minutes).toString(),
|
||||
e.isBillable ? 'Ja' : 'Nein',
|
||||
`"${e.tags.join(', ')}"`,
|
||||
e.startTime
|
||||
? new Date(e.startTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
e.endTime
|
||||
? new Date(e.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
];
|
||||
});
|
||||
|
||||
const csv = [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n');
|
||||
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
|
||||
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
86
apps/times/apps/web/src/lib/utils/rounding.test.ts
Normal file
86
apps/times/apps/web/src/lib/utils/rounding.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { roundDuration } from './rounding';
|
||||
|
||||
describe('roundDuration', () => {
|
||||
describe('no rounding', () => {
|
||||
it('returns original when increment is 0', () => {
|
||||
expect(roundDuration(3661, 0, 'up')).toBe(3661);
|
||||
});
|
||||
|
||||
it('returns original when method is none', () => {
|
||||
expect(roundDuration(3661, 5, 'none')).toBe(3661);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round up', () => {
|
||||
it('rounds 7 min up to 15 min', () => {
|
||||
expect(roundDuration(7 * 60, 15, 'up')).toBe(15 * 60);
|
||||
});
|
||||
|
||||
it('rounds 16 min up to 30 min', () => {
|
||||
expect(roundDuration(16 * 60, 15, 'up')).toBe(30 * 60);
|
||||
});
|
||||
|
||||
it('does not round exact values', () => {
|
||||
expect(roundDuration(15 * 60, 15, 'up')).toBe(15 * 60);
|
||||
});
|
||||
|
||||
it('rounds 1 min up to 5 min', () => {
|
||||
expect(roundDuration(60, 5, 'up')).toBe(5 * 60);
|
||||
});
|
||||
|
||||
it('rounds 61 min up to 66 min (6 min increment)', () => {
|
||||
expect(roundDuration(61 * 60, 6, 'up')).toBe(66 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round down', () => {
|
||||
it('rounds 7 min down to 0 min', () => {
|
||||
expect(roundDuration(7 * 60, 15, 'down')).toBe(0);
|
||||
});
|
||||
|
||||
it('rounds 22 min down to 15 min', () => {
|
||||
expect(roundDuration(22 * 60, 15, 'down')).toBe(15 * 60);
|
||||
});
|
||||
|
||||
it('does not round exact values', () => {
|
||||
expect(roundDuration(30 * 60, 15, 'down')).toBe(30 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round nearest', () => {
|
||||
it('rounds 7 min nearest to 10 (with 10 min increment)', () => {
|
||||
expect(roundDuration(7 * 60, 10, 'nearest')).toBe(10 * 60);
|
||||
});
|
||||
|
||||
it('rounds 3 min nearest to 0 (with 10 min increment)', () => {
|
||||
expect(roundDuration(3 * 60, 10, 'nearest')).toBe(0);
|
||||
});
|
||||
|
||||
it('rounds 5 min nearest to 10 (midpoint rounds up)', () => {
|
||||
expect(roundDuration(5 * 60, 10, 'nearest')).toBe(10 * 60);
|
||||
});
|
||||
|
||||
it('rounds 8 min nearest to 10 (with 5 min increment)', () => {
|
||||
expect(roundDuration(8 * 60, 5, 'nearest')).toBe(10 * 60);
|
||||
});
|
||||
|
||||
it('rounds 2 min nearest to 0 (with 5 min increment)', () => {
|
||||
expect(roundDuration(2 * 60, 5, 'nearest')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles 0 seconds', () => {
|
||||
expect(roundDuration(0, 15, 'up')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles 1 minute increment', () => {
|
||||
expect(roundDuration(90, 1, 'up')).toBe(120); // 1.5 min -> 2 min
|
||||
});
|
||||
|
||||
it('handles negative increment as no rounding', () => {
|
||||
expect(roundDuration(3661, -5, 'up')).toBe(3661);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
apps/times/apps/web/src/lib/utils/rounding.ts
Normal file
36
apps/times/apps/web/src/lib/utils/rounding.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Duration rounding utility
|
||||
*
|
||||
* Applies rounding based on user settings (increment + method).
|
||||
*/
|
||||
|
||||
import type { RoundingMethod } from '@times/shared';
|
||||
|
||||
/**
|
||||
* Round a duration in seconds based on settings.
|
||||
* @param seconds - Duration in seconds
|
||||
* @param increment - Rounding increment in minutes (0 = no rounding)
|
||||
* @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest'
|
||||
* @returns Rounded duration in seconds
|
||||
*/
|
||||
export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number {
|
||||
if (increment <= 0 || method === 'none') return seconds;
|
||||
|
||||
const incrementSeconds = increment * 60;
|
||||
const remainder = seconds % incrementSeconds;
|
||||
|
||||
if (remainder === 0) return seconds;
|
||||
|
||||
switch (method) {
|
||||
case 'up':
|
||||
return seconds - remainder + incrementSeconds;
|
||||
case 'down':
|
||||
return seconds - remainder;
|
||||
case 'nearest':
|
||||
return remainder >= incrementSeconds / 2
|
||||
? seconds - remainder + incrementSeconds
|
||||
: seconds - remainder;
|
||||
default:
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
7
apps/times/apps/web/src/lib/version.ts
Normal file
7
apps/times/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';
|
||||
214
apps/times/apps/web/src/routes/(app)/+layout.svelte
Normal file
214
apps/times/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { setContext, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import TimerIndicator from '$lib/components/TimerIndicator.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 { timesStore } 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 timesStore.initialize();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
timesStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
viewStore.initialize();
|
||||
await timerStore.initialize();
|
||||
initialized = true;
|
||||
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('times')) {
|
||||
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: '/templates', label: $_('nav.templates'), icon: 'bookmark' },
|
||||
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
|
||||
{ href: '/mana', label: 'Mana', icon: 'star' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
onDestroy(() => {
|
||||
timerStore.destroy();
|
||||
});
|
||||
|
||||
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))]">Times</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>
|
||||
|
||||
<!-- Timer Indicator (visible when timer running) -->
|
||||
<TimerIndicator />
|
||||
|
||||
<!-- 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="times"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale="de"
|
||||
/>
|
||||
<SyncIndicator />
|
||||
</AuthGate>
|
||||
78
apps/times/apps/web/src/routes/(app)/+page.svelte
Normal file
78
apps/times/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@times/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';
|
||||
import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.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);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Timer | Times</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)} />
|
||||
<KeyboardShortcuts onNewEntry={() => (showEntryForm = true)} />
|
||||
363
apps/times/apps/web/src/routes/(app)/clients/+page.svelte
Normal file
363
apps/times/apps/web/src/routes/(app)/clients/+page.svelte
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
<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 '@times/shared';
|
||||
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
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 deleteConfirmId = $state<string | null>(null);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
deleteConfirmId = id;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteConfirmId) return;
|
||||
await clientCollection.delete(deleteConfirmId);
|
||||
editingClientId = null;
|
||||
deleteConfirmId = 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')} | Times</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>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={deleteConfirmId !== null}
|
||||
title={$_('common.delete')}
|
||||
message={$_('client.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (deleteConfirmId = null)}
|
||||
/>
|
||||
160
apps/times/apps/web/src/routes/(app)/clients/[id]/+page.svelte
Normal file
160
apps/times/apps/web/src/routes/(app)/clients/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { clientCollection } from '$lib/data/local-store';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
} from '$lib/data/queries';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import type { Project, Client, TimeEntry } from '@times/shared';
|
||||
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let clientId = $derived($page.params.id);
|
||||
|
||||
let client = $derived(allClients.value.find((c) => c.id === clientId));
|
||||
let clientProjects = $derived(
|
||||
allProjects.value.filter((p) => p.clientId === clientId).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
let clientEntries = $derived(
|
||||
allTimeEntries.value
|
||||
.filter((e) => e.clientId === clientId && !e.isRunning)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
);
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(clientEntries));
|
||||
let billableDuration = $derived(getBillableDuration(clientEntries));
|
||||
|
||||
function getProjectHours(projectId: string): number {
|
||||
return getTotalDuration(clientEntries.filter((e) => e.projectId === projectId));
|
||||
}
|
||||
|
||||
let billingValue = $derived(() => {
|
||||
if (!client?.billingRate) return null;
|
||||
return (billableDuration / 3600) * client.billingRate.amount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{client?.name || 'Kunde'} | Times</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !client}
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Kunde nicht gefunden.</p>
|
||||
<a href="/clients" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Back + Header -->
|
||||
<div>
|
||||
<a
|
||||
href="/clients"
|
||||
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{$_('nav.clients')}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white"
|
||||
style="background-color: {client.color}"
|
||||
>
|
||||
{client.shortCode || client.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{client.name}</h1>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{#if client.shortCode}{client.shortCode} ·
|
||||
{/if}
|
||||
{#if client.email}{client.email} ·
|
||||
{/if}
|
||||
{#if client.billingRate}
|
||||
{client.billingRate.amount} {client.billingRate.currency}/h
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<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-xl 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-xl 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))]">{$_('nav.projects')}</p>
|
||||
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{clientProjects.length}</p>
|
||||
</div>
|
||||
{#if billingValue() !== null}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">Wert</p>
|
||||
<p class="mt-1 text-xl font-bold text-[hsl(var(--primary))]">
|
||||
{billingValue()!.toFixed(0)}
|
||||
{client.billingRate!.currency}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Projects -->
|
||||
{#if clientProjects.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.projects')}
|
||||
</h2>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each clientProjects as proj}
|
||||
{@const hours = getProjectHours(proj.id)}
|
||||
<a
|
||||
href="/projects/{proj.id}"
|
||||
class="entry-item flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {proj.color}"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{proj.name}</p>
|
||||
{#if proj.isBillable}
|
||||
<span class="text-xs text-[hsl(var(--primary))]">{$_('project.billable')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(hours)}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Entries -->
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
|
||||
</h2>
|
||||
<EntryList entries={clientEntries} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
113
apps/times/apps/web/src/routes/(app)/entries/+page.svelte
Normal file
113
apps/times/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 '@times/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')} | Times</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/times/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
12
apps/times/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 | Times</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/times/apps/web/src/routes/(app)/help/+page.svelte
Normal file
12
apps/times/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 | Times</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/times/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
12
apps/times/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 | Times</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/times/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
12
apps/times/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 | Times</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>
|
||||
372
apps/times/apps/web/src/routes/(app)/projects/+page.svelte
Normal file
372
apps/times/apps/web/src/routes/(app)/projects/+page.svelte
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<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 '@times/shared';
|
||||
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
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);
|
||||
let deleteConfirmId = $state<string | null>(null);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
deleteConfirmId = id;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteConfirmId) return;
|
||||
await projectCollection.delete(deleteConfirmId);
|
||||
editingProjectId = null;
|
||||
deleteConfirmId = 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')} | Times</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>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={deleteConfirmId !== null}
|
||||
title={$_('common.delete')}
|
||||
message={$_('project.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (deleteConfirmId = null)}
|
||||
/>
|
||||
330
apps/times/apps/web/src/routes/(app)/projects/[id]/+page.svelte
Normal file
330
apps/times/apps/web/src/routes/(app)/projects/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { projectCollection } from '$lib/data/local-store';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
} from '$lib/data/queries';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import type { Project, Client, TimeEntry } from '@times/shared';
|
||||
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let projectId = $derived($page.params.id);
|
||||
|
||||
let project = $derived(allProjects.value.find((p) => p.id === projectId));
|
||||
let client = $derived(
|
||||
project?.clientId ? allClients.value.find((c) => c.id === project!.clientId) : undefined
|
||||
);
|
||||
let projectEntries = $derived(
|
||||
allTimeEntries.value
|
||||
.filter((e) => e.projectId === projectId && !e.isRunning)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
);
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(projectEntries));
|
||||
let billableDuration = $derived(getBillableDuration(projectEntries));
|
||||
|
||||
let budgetPercent = $derived(() => {
|
||||
if (!project?.budget || project.budget.type !== 'hours') return null;
|
||||
const hoursLogged = totalDuration / 3600;
|
||||
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
|
||||
});
|
||||
|
||||
let budgetHoursUsed = $derived(totalDuration / 3600);
|
||||
let budgetHoursTotal = $derived(project?.budget?.type === 'hours' ? project.budget.amount : null);
|
||||
|
||||
// Edit state
|
||||
let isEditing = $state(false);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editClientId = $state('');
|
||||
let editColor = $state('');
|
||||
let editIsBillable = $state(false);
|
||||
let editBudgetHours = $state(0);
|
||||
let editRateAmount = $state(0);
|
||||
|
||||
function startEditing() {
|
||||
if (!project) return;
|
||||
isEditing = true;
|
||||
editName = project.name;
|
||||
editDescription = project.description ?? '';
|
||||
editClientId = project.clientId ?? '';
|
||||
editColor = project.color;
|
||||
editIsBillable = project.isBillable;
|
||||
editBudgetHours = project.budget?.type === 'hours' ? project.budget.amount : 0;
|
||||
editRateAmount = project.billingRate?.amount ?? 0;
|
||||
}
|
||||
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
function save(updates: Record<string, unknown>) {
|
||||
const id = projectId;
|
||||
if (!id) return;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
projectCollection.update(id, updates);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
const id = projectId;
|
||||
if (!project || !id) return;
|
||||
await projectCollection.update(id, { isArchived: !project.isArchived });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{project?.name || 'Projekt'} | Times</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !project}
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Projekt nicht gefunden.</p>
|
||||
<a href="/projects" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Back + Header -->
|
||||
<div>
|
||||
<a
|
||||
href="/projects"
|
||||
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{$_('nav.projects')}
|
||||
</a>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-4 w-4 rounded-full" style="background-color: {project.color}"></div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{project.name}</h1>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{client?.name || $_('project.internal')}
|
||||
{#if project.isBillable}
|
||||
<span
|
||||
class="ml-2 rounded bg-[hsl(var(--primary)/0.1)] px-1.5 py-0.5 text-xs text-[hsl(var(--primary))]"
|
||||
>
|
||||
{$_('project.billable')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if project.isArchived}
|
||||
<span
|
||||
class="ml-2 rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{$_('project.archived')}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (isEditing ? (isEditing = false) : startEditing())}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{isEditing ? $_('common.close') : $_('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{project.isArchived ? $_('common.unarchive') : $_('common.archive')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
{#if isEditing}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
oninput={(e) => {
|
||||
editName = (e.target as HTMLInputElement).value;
|
||||
save({ name: editName });
|
||||
}}
|
||||
placeholder={$_('project.name')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDescription}
|
||||
oninput={(e) => {
|
||||
editDescription = (e.target as HTMLInputElement).value;
|
||||
save({ description: editDescription || null });
|
||||
}}
|
||||
placeholder={$_('project.description')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={editClientId}
|
||||
onchange={(e) => {
|
||||
editClientId = (e.target as HTMLSelectElement).value;
|
||||
save({ clientId: editClientId || null });
|
||||
}}
|
||||
class="flex-1 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>
|
||||
<label
|
||||
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editIsBillable}
|
||||
onchange={() => {
|
||||
editIsBillable = !editIsBillable;
|
||||
save({ isBillable: editIsBillable });
|
||||
}}
|
||||
class="accent-[hsl(var(--primary))]"
|
||||
/>
|
||||
{$_('project.billable')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('project.budget')} (h):</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={editBudgetHours}
|
||||
min="0"
|
||||
oninput={(e) => {
|
||||
editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({
|
||||
budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null,
|
||||
});
|
||||
}}
|
||||
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-[hsl(var(--muted-foreground))]">Rate:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editRateAmount}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({
|
||||
billingRate:
|
||||
editRateAmount > 0
|
||||
? { amount: editRateAmount, currency: 'EUR', per: 'hour' }
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
|
||||
/>
|
||||
<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;
|
||||
save({ 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>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<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-xl 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-xl font-bold text-[hsl(var(--primary))]">
|
||||
{formatDurationDecimal(billableDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
{#if budgetHoursTotal}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
|
||||
{budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<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-xl font-bold text-[hsl(var(--foreground))]">{projectEntries.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Progress -->
|
||||
{#if budgetPercent() !== null}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</span>
|
||||
<span class="font-medium text-[hsl(var(--foreground))]">{budgetPercent()}%</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2.5 rounded-full bg-[hsl(var(--muted))]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {budgetPercent()! > 90
|
||||
? 'bg-red-500'
|
||||
: budgetPercent()! > 75
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[hsl(var(--primary))]'}"
|
||||
style="width: {budgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if project.billingRate}
|
||||
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{project.billingRate.amount}
|
||||
{project.billingRate.currency}/h · Wert: {(
|
||||
(billableDuration / 3600) *
|
||||
project.billingRate.amount
|
||||
).toFixed(0)}
|
||||
{project.billingRate.currency}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Entries -->
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
|
||||
</h2>
|
||||
<EntryList entries={projectEntries} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
215
apps/times/apps/web/src/routes/(app)/reports/+page.svelte
Normal file
215
apps/times/apps/web/src/routes/(app)/reports/+page.svelte
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||
import { exportEntriesToCSV } from '$lib/utils/export';
|
||||
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')} | Times</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 items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
onclick={() => exportEntriesToCSV(entries(), allProjects.value, allClients.value)}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
CSV Export
|
||||
</button>
|
||||
</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>
|
||||
290
apps/times/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
290
apps/times/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settingsCollection } from '$lib/data/local-store';
|
||||
import type { TimesSettings } from '@times/shared';
|
||||
import { CURRENCIES, ROUNDING_INCREMENTS } from '@times/shared/constants';
|
||||
|
||||
const settings = getContext<{ value: TimesSettings | null }>('settings');
|
||||
|
||||
// Local edit state, synced from settings
|
||||
let workingHoursPerDay = $state(8);
|
||||
let workingDaysPerWeek = $state(5);
|
||||
let roundingIncrement = $state(0);
|
||||
let roundingMethod = $state<'none' | 'up' | 'down' | 'nearest'>('none');
|
||||
let defaultRate = $state(0);
|
||||
let defaultCurrency = $state('EUR');
|
||||
let weekStartsOn = $state<0 | 1>(1);
|
||||
let timerReminderMinutes = $state(0);
|
||||
let autoStopTimerHours = $state(0);
|
||||
let defaultVisibility = $state<'private' | 'guild'>('private');
|
||||
let initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const s = settings.value;
|
||||
if (s && !initialized) {
|
||||
workingHoursPerDay = s.workingHoursPerDay;
|
||||
workingDaysPerWeek = s.workingDaysPerWeek;
|
||||
roundingIncrement = s.roundingIncrement;
|
||||
roundingMethod = s.roundingMethod;
|
||||
defaultRate = s.defaultBillingRate?.amount ?? 0;
|
||||
defaultCurrency = s.defaultBillingRate?.currency ?? 'EUR';
|
||||
weekStartsOn = s.weekStartsOn;
|
||||
timerReminderMinutes = s.timerReminderMinutes;
|
||||
autoStopTimerHours = s.autoStopTimerHours;
|
||||
defaultVisibility = s.defaultVisibility;
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function save(updates: Record<string, unknown>) {
|
||||
if (!settings.value) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
settingsCollection.update(settings.value!.id, updates);
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('settings.title')} | Times</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('settings.title')}</h1>
|
||||
|
||||
<!-- Working Time -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Arbeitszeit</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.workingHours')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={workingHoursPerDay}
|
||||
min="1"
|
||||
max="24"
|
||||
step="0.5"
|
||||
oninput={(e) => {
|
||||
workingHoursPerDay = parseFloat((e.target as HTMLInputElement).value) || 8;
|
||||
save({ workingHoursPerDay });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.workingDays')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={workingDaysPerWeek}
|
||||
min="1"
|
||||
max="7"
|
||||
oninput={(e) => {
|
||||
workingDaysPerWeek = parseInt((e.target as HTMLInputElement).value) || 5;
|
||||
save({ workingDaysPerWeek });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.weekStart')}</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
weekStartsOn = 1;
|
||||
save({ weekStartsOn: 1 });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 1
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.monday')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
weekStartsOn = 0;
|
||||
save({ weekStartsOn: 0 });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 0
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.sunday')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rounding -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.rounding')}</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]">Intervall</label>
|
||||
<select
|
||||
value={roundingIncrement}
|
||||
onchange={(e) => {
|
||||
roundingIncrement = parseInt((e.target as HTMLSelectElement).value);
|
||||
save({ roundingIncrement });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{#each ROUNDING_INCREMENTS as inc}
|
||||
<option value={inc}>{inc === 0 ? $_('settings.none') : `${inc} min`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.roundingMethod')}</label
|
||||
>
|
||||
<select
|
||||
value={roundingMethod}
|
||||
onchange={(e) => {
|
||||
roundingMethod = (e.target as HTMLSelectElement).value as any;
|
||||
save({ roundingMethod });
|
||||
}}
|
||||
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="none">{$_('settings.none')}</option>
|
||||
<option value="up">{$_('settings.up')}</option>
|
||||
<option value="down">{$_('settings.down')}</option>
|
||||
<option value="nearest">{$_('settings.nearest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('settings.billingRate')}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.billingRate')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={defaultRate}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
defaultRate = parseFloat((e.target as HTMLInputElement).value) || 0;
|
||||
save({
|
||||
defaultBillingRate:
|
||||
defaultRate > 0
|
||||
? { amount: defaultRate, currency: defaultCurrency, per: 'hour' }
|
||||
: 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))]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.currency')}</label
|
||||
>
|
||||
<select
|
||||
value={defaultCurrency}
|
||||
onchange={(e) => {
|
||||
defaultCurrency = (e.target as HTMLSelectElement).value;
|
||||
if (defaultRate > 0)
|
||||
save({
|
||||
defaultBillingRate: { amount: defaultRate, currency: defaultCurrency, per: 'hour' },
|
||||
});
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{#each CURRENCIES as curr}
|
||||
<option value={curr.code}>{curr.symbol} {curr.code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<span class="pb-2 text-sm text-[hsl(var(--muted-foreground))]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Timer</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.timerReminder')}</label
|
||||
>
|
||||
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
|
||||
<input
|
||||
type="number"
|
||||
value={timerReminderMinutes}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
timerReminderMinutes = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({ timerReminderMinutes });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.autoStop')}</label
|
||||
>
|
||||
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
|
||||
<input
|
||||
type="number"
|
||||
value={autoStopTimerHours}
|
||||
min="0"
|
||||
step="1"
|
||||
oninput={(e) => {
|
||||
autoStopTimerHours = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({ autoStopTimerHours });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.visibility')}</h2>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('settings.visibilityDesc')}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
defaultVisibility = 'private';
|
||||
save({ defaultVisibility: 'private' });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'private'
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.private')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
defaultVisibility = 'guild';
|
||||
save({ defaultVisibility: 'guild' });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'guild'
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.guild')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
197
apps/times/apps/web/src/routes/(app)/templates/+page.svelte
Normal file
197
apps/times/apps/web/src/routes/(app)/templates/+page.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import type { EntryTemplate, Project, Client } from '@times/shared';
|
||||
|
||||
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newProjectId = $state('');
|
||||
let newIsBillable = $state(false);
|
||||
|
||||
let sortedTemplates = $derived(
|
||||
[...allTemplates.value].sort((a, b) => b.usageCount - a.usageCount)
|
||||
);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
await templateCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
name: newName.trim(),
|
||||
projectId: newProjectId || null,
|
||||
clientId: newProjectId
|
||||
? (allProjects.value.find((p) => p.id === newProjectId)?.clientId ?? null)
|
||||
: null,
|
||||
description: newDescription,
|
||||
isBillable: newIsBillable,
|
||||
tags: [],
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newProjectId = '';
|
||||
newIsBillable = false;
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
async function useTemplate(template: EntryTemplate) {
|
||||
// Update usage stats
|
||||
await templateCollection.update(template.id, {
|
||||
usageCount: template.usageCount + 1,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Start timer with template data
|
||||
await timerStore.start({
|
||||
projectId: template.projectId ?? undefined,
|
||||
clientId: template.clientId ?? undefined,
|
||||
description: template.description,
|
||||
isBillable: template.isBillable,
|
||||
tags: template.tags,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: string) {
|
||||
await templateCollection.delete(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.templates')} | Times</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.templates')}</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))]"
|
||||
>
|
||||
+ {$_('template.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"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Vorlagenname"
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newDescription}
|
||||
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))]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
bind:value={newProjectId}
|
||||
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>
|
||||
<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))]"
|
||||
/>
|
||||
{$_('entry.billable')}
|
||||
</label>
|
||||
</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 sortedTemplates.length === 0 && !showCreateForm}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<p>{$_('template.noTemplates')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each sortedTemplates as template (template.id)}
|
||||
{@const project = template.projectId
|
||||
? allProjects.value.find((p) => p.id === template.projectId)
|
||||
: undefined}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
|
||||
>
|
||||
{#if project}
|
||||
<div
|
||||
class="h-3 w-3 shrink-0 rounded-full"
|
||||
style="background-color: {project.color}"
|
||||
></div>
|
||||
{:else}
|
||||
<div class="h-3 w-3 shrink-0 rounded-full bg-gray-400"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{template.name}</p>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{template.description || $_('timer.noDescription')}
|
||||
{#if project}
|
||||
· {project.name}{/if}
|
||||
{#if template.isBillable}
|
||||
· ${/if}
|
||||
{#if template.usageCount > 0}
|
||||
· {template.usageCount}x{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => useTemplate(template)}
|
||||
disabled={timerStore.isRunning}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-xs font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('timer.start')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteTemplate(template.id)}
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
12
apps/times/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
12
apps/times/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 | Times</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/times/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/times/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
270
apps/times/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
270
apps/times/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')} | Times</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))]">Times</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 !== 'times'}
|
||||
<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/times/apps/web/src/routes/+error.svelte
Normal file
19
apps/times/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/times/apps/web/src/routes/+layout.svelte
Normal file
35
apps/times/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/times/apps/web/src/routes/+layout.ts
Normal file
2
apps/times/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/times/apps/web/src/routes/health/+server.ts
Normal file
10
apps/times/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: 'times-web',
|
||||
});
|
||||
};
|
||||
39
apps/times/apps/web/src/routes/offline/+page.svelte
Normal file
39
apps/times/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 | Times</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/times/apps/web/svelte.config.js
Normal file
14
apps/times/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/times/apps/web/tsconfig.json
Normal file
14
apps/times/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"
|
||||
}
|
||||
}
|
||||
60
apps/times/apps/web/vite.config.ts
Normal file
60
apps/times/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { defineConfig } from 'vite';
|
||||
import { getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Times',
|
||||
short_name: 'Times',
|
||||
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(),
|
||||
});
|
||||
14
apps/times/package.json
Normal file
14
apps/times/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "times",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Times - Zeiterfassung & Timetracking",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @times/web dev",
|
||||
"dev:web": "pnpm --filter @times/web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
22
apps/times/packages/shared/package.json
Normal file
22
apps/times/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@times/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/times/packages/shared/src/constants/index.ts
Normal file
40
apps/times/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/times/packages/shared/src/index.ts
Normal file
2
apps/times/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types/index.js';
|
||||
export * from './constants/index.js';
|
||||
154
apps/times/packages/shared/src/types/index.ts
Normal file
154
apps/times/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 TimesSettings {
|
||||
id: string;
|
||||
defaultBillingRate?: BillingRate;
|
||||
workingHoursPerDay: number;
|
||||
workingDaysPerWeek: number;
|
||||
roundingIncrement: number;
|
||||
roundingMethod: RoundingMethod;
|
||||
defaultVisibility: ProjectVisibility;
|
||||
weekStartsOn: 0 | 1;
|
||||
timerReminderMinutes: number;
|
||||
autoStopTimerHours: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── 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/times/packages/shared/tsconfig.json
Normal file
16
apps/times/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/**/*"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue