refactor: consolidate Clock app into Times

Merge the standalone Clock app (alarms, countdown timers, stopwatch,
world clock, pomodoro) into the Times app as a unified time management
application.

Times standalone app:
- Add 3 new collections (alarms, countdownTimers, worldClocks) to timesStore
- Add Clock types and constants to @times/shared
- Add 6 new stores (alarms, countdown-timers, world-clocks, stopwatch, session-*)
- Add 5 new routes under /clock/* (dashboard, alarms, timers, stopwatch, world-clock)
- Extend layout with Clock context providers and navigation items
- Add clock.* i18n namespace (de/en)
- Add WorldMap and CircularProgress components

Manacore unified app:
- Merge clock module into times module (stores, queries, types, components)
- Move Clock DB tables under times appId (timeAlarms, timeCountdownTimers, timeWorldClocks)
- Update search provider, splitscreen registry, dashboard widgets
- Add redirects from /clock/* to /times/clock/*
- Remove @clock/shared dependency

Cleanup:
- Archive Clock app to apps-archived/clock/
- Remove dev:clock:* scripts from root package.json
- Remove Clock from mana-apps.ts, update Times description
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 13:04:07 +02:00
parent 35f4bd48de
commit e870270734
131 changed files with 1524 additions and 5969 deletions

View file

@ -1,12 +1,14 @@
# Times
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
Zeiterfassung, Uhren & Timer - 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.
Times is a combined time tracking and clock app with timer, manual entry, projects, clients, reports, templates, alarms, countdown timers, stopwatch, world clock, and guild (team) integration. Built local-first for offline capability and instant UI.
The Clock app was consolidated into Times — all clock features live under `/clock/*` routes.
### Tech Stack
@ -105,6 +107,35 @@ Recognized patterns:
- Default billing rate with currency (EUR/CHF/USD/GBP)
- Timer reminder and auto-stop configuration
### Clock Features (under /clock/*)
#### Alarms
- Create, edit, delete alarms with time, label, repeat days
- Quick preset alarms (06:00-22:00)
- Sound selection, snooze configuration
- Enable/disable toggle
#### Countdown Timers
- Create countdown timers with custom durations
- Quick presets (1-60 min)
- Start, pause, reset controls
- Browser notifications on completion
#### Stopwatch
- Multiple parallel stopwatches with lap tracking
- Color-coded, focus/unfocus individual stopwatches
- Best/worst lap highlighting
- Local-only (no sync)
#### World Clock
- Track time in multiple timezones
- Interactive world map with city markers
- 30+ popular timezone cities
- Day/night indicator, offset display
#### Pomodoro Presets
- Classic (25/5/15), Short Focus (15/3/10), Deep Work (50/10/30)
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
@ -122,6 +153,9 @@ Recognized patterns:
| tags | Entry categorization | name, order |
| templates | Quick-start templates | usageCount, lastUsedAt |
| settings | App configuration | (single record) |
| alarms | Clock alarms/wecker | enabled, time |
| countdownTimers | Countdown timers | status |
| worldClocks | World clock cities | sortOrder, timezone |
## Project Structure
@ -146,7 +180,13 @@ apps/times/
│ │ │ │ ├── feedback/ # Feedback form
│ │ │ │ ├── profile/ # User profile
│ │ │ │ ├── themes/ # Theme selection
│ │ │ │ └── help/ # Help & docs
│ │ │ │ ├── help/ # Help & docs
│ │ │ │ └── clock/ # Clock features (consolidated from Clock app)
│ │ │ │ ├── +page.svelte # Clock dashboard
│ │ │ │ ├── alarms/ # Alarm management
│ │ │ │ ├── timers/ # Countdown timers
│ │ │ │ ├── stopwatch/ # Stopwatch with laps
│ │ │ │ └── world-clock/ # World clock with map
│ │ │ ├── +layout.svelte # Root layout (i18n, theme, auth init)
│ │ │ ├── +layout.ts # SSR disabled
│ │ │ ├── +error.svelte # Error page
@ -154,17 +194,23 @@ apps/times/
│ │ │ └── offline/ # Offline fallback
│ │ └── lib/
│ │ ├── data/
│ │ │ ├── local-store.ts # 6 collections + typed accessors
│ │ │ ├── local-store.ts # 9 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)
│ │ │ └── guest-seed.ts # Demo data (2 clients, 3 projects, 5 entries, 1 alarm, 2 world clocks)
│ │ ├── 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
│ │ │ ├── user-settings.svelte.ts
│ │ │ ├── alarms.svelte.ts # Clock: alarm CRUD
│ │ │ ├── countdown-timers.svelte.ts # Clock: countdown timer CRUD
│ │ │ ├── world-clocks.svelte.ts # Clock: world clock CRUD
│ │ │ ├── stopwatch.svelte.ts # Clock: stopwatch (local-only)
│ │ │ ├── session-alarms.svelte.ts # Clock: guest session alarms
│ │ │ └── session-timers.svelte.ts # Clock: guest session timers
│ │ ├── components/
│ │ │ ├── TimerCard.svelte # Main timer widget
│ │ │ ├── TimerIndicator.svelte # Compact navbar indicator

View file

@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
daysLived: number;
lifeExpectancyYears?: number;
size?: number;
}
let { daysLived, lifeExpectancyYears = 80, size = 280 }: Props = $props();
let totalDays = $derived(Math.ceil(lifeExpectancyYears * 365.25));
let percentage = $derived(Math.min((daysLived / totalDays) * 100, 100));
let remainingDays = $derived(Math.max(totalDays - daysLived, 0));
let strokeWidth = 12;
let radius = $derived((size - strokeWidth) / 2);
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
let animatedOffset = $state(circumference);
let mounted = $state(false);
onMount(() => {
mounted = true;
requestAnimationFrame(() => {
animatedOffset = dashOffset;
});
});
$effect(() => {
if (mounted) {
animatedOffset = dashOffset;
}
});
</script>
<div class="circular-container">
<div class="circular-wrapper" style="width: {size}px; height: {size}px;">
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="circular-svg">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-muted-foreground) / 0.15)"
stroke-width={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-primary))"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={animatedOffset}
transform="rotate(-90 {size / 2} {size / 2})"
class="progress-circle"
/>
{#each Array(8) as _, i}
{@const angle = (i / 8) * 360 - 90}
{@const markerRadius = radius + strokeWidth / 2 + 8}
{@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)}
{@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="decade-marker">
{i * 10}
</text>
{/each}
</svg>
<div class="center-content">
<span class="percentage">{percentage.toFixed(1)}%</span>
<span class="label">gelebt</span>
</div>
</div>
<div class="circular-stats">
<div class="stat-row">
<div class="stat">
<span class="stat-value lived">{daysLived.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage gelebt</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value remaining">{remainingDays.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage verbleibend</span>
</div>
</div>
<p class="expectancy-note">Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung</p>
</div>
</div>
<style>
.circular-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.circular-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.circular-svg {
transform: rotate(0deg);
}
.progress-circle {
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.decade-marker {
font-size: 0.625rem;
fill: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.center-content {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.percentage {
font-size: 2.5rem;
font-weight: 200;
color: hsl(var(--color-foreground));
line-height: 1;
}
.label {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.circular-stats {
text-align: center;
}
.stat-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-divider {
width: 1px;
height: 2.5rem;
background: hsl(var(--color-border));
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-value.lived {
color: hsl(var(--color-primary));
}
.stat-value.remaining {
color: hsl(var(--color-muted-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.expectancy-note {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground) / 0.7);
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,108 @@
<script lang="ts">
/**
* WorldMap - Interactive world map component for world clock
*/
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { POPULAR_TIMEZONES } from '@times/shared';
interface Props {
selectedTimezones?: string[];
onCityClick?: (timezone: string, cityName: string) => void;
}
let { selectedTimezones = [], onCityClick }: Props = $props();
let mapContainer: HTMLDivElement;
let mapLoaded = $state(false);
const cities = POPULAR_TIMEZONES.map((tz) => ({
timezone: tz.timezone,
city: tz.city,
lat: tz.lat,
lng: tz.lng,
}));
function handleCityClick(timezone: string, cityName: string) {
onCityClick?.(timezone, cityName);
}
onMount(() => {
if (browser) {
mapLoaded = true;
}
});
</script>
<div class="world-map" bind:this={mapContainer}>
{#if mapLoaded}
<div class="map-placeholder">
<svg viewBox="0 0 800 400" class="map-svg">
<rect x="0" y="0" width="800" height="400" fill="hsl(var(--muted))" opacity="0.3" />
{#each cities as city}
{@const x = ((city.lng + 180) / 360) * 800}
{@const y = ((90 - city.lat) / 180) * 400}
{@const isSelected = selectedTimezones.includes(city.timezone)}
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
<circle
cx={x}
cy={y}
r={isSelected ? 8 : 5}
fill={isSelected ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'}
class="cursor-pointer hover:opacity-80 transition-all"
/>
{#if isSelected}
<text
{x}
y={y - 12}
text-anchor="middle"
font-size="10"
fill="hsl(var(--foreground))"
class="pointer-events-none"
>
{city.city}
</text>
{/if}
</g>
{/each}
</svg>
</div>
{:else}
<div class="map-loading">
<span class="text-muted-foreground">Karte wird geladen...</span>
</div>
{/if}
</div>
<style>
.world-map {
width: 100%;
height: 300px;
background: hsl(var(--card));
border-radius: 12px;
overflow: hidden;
border: 1px solid hsl(var(--border));
}
.map-placeholder {
width: 100%;
height: 100%;
}
.map-svg {
width: 100%;
height: 100%;
}
.city-marker {
cursor: pointer;
}
.map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -10,6 +10,8 @@ import type {
LocalTimeEntry,
LocalTag,
LocalSettings,
LocalAlarm,
LocalWorldClock,
} from './local-store';
const DEMO_CLIENT_ID = 'demo-client-acme';
@ -181,3 +183,33 @@ export const guestSettings: LocalSettings[] = [
autoStopTimerHours: 0,
},
];
// ─── Clock Guest Seed ───────────────────────────────────────
export const guestAlarms: LocalAlarm[] = [
{
id: 'alarm-weekday-morning',
label: 'Wecker Wochentags',
time: '07:00',
enabled: true,
repeatDays: [1, 2, 3, 4, 5],
snoozeMinutes: 5,
sound: null,
vibrate: true,
},
];
export const guestWorldClocks: LocalWorldClock[] = [
{
id: 'wc-new-york',
timezone: 'America/New_York',
cityName: 'New York',
sortOrder: 0,
},
{
id: 'wc-tokyo',
timezone: 'Asia/Tokyo',
cityName: 'Tokio',
sortOrder: 1,
},
];

View file

@ -12,8 +12,15 @@ import {
guestTimeEntries,
guestTags,
guestSettings,
guestAlarms,
guestWorldClocks,
} from './guest-seed';
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@times/shared';
import type {
BillingRate,
ProjectVisibility,
EntrySourceRef,
CountdownTimerStatus,
} from '@times/shared';
// ─── Types ──────────────────────────────────────────────────
@ -93,6 +100,34 @@ export interface LocalSettings extends BaseRecord {
autoStopTimerHours: number;
}
// ─── Clock Types ────────────────────────────────────────────
export interface LocalAlarm extends BaseRecord {
label: string | null;
time: string; // HH:mm format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
}
export interface LocalCountdownTimer extends BaseRecord {
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: CountdownTimerStatus;
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
}
export interface LocalWorldClock extends BaseRecord {
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
@ -138,6 +173,21 @@ export const timesStore = createLocalStore({
indexes: [],
guestSeed: guestSettings,
},
// ─── Clock Collections ───
{
name: 'alarms',
indexes: ['enabled', 'time'],
guestSeed: guestAlarms,
},
{
name: 'countdownTimers',
indexes: ['status'],
},
{
name: 'worldClocks',
indexes: ['sortOrder', 'timezone'],
guestSeed: guestWorldClocks,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
@ -151,3 +201,9 @@ export const timeEntryCollection = timesStore.collection<LocalTimeEntry>('timeEn
export const tagCollection = timesStore.collection<LocalTag>('tags');
export const templateCollection = timesStore.collection<LocalTemplate>('templates');
export const settingsCollection = timesStore.collection<LocalSettings>('settings');
// Clock collection accessors
export const alarmCollection = timesStore.collection<LocalAlarm>('alarms');
export const countdownTimerCollection =
timesStore.collection<LocalCountdownTimer>('countdownTimers');
export const worldClockCollection = timesStore.collection<LocalWorldClock>('worldClocks');

View file

@ -13,12 +13,18 @@ import {
tagCollection,
templateCollection,
settingsCollection,
alarmCollection,
countdownTimerCollection,
worldClockCollection,
type LocalClient,
type LocalProject,
type LocalTimeEntry,
type LocalTag,
type LocalTemplate,
type LocalSettings,
type LocalAlarm,
type LocalCountdownTimer,
type LocalWorldClock,
} from './local-store';
import type {
Client,
@ -29,6 +35,9 @@ import type {
TimesSettings,
FilterCriteria,
SortOption,
Alarm,
CountdownTimer,
WorldClock,
} from '@times/shared';
// ─── Type Converters ───────────────────────────────────────
@ -336,3 +345,88 @@ export function getClientById(clients: Client[], id: string): Client | undefined
export function getProjectsByClient(projects: Project[], clientId: string): Project[] {
return projects.filter((p) => p.clientId === clientId);
}
// ─── Clock Type Converters ───────────────────────────────────
export function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCountdownTimer(local: LocalCountdownTimer): CountdownTimer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Clock Live Query Hooks ──────────────────────────────────
export function useAllAlarms() {
return useLiveQueryWithDefault(async () => {
const locals = await alarmCollection.getAll();
return locals.map(toAlarm);
}, [] as Alarm[]);
}
export function useAllCountdownTimers() {
return useLiveQueryWithDefault(async () => {
const locals = await countdownTimerCollection.getAll();
return locals.map(toCountdownTimer);
}, [] as CountdownTimer[]);
}
export function useAllWorldClocks() {
return useLiveQueryWithDefault(async () => {
const locals = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toWorldClock);
}, [] as WorldClock[]);
}
// ─── Clock Pure Helpers ──────────────────────────────────────
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
return alarms.filter((a) => a.enabled);
}
export function filterActiveCountdownTimers(timers: CountdownTimer[]): CountdownTimer[] {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
}
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
}

View file

@ -11,7 +11,11 @@
"clients": "Kunden",
"reports": "Reports",
"settings": "Einstellungen",
"templates": "Vorlagen"
"templates": "Vorlagen",
"alarms": "Wecker",
"countdown": "Countdown",
"stopwatch": "Stoppuhr",
"worldClock": "Weltuhr"
},
"timer": {
"start": "Timer starten",
@ -131,6 +135,46 @@
"hours": "Stunden",
"minutes": "Minuten"
},
"clock": {
"alarm": {
"title": "Wecker",
"custom": "Eigene Wecker",
"edit": "Wecker bearbeiten",
"time": "Uhrzeit",
"label": "Bezeichnung",
"repeat": "Wiederholung",
"sound": "Ton",
"snooze": "Schlummern"
},
"timer": {
"title": "Countdown Timer",
"finished": "Timer abgelaufen!"
},
"stopwatch": {
"title": "Stoppuhr",
"new": "Neue Stoppuhr",
"start": "Start",
"stop": "Stopp",
"continue": "Weiter",
"reset": "Reset",
"lap": "Runde",
"laps": "Runden",
"total": "Gesamt",
"best": "Beste",
"worst": "Langsamste",
"noStopwatches": "Keine Stoppuhren",
"noStopwatchesDescription": "Erstelle eine neue Stoppuhr um Zeit zu messen.",
"startFirst": "Erste Stoppuhr starten",
"otherStopwatches": "Weitere Stoppuhren"
},
"worldClock": {
"title": "Weltzeituhr",
"add": "Stadt hinzufügen",
"search": "Stadt suchen...",
"noClocks": "Keine Weltuhren hinzugefügt",
"same": "Gleiche Zeit"
}
},
"error": {
"notFound": "Seite nicht gefunden",
"backToHome": "Zurück zur Startseite"

View file

@ -11,7 +11,11 @@
"clients": "Clients",
"reports": "Reports",
"settings": "Settings",
"templates": "Templates"
"templates": "Templates",
"alarms": "Alarms",
"countdown": "Countdown",
"stopwatch": "Stopwatch",
"worldClock": "World Clock"
},
"timer": {
"start": "Start Timer",
@ -131,6 +135,46 @@
"hours": "Hours",
"minutes": "Minutes"
},
"clock": {
"alarm": {
"title": "Alarms",
"custom": "Custom Alarms",
"edit": "Edit Alarm",
"time": "Time",
"label": "Label",
"repeat": "Repeat",
"sound": "Sound",
"snooze": "Snooze"
},
"timer": {
"title": "Countdown Timer",
"finished": "Timer finished!"
},
"stopwatch": {
"title": "Stopwatch",
"new": "New Stopwatch",
"start": "Start",
"stop": "Stop",
"continue": "Continue",
"reset": "Reset",
"lap": "Lap",
"laps": "Laps",
"total": "Total",
"best": "Best",
"worst": "Worst",
"noStopwatches": "No Stopwatches",
"noStopwatchesDescription": "Create a new stopwatch to start timing.",
"startFirst": "Start First Stopwatch",
"otherStopwatches": "Other Stopwatches"
},
"worldClock": {
"title": "World Clock",
"add": "Add City",
"search": "Search city...",
"noClocks": "No world clocks added",
"same": "Same time"
}
},
"error": {
"notFound": "Page not found",
"backToHome": "Back to home"

View file

@ -0,0 +1,85 @@
/**
* Alarms Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, toggle).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import { toAlarm } from '$lib/data/queries';
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@times/shared';
let error = $state<string | null>(null);
export const alarmsStore = {
get error() {
return error;
},
async createAlarm(input: CreateAlarmInput) {
error = null;
try {
const newLocal: LocalAlarm = {
id: crypto.randomUUID(),
label: input.label ?? null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays ?? null,
snoozeMinutes: input.snoozeMinutes ?? null,
sound: input.sound ?? null,
vibrate: input.vibrate ?? null,
};
const inserted = await alarmCollection.insert(newLocal);
return { success: true, data: toAlarm(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
return { success: false, error: error };
}
},
async updateAlarm(id: string, input: UpdateAlarmInput) {
error = null;
try {
const updateData: Partial<LocalAlarm> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.time !== undefined) updateData.time = input.time;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
const updated = await alarmCollection.update(id, updateData);
if (updated) {
return { success: true, data: toAlarm(updated) };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update alarm';
console.error('Failed to update alarm:', e);
return { success: false, error: error };
}
},
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
const alarm = currentAlarms.find((a) => a.id === id);
if (!alarm) return { success: false, error: 'Alarm not found' };
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
async deleteAlarm(id: string) {
error = null;
try {
await alarmCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
console.error('Failed to delete alarm:', e);
return { success: false, error: error };
}
},
};

View file

@ -0,0 +1,163 @@
/**
* Countdown Timers Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, start, pause, reset).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { countdownTimerCollection, type LocalCountdownTimer } from '$lib/data/local-store';
import { toCountdownTimer } from '$lib/data/queries';
import type { CreateCountdownTimerInput, UpdateCountdownTimerInput } from '@times/shared';
let error = $state<string | null>(null);
export const countdownTimersStore = {
get error() {
return error;
},
async createTimer(input: CreateCountdownTimerInput) {
error = null;
try {
const newLocal: LocalCountdownTimer = {
id: crypto.randomUUID(),
label: input.label ?? null,
durationSeconds: input.durationSeconds,
remainingSeconds: null,
status: 'idle',
startedAt: null,
pausedAt: null,
sound: input.sound ?? null,
};
const inserted = await countdownTimerCollection.insert(newLocal);
return { success: true, data: toCountdownTimer(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
return { success: false, error: error };
}
},
async updateTimer(id: string, input: UpdateCountdownTimerInput) {
error = null;
try {
const updateData: Partial<LocalCountdownTimer> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
const updated = await countdownTimerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toCountdownTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update timer';
console.error('Failed to update timer:', e);
return { success: false, error: error };
}
},
async startTimer(id: string) {
error = null;
try {
const existing = await countdownTimerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
const updateData: Partial<LocalCountdownTimer> = {
status: 'running',
startedAt: new Date().toISOString(),
pausedAt: null,
};
if (existing.status !== 'paused') {
updateData.remainingSeconds = existing.durationSeconds;
}
const updated = await countdownTimerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toCountdownTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start timer';
console.error('Failed to start timer:', e);
return { success: false, error: error };
}
},
async pauseTimer(id: string) {
error = null;
try {
const existing = await countdownTimerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
let remaining = existing.remainingSeconds ?? existing.durationSeconds;
if (existing.startedAt) {
const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000;
remaining = Math.max(0, remaining - elapsed);
}
const updateData: Partial<LocalCountdownTimer> = {
status: 'paused',
pausedAt: new Date().toISOString(),
remainingSeconds: Math.round(remaining),
startedAt: null,
};
const updated = await countdownTimerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toCountdownTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to pause timer';
console.error('Failed to pause timer:', e);
return { success: false, error: error };
}
},
async resetTimer(id: string) {
error = null;
try {
const updateData: Partial<LocalCountdownTimer> = {
status: 'idle',
remainingSeconds: null,
startedAt: null,
pausedAt: null,
};
const updated = await countdownTimerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toCountdownTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reset timer';
console.error('Failed to reset timer:', e);
return { success: false, error: error };
}
},
async deleteTimer(id: string) {
error = null;
try {
await countdownTimerCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
console.error('Failed to delete timer:', e);
return { success: false, error: error };
}
},
async updateLocalTimer(id: string, remainingSeconds: number) {
try {
await countdownTimerCollection.update(id, { remainingSeconds });
} catch (e) {
console.error('Failed to update local timer:', e);
}
},
};

View file

@ -0,0 +1,113 @@
/**
* Session Alarms Store - Manages alarms in sessionStorage for guest users
*/
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@times/shared';
const STORAGE_KEY = 'times-session-alarms';
let alarms = $state<Alarm[]>([]);
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
alarms = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session alarms:', e);
}
}
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms));
} catch (e) {
console.error('Failed to save session alarms:', e);
}
}
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionAlarmsStore = {
get alarms() {
return alarms;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
createAlarm(input: CreateAlarmInput): Alarm {
const now = new Date().toISOString();
const alarm: Alarm = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays || null,
snoozeMinutes: input.snoozeMinutes || null,
sound: input.sound || null,
vibrate: input.vibrate ?? null,
createdAt: now,
updatedAt: now,
};
alarms = [...alarms, alarm];
saveToStorage();
return alarm;
},
updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null {
const index = alarms.findIndex((a) => a.id === id);
if (index === -1) return null;
const updated: Alarm = {
...alarms[index],
...input,
updatedAt: new Date().toISOString(),
};
alarms = alarms.map((a) => (a.id === id ? updated : a));
saveToStorage();
return updated;
},
toggleAlarm(id: string): Alarm | null {
const alarm = alarms.find((a) => a.id === id);
if (!alarm) return null;
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
deleteAlarm(id: string): void {
alarms = alarms.filter((a) => a.id !== id);
saveToStorage();
},
isSessionAlarm(id: string): boolean {
return id.startsWith('session_');
},
getAllAlarms(): Alarm[] {
return [...alarms];
},
clear(): void {
alarms = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
get count(): number {
return alarms.length;
},
};

View file

@ -0,0 +1,171 @@
/**
* Session Timers Store - Manages countdown timers in sessionStorage for guest users
*/
import type {
CountdownTimer,
CreateCountdownTimerInput,
UpdateCountdownTimerInput,
CountdownTimerStatus,
} from '@times/shared';
const STORAGE_KEY = 'times-session-timers';
let timers = $state<CountdownTimer[]>([]);
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
timers = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session timers:', e);
}
}
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers));
} catch (e) {
console.error('Failed to save session timers:', e);
}
}
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionTimersStore = {
get timers() {
return timers;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
createTimer(input: CreateCountdownTimerInput): CountdownTimer {
const now = new Date().toISOString();
const timer: CountdownTimer = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
durationSeconds: input.durationSeconds,
remainingSeconds: input.durationSeconds,
status: 'idle' as CountdownTimerStatus,
startedAt: null,
pausedAt: null,
sound: input.sound || null,
createdAt: now,
updatedAt: now,
};
timers = [...timers, timer];
saveToStorage();
return timer;
},
updateTimer(id: string, input: UpdateCountdownTimerInput): CountdownTimer | null {
const index = timers.findIndex((t) => t.id === id);
if (index === -1) return null;
const updated: CountdownTimer = {
...timers[index],
...input,
updatedAt: new Date().toISOString(),
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
startTimer(id: string): CountdownTimer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: CountdownTimer = {
...timer,
status: 'running',
startedAt: now,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
pauseTimer(id: string): CountdownTimer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: CountdownTimer = {
...timer,
status: 'paused',
pausedAt: now,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
resetTimer(id: string): CountdownTimer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: CountdownTimer = {
...timer,
status: 'idle',
remainingSeconds: timer.durationSeconds,
startedAt: null,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
updateLocalState(id: string, updates: Partial<CountdownTimer>): void {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
saveToStorage();
},
deleteTimer(id: string): void {
timers = timers.filter((t) => t.id !== id);
saveToStorage();
},
isSessionTimer(id: string): boolean {
return id.startsWith('session_');
},
getAllTimers(): CountdownTimer[] {
return [...timers];
},
clear(): void {
timers = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
get count(): number {
return timers.length;
},
};

View file

@ -0,0 +1,194 @@
/**
* Stopwatch Store - Manages stopwatch state using Svelte 5 runes
* Stopwatches are local-only (no backend sync)
*/
export interface Lap {
number: number;
time: number;
delta: number;
}
export interface Stopwatch {
id: string;
label: string;
startTime: number | null;
elapsedTime: number;
status: 'idle' | 'running' | 'paused';
laps: Lap[];
color: string;
}
export const STOPWATCH_COLORS = [
'#3B82F6',
'#10B981',
'#F59E0B',
'#EF4444',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#F97316',
];
let stopwatches = $state<Stopwatch[]>([]);
let focusedId = $state<string | null>(null);
let colorIndex = 0;
let tickInterval: ReturnType<typeof setInterval> | null = null;
function getNextColor(): string {
const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length];
colorIndex++;
return color;
}
function startTicking() {
if (tickInterval) return;
tickInterval = setInterval(() => {
stopwatches = [...stopwatches];
}, 100);
}
function stopTickingIfNoRunning() {
const hasRunning = stopwatches.some((sw) => sw.status === 'running');
if (!hasRunning && tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
export const stopwatchesStore = {
get stopwatches() {
return stopwatches;
},
get focusedId() {
return focusedId;
},
get focusedStopwatch() {
return stopwatches.find((sw) => sw.id === focusedId) || null;
},
create(label?: string): string {
const id = crypto.randomUUID();
const newStopwatch: Stopwatch = {
id,
label: label || `Stopwatch ${stopwatches.length + 1}`,
startTime: null,
elapsedTime: 0,
status: 'idle',
laps: [],
color: getNextColor(),
};
stopwatches = [...stopwatches, newStopwatch];
if (!focusedId) {
focusedId = id;
}
return id;
},
start(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: Date.now(),
status: 'running' as const,
};
});
startTicking();
},
pause(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const elapsed = sw.startTime ? Date.now() - sw.startTime : 0;
return {
...sw,
startTime: null,
elapsedTime: sw.elapsedTime + elapsed,
status: 'paused' as const,
};
});
stopTickingIfNoRunning();
},
reset(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: null,
elapsedTime: 0,
status: 'idle' as const,
laps: [],
};
});
stopTickingIfNoRunning();
},
addLap(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const currentTime = this.getElapsed(sw);
const lastLap = sw.laps[sw.laps.length - 1];
const delta = lastLap ? currentTime - lastLap.time : currentTime;
const newLap: Lap = {
number: sw.laps.length + 1,
time: currentTime,
delta,
};
return {
...sw,
laps: [...sw.laps, newLap],
};
});
},
delete(id: string) {
stopwatches = stopwatches.filter((sw) => sw.id !== id);
if (focusedId === id) {
focusedId = stopwatches[0]?.id || null;
}
stopTickingIfNoRunning();
},
setFocused(id: string | null) {
focusedId = id;
},
updateLabel(id: string, label: string) {
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
},
getElapsed(sw: Stopwatch): number {
if (sw.status === 'running' && sw.startTime) {
return sw.elapsedTime + (Date.now() - sw.startTime);
}
return sw.elapsedTime;
},
};
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
export function formatLapTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (minutes > 0) {
return `+${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `+${seconds}.${centiseconds.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,65 @@
/**
* World Clocks Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (add, remove, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { CreateWorldClockInput } from '@times/shared';
let error = $state<string | null>(null);
export const worldClocksStore = {
get error() {
return error;
},
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: currentCount,
};
await worldClockCollection.insert(newLocal);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
return { success: false, error: error };
}
},
async removeWorldClock(id: string) {
error = null;
try {
await worldClockCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
console.error('Failed to remove world clock:', e);
return { success: false, error: error };
}
},
async reorder(ids: string[]) {
error = null;
try {
for (let i = 0; i < ids.length; i++) {
await worldClockCollection.update(ids[i], {
sortOrder: i,
} as Partial<LocalWorldClock>);
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
console.error('Failed to reorder world clocks:', e);
return { success: false, error: error };
}
},
};

View file

@ -22,6 +22,9 @@
useAllTags,
useAllTemplates,
useSettings,
useAllAlarms,
useAllCountdownTimers,
useAllWorldClocks,
} from '$lib/data/queries';
let { children } = $props();
@ -30,7 +33,7 @@
let initialized = $state(false);
let showGuestWelcome = $state(false);
// Live queries
// Live queries — Time Tracking
const allClients = useAllClients();
const allProjects = useAllProjects();
const allTimeEntries = useAllTimeEntries();
@ -38,6 +41,11 @@
const allTemplates = useAllTemplates();
const settings = useSettings();
// Live queries — Clock
const allAlarms = useAllAlarms();
const allCountdownTimers = useAllCountdownTimers();
const allWorldClocks = useAllWorldClocks();
// Provide data to child components
setContext('clients', allClients);
setContext('projects', allProjects);
@ -45,6 +53,9 @@
setContext('tags', allTags);
setContext('templates', allTemplates);
setContext('settings', settings);
setContext('alarms', allAlarms);
setContext('countdownTimers', allCountdownTimers);
setContext('worldClocks', allWorldClocks);
async function handleAuthReady() {
await timesStore.initialize();
@ -69,6 +80,11 @@
{ href: '/clients', label: $_('nav.clients'), icon: 'buildings' },
{ href: '/reports', label: $_('nav.reports'), icon: 'chart-bar' },
{ href: '/templates', label: $_('nav.templates'), icon: 'bookmark' },
{ href: '/clock', label: 'Clock', icon: 'clock', separator: true },
{ href: '/clock/alarms', label: $_('nav.alarms'), icon: 'bell' },
{ href: '/clock/timers', label: $_('nav.countdown'), icon: 'timer' },
{ href: '/clock/stopwatch', label: $_('nav.stopwatch'), icon: 'hourglass' },
{ href: '/clock/world-clock', label: $_('nav.worldClock'), icon: 'globe' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{ href: '/mana', label: 'Mana', icon: 'star' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
@ -111,7 +127,7 @@
<!-- Nav Items -->
<div class="hidden items-center gap-1 md:flex">
{#each navItems.slice(0, 5) as item}
{#each navItems.slice(0, 11) as item}
<a
href={item.href}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {$page.url.pathname ===
@ -158,7 +174,7 @@
<!-- Mobile nav -->
<div class="flex gap-1 overflow-x-auto px-4 pb-2 md:hidden">
{#each navItems.slice(0, 5) as item}
{#each navItems.slice(0, 11) as item}
<a
href={item.href}
class="shrink-0 rounded-full px-3 py-1 text-xs transition-colors {$page.url

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { Clock, Bell, Timer, Hourglass, Globe } from '@manacore/shared-icons';
const quickLinks = [
{
href: '/clock/world-clock',
icon: Globe,
label: 'Weltzeituhr',
description: 'Zeitzonen im Blick',
color: 'bg-blue-500',
},
{
href: '/clock/alarms',
icon: Bell,
label: 'Wecker',
description: 'Alarme verwalten',
color: 'bg-amber-500',
},
{
href: '/clock/timers',
icon: Timer,
label: 'Timer',
description: 'Countdowns starten',
color: 'bg-green-500',
},
{
href: '/clock/stopwatch',
icon: Hourglass,
label: 'Stoppuhr',
description: 'Zeit messen',
color: 'bg-purple-500',
},
];
</script>
<svelte:head>
<title>Times - Clock</title>
</svelte:head>
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Clock</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Zeit-Management Hub</p>
</header>
<!-- Current Time Display -->
<div class="current-time-card mb-8 p-6 rounded-xl bg-card border border-border">
<div class="flex items-center gap-4">
<div class="p-3 rounded-full bg-primary/10">
<Clock size={32} class="text-primary" />
</div>
<div>
<div class="text-4xl font-bold text-foreground tabular-nums">
{new Date().toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div class="text-muted-foreground">
{new Date().toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each quickLinks as link}
<a
href={link.href}
class="quick-link p-4 rounded-xl bg-card border border-border hover:border-primary/50 transition-all hover:shadow-lg group"
>
<div class="flex flex-col items-center text-center gap-3">
<div
class="{link.color} p-3 rounded-full text-white group-hover:scale-110 transition-transform"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>
<style>
.dashboard {
max-width: 800px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,292 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import type { Alarm } from '@times/shared';
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@times/shared';
const allAlarms: { readonly value: Alarm[] } = getContext('alarms');
let newTime = $state('07:00');
let newLabel = $state('');
let newRepeatDays = $state<number[]>([]);
let showOptions = $state(false);
let showEditModal = $state(false);
let editingId = $state<string | null>(null);
let editTime = $state('07:00');
let editLabel = $state('');
let editRepeatDays = $state<number[]>([]);
let editSound = $state('default');
let editSnoozeMinutes = $state(5);
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
function findAlarmForPreset(presetTime: string): Alarm | undefined {
return allAlarms.value.find((a) => a.time.slice(0, 5) === presetTime);
}
async function togglePreset(presetTime: string, presetLabel: string) {
const existingAlarm = findAlarmForPreset(presetTime);
if (existingAlarm) {
await alarmsStore.toggleAlarm(existingAlarm.id, allAlarms.value);
} else {
const result = await alarmsStore.createAlarm({
time: presetTime + ':00',
label: presetLabel,
enabled: true,
});
if (result.success) {
toast.success('Wecker erstellt');
} else {
toast.error(result.error || 'Fehler beim Erstellen');
}
}
}
async function handleQuickCreate() {
const result = await alarmsStore.createAlarm({
time: newTime + ':00',
label: newLabel || undefined,
repeatDays: newRepeatDays.length > 0 ? newRepeatDays : undefined,
enabled: true,
});
if (result.success) {
toast.success('Wecker erstellt');
newTime = '07:00';
newLabel = '';
newRepeatDays = [];
showOptions = false;
} else {
toast.error(result.error || 'Fehler beim Erstellen');
}
}
function toggleNewDay(day: number) {
if (newRepeatDays.includes(day)) {
newRepeatDays = newRepeatDays.filter((d) => d !== day);
} else {
newRepeatDays = [...newRepeatDays, day];
}
}
function openEditModal(alarm: Alarm) {
editingId = alarm.id;
editTime = alarm.time.slice(0, 5);
editLabel = alarm.label || '';
editRepeatDays = alarm.repeatDays || [];
editSound = alarm.sound || 'default';
editSnoozeMinutes = alarm.snoozeMinutes || 5;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingId = null;
}
function toggleEditDay(day: number) {
if (editRepeatDays.includes(day)) {
editRepeatDays = editRepeatDays.filter((d) => d !== day);
} else {
editRepeatDays = [...editRepeatDays, day];
}
}
async function handleEditSubmit() {
if (!editingId) return;
const result = await alarmsStore.updateAlarm(editingId, {
time: editTime + ':00',
label: editLabel || undefined,
repeatDays: editRepeatDays.length > 0 ? editRepeatDays : undefined,
sound: editSound,
snoozeMinutes: editSnoozeMinutes,
});
if (result.success) {
toast.success('Wecker aktualisiert');
closeEditModal();
} else {
toast.error(result.error || 'Fehler beim Speichern');
}
}
async function handleDelete(id: string) {
const result = await alarmsStore.deleteAlarm(id);
if (result.success) {
toast.success('Wecker gelöscht');
} else {
toast.error(result.error || 'Fehler beim Löschen');
}
}
async function handleToggle(id: string) {
await alarmsStore.toggleAlarm(id, allAlarms.value);
}
function getRepeatText(days: number[] | null) {
if (!days || days.length === 0) return 'Einmalig';
if (days.length === 7) return 'Täglich';
if (days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))) return 'Wochentags';
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Am Wochenende';
return days.map((d) => dayNames[d]).join(', ');
}
</script>
<PageHeader title={$_('clock.alarm.title')} size="md" centered />
<div class="space-y-4">
<div class="quick-create">
<input type="time" class="time-input-inline" bind:value={newTime} />
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={newLabel} />
<button
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
onclick={() => (showOptions = !showOptions)}
title="Wiederholung"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
class:text-primary={newRepeatDays.length > 0}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
</button>
<button class="btn btn-primary btn-sm" onclick={handleQuickCreate}> + </button>
</div>
{#if showOptions}
<div class="day-selector-compact">
{#each dayNames as day, i}
<button
type="button"
class:active={newRepeatDays.includes(i)}
onclick={() => toggleNewDay(i)}
>
{day}
</button>
{/each}
</div>
{/if}
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
</div>
{/each}
</div>
{#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0}
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('clock.alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if showEditModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="card w-full max-w-md">
<h2 class="mb-4 text-xl font-semibold">{$_('clock.alarm.edit')}</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleEditSubmit();
}}
>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('clock.alarm.time')}</label>
<input type="time" class="input time-input" bind:value={editTime} />
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('clock.alarm.label')}</label>
<input
type="text"
class="input"
placeholder="Arbeit, Sport, etc."
bind:value={editLabel}
/>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('clock.alarm.repeat')}</label>
<div class="day-selector">
{#each dayNames as day, i}
<button
type="button"
class:active={editRepeatDays.includes(i)}
onclick={() => toggleEditDay(i)}>{day}</button
>
{/each}
</div>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('clock.alarm.sound')}</label>
<select class="input" bind:value={editSound}>
{#each ALARM_SOUNDS as sound}
<option value={sound.id}>{sound.nameDE}</option>
{/each}
</select>
</div>
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('clock.alarm.snooze')}</label>
<select class="input" bind:value={editSnoozeMinutes}>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-secondary flex-1" onclick={closeEditModal}
>{$_('common.cancel')}</button
>
<button type="submit" class="btn btn-primary flex-1">{$_('common.save')}</button>
</div>
</form>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,396 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { PageHeader } from '@manacore/shared-ui';
import { Clock } from '@manacore/shared-icons';
import {
stopwatchesStore,
formatTime,
formatLapTime,
type Stopwatch,
} from '$lib/stores/stopwatch.svelte';
let editingLabelId = $state<string | null>(null);
let editingLabelValue = $state('');
function handleCreateNew() {
const id = stopwatchesStore.create();
stopwatchesStore.start(id);
}
function handleFocus(id: string) {
stopwatchesStore.setFocused(id);
}
function startEditLabel(sw: Stopwatch) {
editingLabelId = sw.id;
editingLabelValue = sw.label;
}
function saveLabel() {
if (editingLabelId && editingLabelValue.trim()) {
stopwatchesStore.updateLabel(editingLabelId, editingLabelValue.trim());
}
editingLabelId = null;
}
function handleLabelKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') saveLabel();
else if (e.key === 'Escape') editingLabelId = null;
}
function getBestLap(sw: Stopwatch) {
if (sw.laps.length < 2) return null;
return sw.laps.reduce((best, lap) => (lap.delta < best.delta ? lap : best));
}
function getWorstLap(sw: Stopwatch) {
if (sw.laps.length < 2) return null;
return sw.laps.reduce((worst, lap) => (lap.delta > worst.delta ? lap : worst));
}
let focused = $derived(stopwatchesStore.focusedStopwatch);
let otherStopwatches = $derived(
stopwatchesStore.stopwatches.filter((sw) => sw.id !== stopwatchesStore.focusedId)
);
</script>
<div class="flex items-center justify-between mb-6">
<PageHeader title={$_('clock.stopwatch.title')} size="md" />
<button class="btn btn-primary btn-sm" onclick={handleCreateNew}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
{$_('clock.stopwatch.new')}
</button>
</div>
{#if stopwatchesStore.stopwatches.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-24 h-24 mb-6 rounded-full bg-muted flex items-center justify-center">
<Clock size={20} class="text-muted-foreground" />
</div>
<h2 class="text-xl font-medium text-foreground mb-2">{$_('clock.stopwatch.noStopwatches')}</h2>
<p class="text-muted-foreground mb-6 max-w-sm">
{$_('clock.stopwatch.noStopwatchesDescription')}
</p>
<button class="btn btn-primary btn-lg" onclick={handleCreateNew}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
{$_('clock.stopwatch.startFirst')}
</button>
</div>
{:else}
<div class="space-y-4">
{#if focused}
{@const bestLap = getBestLap(focused)}
{@const worstLap = getWorstLap(focused)}
{@const isRunning = focused.status === 'running'}
<div class="stopwatch-card-focused" style="--sw-color: {focused.color}">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
class:animate-pulse={isRunning}
style="background-color: {focused.color}"
></div>
{#if editingLabelId === focused.id}
<input
type="text"
class="bg-transparent border-b border-primary text-lg font-medium focus:outline-none"
bind:value={editingLabelValue}
onblur={saveLabel}
onkeydown={handleLabelKeydown}
autofocus
/>
{:else}
<button
class="text-lg font-medium hover:text-primary transition-colors"
onclick={() => startEditLabel(focused)}
>
{focused.label}
</button>
{/if}
</div>
<button
class="text-muted-foreground hover:text-error transition-colors p-1"
onclick={() => stopwatchesStore.delete(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="flex flex-col items-center mb-6">
<div
class="digital-clock text-5xl sm:text-6xl font-light tabular-nums"
class:text-primary={isRunning}
>
{formatTime(stopwatchesStore.getElapsed(focused))}
</div>
{#if focused.laps.length > 0}
<div class="text-sm text-muted-foreground mt-1">
{focused.laps.length}
{$_('clock.stopwatch.laps')}
</div>
{/if}
</div>
<div class="flex justify-center gap-3 mb-6">
{#if isRunning}
<button
class="btn btn-secondary btn-lg"
onclick={() => stopwatchesStore.pause(focused.id)}
>
{$_('clock.stopwatch.stop')}
</button>
<button
class="btn btn-primary btn-lg"
onclick={() => stopwatchesStore.addLap(focused.id)}
>
{$_('clock.stopwatch.lap')}
</button>
{:else if focused.elapsedTime > 0}
<button
class="btn btn-primary btn-lg"
onclick={() => stopwatchesStore.start(focused.id)}
>
{$_('clock.stopwatch.continue')}
</button>
<button
class="btn btn-secondary btn-lg"
onclick={() => stopwatchesStore.reset(focused.id)}
>
{$_('clock.stopwatch.reset')}
</button>
{:else}
<button
class="btn btn-primary btn-lg"
onclick={() => stopwatchesStore.start(focused.id)}
>
{$_('clock.stopwatch.start')}
</button>
{/if}
</div>
{#if focused.laps.length > 0}
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-muted-foreground mb-3">
{$_('clock.stopwatch.laps')} ({focused.laps.length})
</h3>
<div class="max-h-48 overflow-y-auto space-y-1 scrollbar-thin">
{#each [...focused.laps].reverse() as lap (lap.number)}
{@const isBest = bestLap?.number === lap.number}
{@const isWorst = worstLap?.number === lap.number}
<div class="lap-item rounded-md" class:best={isBest} class:worst={isWorst}>
<span class="text-sm flex items-center gap-2">
<span class="text-muted-foreground">#{lap.number}</span>
{#if isBest}
<span class="text-xs px-1.5 py-0.5 rounded bg-success/20 text-success"
>{$_('clock.stopwatch.best')}</span
>
{:else if isWorst}
<span class="text-xs px-1.5 py-0.5 rounded bg-error/20 text-error"
>{$_('clock.stopwatch.worst')}</span
>
{/if}
</span>
<div class="text-right">
<span class="font-mono text-sm">{formatLapTime(lap.delta)}</span>
<span class="font-mono text-xs text-muted-foreground ml-2">
{formatTime(lap.time)}
</span>
</div>
</div>
{/each}
</div>
<div class="flex justify-between border-t border-border mt-3 pt-3">
<span class="text-sm font-medium">{$_('clock.stopwatch.total')}</span>
<span class="font-mono text-sm font-medium">
{formatTime(stopwatchesStore.getElapsed(focused))}
</span>
</div>
</div>
{/if}
</div>
{/if}
{#if otherStopwatches.length > 0}
<div>
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('clock.stopwatch.otherStopwatches')} ({otherStopwatches.length})
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{#each otherStopwatches as sw (sw.id)}
{@const swRunning = sw.status === 'running'}
<div
class="stopwatch-card-compact"
class:running={swRunning}
style="--sw-color: {sw.color}"
onclick={() => handleFocus(sw.id)}
onkeydown={(e) => e.key === 'Enter' && handleFocus(sw.id)}
role="button"
tabindex="0"
>
<div class="flex items-center justify-between mb-2">
<div
class="w-2 h-2 rounded-full"
class:animate-pulse={swRunning}
style="background-color: {sw.color}"
></div>
<button
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.delete(sw.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="text-xl font-light tabular-nums mb-1" class:text-primary={swRunning}>
{formatTime(stopwatchesStore.getElapsed(sw))}
</div>
<div class="text-xs text-muted-foreground truncate mb-2">{sw.label}</div>
<div class="flex gap-1">
{#if swRunning}
<button
class="btn btn-secondary btn-sm flex-1 text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.pause(sw.id);
}}
>
{$_('clock.stopwatch.stop')}
</button>
{:else}
<button
class="btn btn-primary btn-sm flex-1 text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.start(sw.id);
}}
>
{sw.elapsedTime > 0
? $_('clock.stopwatch.continue')
: $_('clock.stopwatch.start')}
</button>
{/if}
{#if sw.elapsedTime > 0 && !swRunning}
<button
class="btn btn-ghost btn-sm text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.reset(sw.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
.stopwatch-card-focused {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 2px solid var(--sw-color, hsl(var(--color-primary)));
box-shadow: 0 0 20px
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 20%, transparent);
}
.stopwatch-card-compact {
position: relative;
background-color: hsl(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
transition: all var(--transition-base);
cursor: pointer;
text-align: left;
}
.stopwatch-card-compact:hover {
border-color: var(--sw-color, hsl(var(--color-primary)));
background-color: hsl(var(--color-muted) / 0.3);
}
.stopwatch-card-compact.running {
border-color: var(--sw-color, hsl(var(--color-primary)));
box-shadow: 0 0 10px
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 15%, transparent);
}
.lap-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: hsl(var(--color-muted) / 0.3);
}
.lap-item.best {
background-color: hsl(var(--color-success) / 0.1);
}
.lap-item.worst {
background-color: hsl(var(--color-error) / 0.1);
}
</style>

View file

@ -0,0 +1,286 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { browser } from '$app/environment';
import { PageHeader, toast } from '@manacore/shared-ui';
import { countdownTimersStore } from '$lib/stores/countdown-timers.svelte';
import { QUICK_TIMER_PRESETS, formatCountdownDuration } from '@times/shared';
import type { CountdownTimer } from '@times/shared';
const allTimersQuery: { readonly value: CountdownTimer[] } = getContext('countdownTimers');
let formMinutes = $state(5);
let formSeconds = $state(0);
let formLabel = $state('');
interface LocalTimer {
id: string;
label: string;
durationSeconds: number;
remainingSeconds: number;
status: 'idle' | 'running' | 'paused' | 'finished';
createdAt: Date;
}
let localTimers = $state<LocalTimer[]>([]);
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
let allTimers = $derived([...allTimersQuery.value, ...localTimers]);
onDestroy(() => {
intervals.forEach((interval) => clearInterval(interval));
});
function startLocalCountdown(timerId: string, isLocal: boolean = false) {
if (intervals.has(timerId)) {
clearInterval(intervals.get(timerId));
}
const interval = setInterval(() => {
if (isLocal) {
const timer = localTimers.find((t) => t.id === timerId);
if (!timer || timer.status !== 'running') {
clearInterval(interval);
intervals.delete(timerId);
return;
}
const newRemaining = Math.max(0, timer.remainingSeconds - 1);
localTimers = localTimers.map((t) =>
t.id === timerId
? {
...t,
remainingSeconds: newRemaining,
status: newRemaining === 0 ? 'finished' : 'running',
}
: t
);
if (newRemaining === 0) {
clearInterval(interval);
intervals.delete(timerId);
toast.success($_('clock.timer.finished'));
if (browser && 'Notification' in window && Notification.permission === 'granted') {
new Notification('Timer', { body: 'Timer abgelaufen!' });
}
}
} else {
const timer = allTimersQuery.value.find((t) => t.id === timerId);
if (!timer || timer.status !== 'running') {
clearInterval(interval);
intervals.delete(timerId);
return;
}
const newRemaining = Math.max(0, (timer.remainingSeconds || 0) - 1);
countdownTimersStore.updateLocalTimer(timerId, newRemaining);
if (newRemaining === 0) {
clearInterval(interval);
intervals.delete(timerId);
toast.success($_('clock.timer.finished'));
}
}
}, 1000);
intervals.set(timerId, interval);
}
function createAndStartTimer() {
const durationSeconds = formMinutes * 60 + formSeconds;
if (durationSeconds <= 0) {
toast.error('Bitte eine g\u00FCltige Zeit eingeben');
return;
}
const newTimer: LocalTimer = {
id: crypto.randomUUID(),
label: formLabel || formatCountdownDuration(durationSeconds),
durationSeconds,
remainingSeconds: durationSeconds,
status: 'running',
createdAt: new Date(),
};
localTimers = [...localTimers, newTimer];
startLocalCountdown(newTimer.id, true);
toast.success('Timer gestartet');
formLabel = '';
}
function setPreset(seconds: number) {
formMinutes = Math.floor(seconds / 60);
formSeconds = seconds % 60;
}
async function handleStart(id: string, isLocal: boolean) {
if (isLocal) {
localTimers = localTimers.map((t) =>
t.id === id ? { ...t, status: 'running' as const } : t
);
startLocalCountdown(id, true);
} else {
const result = await countdownTimersStore.startTimer(id);
if (result.success) startLocalCountdown(id, false);
}
}
async function handlePause(id: string, isLocal: boolean) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
if (isLocal) {
localTimers = localTimers.map((t) => (t.id === id ? { ...t, status: 'paused' as const } : t));
} else {
await countdownTimersStore.pauseTimer(id);
}
}
async function handleReset(id: string, isLocal: boolean) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
if (isLocal) {
localTimers = localTimers.map((t) =>
t.id === id ? { ...t, remainingSeconds: t.durationSeconds, status: 'idle' as const } : t
);
} else {
await countdownTimersStore.resetTimer(id);
}
}
async function handleDelete(id: string, isLocal: boolean) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
if (isLocal) {
localTimers = localTimers.filter((t) => t.id !== id);
} else {
await countdownTimersStore.deleteTimer(id);
}
}
function getTimerDisplay(timer: any) {
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
return formatCountdownDuration(remaining);
}
function getProgress(timer: any) {
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
return (remaining / timer.durationSeconds) * 100;
}
function isLocalTimer(timer: any): boolean {
return localTimers.some((t) => t.id === timer.id);
}
</script>
<PageHeader title={$_('clock.timer.title')} size="md" centered />
<div class="space-y-4">
<div class="quick-create">
<div class="flex items-center gap-1">
<input
type="number"
class="time-input-inline w-12 text-center"
min="0"
max="99"
bind:value={formMinutes}
/>
<span class="text-muted-foreground">:</span>
<input
type="number"
class="time-input-inline w-12 text-center"
min="0"
max="59"
bind:value={formSeconds}
/>
</div>
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={formLabel} />
<button class="btn btn-primary btn-sm" onclick={createAndStartTimer}> Start </button>
</div>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
{#each QUICK_TIMER_PRESETS as preset}
<button class="alarm-tile text-center" onclick={() => setPreset(preset.seconds)}>
<span class="text-lg font-light tabular-nums">{preset.label}</span>
</button>
{/each}
</div>
{#if allTimers.length > 0}
<div>
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
Aktiv ({allTimers.length})
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each allTimers as timer (timer.id)}
{@const isLocal = isLocalTimer(timer)}
<div class="alarm-tile" class:active={timer.status === 'running'}>
<div class="flex items-start justify-between mb-1">
<span
class="text-xl font-light tabular-nums"
class:text-primary={timer.status === 'running'}
class:text-green-500={timer.status === 'finished'}
>
{getTimerDisplay(timer)}
</span>
<button
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
onclick={(e) => {
e.stopPropagation();
handleDelete(timer.id, isLocal);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="text-[10px] text-muted-foreground truncate mb-2">{timer.label}</div>
<div class="h-1 bg-muted rounded-full overflow-hidden mb-2">
<div
class="h-full rounded-full transition-all duration-1000"
class:bg-primary={timer.status !== 'finished'}
class:bg-green-500={timer.status === 'finished'}
style="width: {getProgress(timer)}%"
></div>
</div>
<div class="flex gap-1">
{#if timer.status === 'running'}
<button
class="btn btn-secondary btn-sm flex-1 text-xs"
onclick={() => handlePause(timer.id, isLocal)}
>
Pause
</button>
{:else}
<button
class="btn btn-primary btn-sm flex-1 text-xs"
onclick={() => handleStart(timer.id, isLocal)}
>
{timer.status === 'finished' ? 'Neu' : 'Start'}
</button>
{/if}
<button
class="btn btn-ghost btn-sm text-xs"
onclick={() => handleReset(timer.id, isLocal)}
>
Reset
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,321 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
import { POPULAR_TIMEZONES } from '@times/shared';
import type { WorldClock } from '@times/shared';
import WorldMap from '$lib/components/clock/WorldMap.svelte';
import { Monitor } from '@manacore/shared-icons';
const allWorldClocks: { readonly value: WorldClock[] } = getContext('worldClocks');
let showAddModal = $state(false);
let searchQuery = $state('');
let currentTime = $state(new Date());
let interval: ReturnType<typeof setInterval> | null = null;
let showMap = $state(true);
let selectedTimezones = $derived(allWorldClocks.value.map((wc) => wc.timezone));
function handleMapCityClick(timezone: string, cityName: string) {
const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === timezone);
if (alreadyAdded) {
toast.info(`${cityName} ist bereits hinzugefügt`);
} else {
addCity(timezone, cityName);
}
}
let filteredTimezones = $derived(
searchQuery
? POPULAR_TIMEZONES.filter(
(tz) =>
tz.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
tz.timezone.toLowerCase().includes(searchQuery.toLowerCase())
)
: POPULAR_TIMEZONES
);
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
onDestroy(() => {
if (interval) clearInterval(interval);
});
function openAddModal() {
searchQuery = '';
showAddModal = true;
}
function closeAddModal() {
showAddModal = false;
}
async function addCity(timezone: string, cityName: string) {
const result = await worldClocksStore.addWorldClock(
{ timezone, cityName },
allWorldClocks.value.length
);
if (result.success) {
toast.success(`${cityName} hinzugefügt`);
closeAddModal();
} else {
toast.error(result.error || 'Fehler beim Hinzufügen');
}
}
async function removeCity(id: string) {
const result = await worldClocksStore.removeWorldClock(id);
if (result.success) {
toast.success('Stadt entfernt');
}
}
function getTimeForTimezone(timezone: string) {
try {
const formatter = new Intl.DateTimeFormat('de-DE', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return formatter.format(currentTime);
} catch {
return '--:--';
}
}
function getDateForTimezone(timezone: string) {
try {
const formatter = new Intl.DateTimeFormat('de-DE', {
timeZone: timezone,
weekday: 'short',
day: 'numeric',
month: 'short',
});
return formatter.format(currentTime);
} catch {
return '';
}
}
function getOffsetText(timezone: string) {
try {
const localOffset = currentTime.getTimezoneOffset();
const targetDate = new Date(currentTime.toLocaleString('en-US', { timeZone: timezone }));
const utcDate = new Date(currentTime.toUTCString().slice(0, -4));
const targetOffset = (targetDate.getTime() - utcDate.getTime()) / (1000 * 60);
const diffMinutes = targetOffset + localOffset;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours === 0) return $_('clock.worldClock.same');
if (diffHours > 0) return `+${diffHours}h`;
return `${diffHours}h`;
} catch {
return '';
}
}
function isDaytime(timezone: string) {
try {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
hour12: false,
});
const hour = parseInt(formatter.format(currentTime));
return hour >= 6 && hour < 20;
} catch {
return true;
}
}
</script>
<PageHeader title={$_('clock.worldClock.title')} size="md" centered>
{#snippet actions()}
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm px-2"
onclick={() => (showMap = !showMap)}
title={showMap ? 'Karte ausblenden' : 'Karte anzeigen'}
>
<Monitor size={20} />
</button>
<button class="btn btn-primary btn-sm" onclick={openAddModal}>
+ {$_('clock.worldClock.add')}
</button>
</div>
{/snippet}
</PageHeader>
<div class="world-clock-page">
{#if showMap}
<div class="map-section">
<div class="map-container">
<WorldMap {selectedTimezones} onCityClick={handleMapCityClick} />
</div>
<p class="text-center text-xs text-muted-foreground py-2">
Klicke auf eine Stadt um sie hinzuzufügen
</p>
</div>
{/if}
{#if allWorldClocks.value.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('clock.worldClock.noClocks')}</p>
<button class="btn btn-primary mt-4" onclick={openAddModal}>
{$_('clock.worldClock.add')}
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each allWorldClocks.value as clock (clock.id)}
{@const isDay = isDaytime(clock.timezone)}
<div class="world-clock-card relative">
<button
class="absolute right-3 top-3 text-muted-foreground hover:text-error p-0.5"
onclick={() => removeCity(clock.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
<div class="mb-2 flex items-center gap-2">
<span class="text-xs text-muted-foreground">{isDay ? 'Tag' : 'Nacht'}</span>
<span class="city-name">{clock.cityName}</span>
</div>
<div class="time-display">
{getTimeForTimezone(clock.timezone)}
</div>
<div class="mt-2 flex items-center justify-between">
<span class="timezone-info">{getDateForTimezone(clock.timezone)}</span>
<span class="text-sm font-medium text-primary">{getOffsetText(clock.timezone)}</span>
</div>
</div>
{/each}
</div>
{/if}
{#if showAddModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">{$_('clock.worldClock.add')}</h2>
<button class="text-muted-foreground hover:text-foreground p-0.5" onclick={closeAddModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<input
type="text"
class="input mb-4"
placeholder={$_('clock.worldClock.search')}
bind:value={searchQuery}
/>
<div class="flex-1 overflow-y-auto -mx-4 px-4">
{#each filteredTimezones as tz}
{@const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === tz.timezone)}
<button
class="flex w-full items-center justify-between rounded-lg p-3 text-left hover:bg-muted transition-colors"
class:opacity-50={alreadyAdded}
disabled={alreadyAdded}
onclick={() => addCity(tz.timezone, tz.city)}
>
<div>
<div class="font-medium">{tz.city}</div>
<div class="text-sm text-muted-foreground">{tz.timezone}</div>
</div>
<div class="text-right">
<div class="font-mono">{getTimeForTimezone(tz.timezone)}</div>
<div class="text-xs text-muted-foreground">{tz.region}</div>
</div>
</button>
{/each}
{#if filteredTimezones.length === 0}
<p class="py-8 text-center text-muted-foreground">
Keine Ergebnisse für "{searchQuery}"
</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
.world-clock-page {
display: flex;
flex-direction: column;
min-height: calc(100vh - 180px);
}
.map-section {
display: flex;
flex-direction: column;
margin: 0 -1rem 1rem -1rem;
background: hsl(var(--color-card));
border-bottom: 1px solid hsl(var(--color-border));
}
.map-container {
width: 100%;
max-height: 50vh;
overflow: hidden;
}
@media (min-width: 768px) {
.map-section {
margin: 0 -1.5rem 1.5rem -1.5rem;
}
.map-container {
max-height: 60vh;
}
}
.world-clock-card {
background: hsl(var(--color-card));
border-radius: var(--radius-lg);
padding: 1rem;
border: 1px solid hsl(var(--color-border));
}
.city-name {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.time-display {
font-size: 2.5rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
line-height: 1;
}
.timezone-info {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>