diff --git a/apps/clock/apps/landing/package.json b/apps/clock/apps/landing/package.json new file mode 100644 index 000000000..9ec6f193d --- /dev/null +++ b/apps/clock/apps/landing/package.json @@ -0,0 +1,34 @@ +{ + "name": "@clock/landing", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev --port 4323", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "type-check": "astro check || echo 'Astro check skipped'", + "format": "prettier --write .", + "clean": "rm -rf dist .astro node_modules" + }, + "dependencies": { + "@astrojs/check": "^0.9.0", + "@manacore/shared-landing-ui": "workspace:*", + "astro": "^5.16.0", + "typescript": "^5.9.2" + }, + "devDependencies": { + "@astrojs/tailwind": "^6.0.2", + "@tailwindcss/typography": "^0.5.18", + "@types/node": "^20.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-astro": "^1.0.0", + "prettier": "^3.6.2", + "prettier-plugin-astro": "^0.14.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.4.0" + } +} diff --git a/apps/clock/apps/web/src/lib/api/alarms.ts b/apps/clock/apps/web/src/lib/api/alarms.ts new file mode 100644 index 000000000..a08041c97 --- /dev/null +++ b/apps/clock/apps/web/src/lib/api/alarms.ts @@ -0,0 +1,15 @@ +/** + * Alarms API - Direct API calls for alarms + */ + +import { api } from './client'; +import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared'; + +export const alarmsApi = { + getAll: () => api.get('/alarms'), + getById: (id: string) => api.get(`/alarms/${id}`), + create: (input: CreateAlarmInput) => api.post('/alarms', input), + update: (id: string, input: UpdateAlarmInput) => api.patch(`/alarms/${id}`, input), + delete: (id: string) => api.delete(`/alarms/${id}`), + toggle: (id: string) => api.post(`/alarms/${id}/toggle`), +}; diff --git a/apps/clock/apps/web/src/lib/api/timers.ts b/apps/clock/apps/web/src/lib/api/timers.ts new file mode 100644 index 000000000..d375ce198 --- /dev/null +++ b/apps/clock/apps/web/src/lib/api/timers.ts @@ -0,0 +1,17 @@ +/** + * Timers API - Direct API calls for timers + */ + +import { api } from './client'; +import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared'; + +export const timersApi = { + getAll: () => api.get('/timers'), + getById: (id: string) => api.get(`/timers/${id}`), + create: (input: CreateTimerInput) => api.post('/timers', input), + update: (id: string, input: UpdateTimerInput) => api.patch(`/timers/${id}`, input), + delete: (id: string) => api.delete(`/timers/${id}`), + start: (id: string) => api.post(`/timers/${id}/start`), + pause: (id: string) => api.post(`/timers/${id}/pause`), + reset: (id: string) => api.post(`/timers/${id}/reset`), +}; diff --git a/apps/clock/apps/web/src/lib/components/WorldMap.svelte b/apps/clock/apps/web/src/lib/components/WorldMap.svelte new file mode 100644 index 000000000..dfdd221dc --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/WorldMap.svelte @@ -0,0 +1,111 @@ + + +
+ {#if mapLoaded} +
+ + + + + + {#each cities as city} + {@const x = ((city.lng + 180) / 360) * 800} + {@const y = ((90 - city.lat) / 180) * 400} + {@const isSelected = selectedTimezones.includes(city.timezone)} + handleCityClick(city.timezone, city.city)}> + + {#if isSelected} + + {city.city} + + {/if} + + {/each} + +
+ {:else} +
+ Karte wird geladen... +
+ {/if} +
+ + diff --git a/apps/clock/apps/web/src/lib/components/skeletons/AlarmsSkeleton.svelte b/apps/clock/apps/web/src/lib/components/skeletons/AlarmsSkeleton.svelte new file mode 100644 index 000000000..0c3ccd6c4 --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/skeletons/AlarmsSkeleton.svelte @@ -0,0 +1,71 @@ + + +
+ +
+ {#each Array(6) as _, i} +
+ +
+ {/each} +
+ + +
+ {#each Array(3) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/skeletons/TimersSkeleton.svelte b/apps/clock/apps/web/src/lib/components/skeletons/TimersSkeleton.svelte new file mode 100644 index 000000000..abf9d75ea --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/skeletons/TimersSkeleton.svelte @@ -0,0 +1,85 @@ + + +
+ +
+ {#each Array(4) as _, i} + + {/each} +
+ + +
+ + +
+ + +
+ {#each Array(2) as _, i} +
+
+ + +
+
+ + +
+
+ {/each} +
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/skeletons/WorldClockSkeleton.svelte b/apps/clock/apps/web/src/lib/components/skeletons/WorldClockSkeleton.svelte new file mode 100644 index 000000000..8c87cc6ad --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/skeletons/WorldClockSkeleton.svelte @@ -0,0 +1,58 @@ + + +
+ +
+ +
+ + +
+ {#each Array(4) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/skeletons/index.ts b/apps/clock/apps/web/src/lib/components/skeletons/index.ts index c6b8952ad..1fdb0e411 100644 --- a/apps/clock/apps/web/src/lib/components/skeletons/index.ts +++ b/apps/clock/apps/web/src/lib/components/skeletons/index.ts @@ -6,3 +6,8 @@ // App Loading Skeleton export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte'; + +// Feature Skeletons +export { default as AlarmsSkeleton } from './AlarmsSkeleton.svelte'; +export { default as TimersSkeleton } from './TimersSkeleton.svelte'; +export { default as WorldClockSkeleton } from './WorldClockSkeleton.svelte'; diff --git a/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts new file mode 100644 index 000000000..79e79bc30 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts @@ -0,0 +1,111 @@ +/** + * Alarms Store - Manages alarm state using Svelte 5 runes + */ + +import { api } from '$lib/api/client'; +import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared'; + +// State +let alarms = $state([]); +let loading = $state(false); +let error = $state(null); + +export const alarmsStore = { + // Getters + get alarms() { + return alarms; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get enabledAlarms() { + return alarms.filter((a) => a.enabled); + }, + + /** + * Fetch all alarms from the backend + */ + async fetchAlarms() { + loading = true; + error = null; + + const response = await api.get('/alarms'); + + if (response.error) { + error = response.error; + loading = false; + return { success: false, error: response.error }; + } + + alarms = response.data || []; + loading = false; + return { success: true }; + }, + + /** + * Create a new alarm + */ + async createAlarm(input: CreateAlarmInput) { + const response = await api.post('/alarms', input); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + alarms = [...alarms, response.data]; + } + return { success: true, data: response.data }; + }, + + /** + * Update an alarm + */ + async updateAlarm(id: string, input: UpdateAlarmInput) { + const response = await api.patch(`/alarms/${id}`, input); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + alarms = alarms.map((a) => (a.id === id ? response.data! : a)); + } + return { success: true, data: response.data }; + }, + + /** + * Toggle alarm enabled state + */ + async toggleAlarm(id: string) { + const alarm = alarms.find((a) => a.id === id); + if (!alarm) return { success: false, error: 'Alarm not found' }; + + return this.updateAlarm(id, { enabled: !alarm.enabled }); + }, + + /** + * Delete an alarm + */ + async deleteAlarm(id: string) { + const response = await api.delete(`/alarms/${id}`); + + if (response.error) { + return { success: false, error: response.error }; + } + + alarms = alarms.filter((a) => a.id !== id); + return { success: true }; + }, + + /** + * Clear all alarms (local state only) + */ + clear() { + alarms = []; + error = null; + }, +}; diff --git a/apps/clock/apps/web/src/lib/stores/navigation.ts b/apps/clock/apps/web/src/lib/stores/navigation.ts new file mode 100644 index 000000000..501357be9 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/navigation.ts @@ -0,0 +1,35 @@ +/** + * Navigation Store - Manages navigation state + */ + +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +const SIDEBAR_MODE_KEY = 'clock_sidebar_mode'; +const NAV_COLLAPSED_KEY = 'clock_nav_collapsed'; + +// Check localStorage for initial values +function getInitialSidebarMode(): boolean { + if (!browser) return false; + return localStorage.getItem(SIDEBAR_MODE_KEY) === 'true'; +} + +function getInitialCollapsed(): boolean { + if (!browser) return false; + return localStorage.getItem(NAV_COLLAPSED_KEY) === 'true'; +} + +// Create stores +export const isSidebarMode = writable(getInitialSidebarMode()); +export const isNavCollapsed = writable(getInitialCollapsed()); + +// Subscribe to persist changes +if (browser) { + isSidebarMode.subscribe((value) => { + localStorage.setItem(SIDEBAR_MODE_KEY, String(value)); + }); + + isNavCollapsed.subscribe((value) => { + localStorage.setItem(NAV_COLLAPSED_KEY, String(value)); + }); +} diff --git a/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts b/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts new file mode 100644 index 000000000..5573dfbc8 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts @@ -0,0 +1,231 @@ +/** + * Stopwatch Store - Manages stopwatch state using Svelte 5 runes + * Stopwatches are local-only (no backend sync) + */ + +export interface Lap { + number: number; + time: number; // milliseconds since start + delta: number; // milliseconds since last lap +} + +export interface Stopwatch { + id: string; + label: string; + startTime: number | null; // timestamp when started + elapsedTime: number; // accumulated milliseconds when paused + status: 'idle' | 'running' | 'paused'; + laps: Lap[]; + color: string; +} + +export const STOPWATCH_COLORS = [ + '#3B82F6', // blue + '#10B981', // green + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // violet + '#EC4899', // pink + '#14B8A6', // teal + '#F97316', // orange +]; + +// State +let stopwatches = $state([]); +let focusedId = $state(null); +let colorIndex = 0; + +// Tick interval for updating display +let tickInterval: ReturnType | null = null; + +function getNextColor(): string { + const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length]; + colorIndex++; + return color; +} + +function startTicking() { + if (tickInterval) return; + tickInterval = setInterval(() => { + // Force reactivity update by reassigning + stopwatches = [...stopwatches]; + }, 100); +} + +function stopTickingIfNoRunning() { + const hasRunning = stopwatches.some((sw) => sw.status === 'running'); + if (!hasRunning && tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } +} + +export const stopwatchesStore = { + // Getters + get stopwatches() { + return stopwatches; + }, + get focusedId() { + return focusedId; + }, + get focusedStopwatch() { + return stopwatches.find((sw) => sw.id === focusedId) || null; + }, + + /** + * Create a new stopwatch + */ + 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 a stopwatch + */ + start(id: string) { + stopwatches = stopwatches.map((sw) => { + if (sw.id !== id) return sw; + return { + ...sw, + startTime: Date.now(), + status: 'running' as const, + }; + }); + startTicking(); + }, + + /** + * Pause a stopwatch + */ + 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 a stopwatch + */ + 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(); + }, + + /** + * Add a lap to a stopwatch + */ + 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 a stopwatch + */ + delete(id: string) { + stopwatches = stopwatches.filter((sw) => sw.id !== id); + if (focusedId === id) { + focusedId = stopwatches[0]?.id || null; + } + stopTickingIfNoRunning(); + }, + + /** + * Set focused stopwatch + */ + setFocused(id: string | null) { + focusedId = id; + }, + + /** + * Update stopwatch label + */ + updateLabel(id: string, label: string) { + stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw)); + }, + + /** + * Get elapsed time for a stopwatch + */ + getElapsed(sw: Stopwatch): number { + if (sw.status === 'running' && sw.startTime) { + return sw.elapsedTime + (Date.now() - sw.startTime); + } + return sw.elapsedTime; + }, +}; + +/** + * Format time in milliseconds to display string + */ +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')}`; +} + +/** + * Format lap time (delta) for display + */ +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')}`; +} diff --git a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts new file mode 100644 index 000000000..741e0e988 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts @@ -0,0 +1,156 @@ +/** + * Timers Store - Manages timer state using Svelte 5 runes + */ + +import { api } from '$lib/api/client'; +import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared'; + +// State +let timers = $state([]); +let loading = $state(false); +let error = $state(null); + +export const timersStore = { + // Getters + get timers() { + return timers; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get activeTimers() { + return timers.filter((t) => t.status === 'running' || t.status === 'paused'); + }, + + /** + * Fetch all timers from the backend + */ + async fetchTimers() { + loading = true; + error = null; + + const response = await api.get('/timers'); + + if (response.error) { + error = response.error; + loading = false; + return { success: false, error: response.error }; + } + + timers = response.data || []; + loading = false; + return { success: true }; + }, + + /** + * Create a new timer + */ + async createTimer(input: CreateTimerInput) { + const response = await api.post('/timers', input); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + timers = [...timers, response.data]; + } + return { success: true, data: response.data }; + }, + + /** + * Update a timer + */ + async updateTimer(id: string, input: UpdateTimerInput) { + const response = await api.patch(`/timers/${id}`, input); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + timers = timers.map((t) => (t.id === id ? response.data! : t)); + } + return { success: true, data: response.data }; + }, + + /** + * Start a timer + */ + async startTimer(id: string) { + const response = await api.post(`/timers/${id}/start`); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + timers = timers.map((t) => (t.id === id ? response.data! : t)); + } + return { success: true, data: response.data }; + }, + + /** + * Pause a timer + */ + async pauseTimer(id: string) { + const response = await api.post(`/timers/${id}/pause`); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + timers = timers.map((t) => (t.id === id ? response.data! : t)); + } + return { success: true, data: response.data }; + }, + + /** + * Reset a timer + */ + async resetTimer(id: string) { + const response = await api.post(`/timers/${id}/reset`); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + timers = timers.map((t) => (t.id === id ? response.data! : t)); + } + return { success: true, data: response.data }; + }, + + /** + * Delete a timer + */ + async deleteTimer(id: string) { + const response = await api.delete(`/timers/${id}`); + + if (response.error) { + return { success: false, error: response.error }; + } + + timers = timers.filter((t) => t.id !== id); + return { success: true }; + }, + + /** + * Update local timer state (for countdown display) + */ + updateLocalState(id: string, updates: Partial) { + timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t)); + }, + + /** + * Clear all timers (local state only) + */ + clear() { + timers = []; + error = null; + }, +}; diff --git a/apps/clock/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/clock/apps/web/src/lib/stores/user-settings.svelte.ts new file mode 100644 index 000000000..7cf6b65e0 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/user-settings.svelte.ts @@ -0,0 +1,105 @@ +/** + * User Settings Store - Manages user preferences using Svelte 5 runes + */ + +import { browser } from '$app/environment'; + +export interface UserSettings { + timeFormat: '12h' | '24h'; + firstDayOfWeek: 0 | 1; // 0 = Sunday, 1 = Monday + showSeconds: boolean; + defaultAlarmSound: string; + vibrationEnabled: boolean; +} + +const DEFAULT_SETTINGS: UserSettings = { + timeFormat: '24h', + firstDayOfWeek: 1, // Monday (European default) + showSeconds: false, + defaultAlarmSound: 'default', + vibrationEnabled: true, +}; + +const STORAGE_KEY = 'clock_user_settings'; + +// Load settings from localStorage +function loadSettings(): UserSettings { + if (!browser) return DEFAULT_SETTINGS; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }; + } + } catch (e) { + console.error('Failed to load user settings:', e); + } + return DEFAULT_SETTINGS; +} + +// Save settings to localStorage +function saveSettings(settings: UserSettings) { + if (!browser) return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch (e) { + console.error('Failed to save user settings:', e); + } +} + +// State +let settings = $state(loadSettings()); + +export const userSettings = { + // Getters + get timeFormat() { + return settings.timeFormat; + }, + get firstDayOfWeek() { + return settings.firstDayOfWeek; + }, + get showSeconds() { + return settings.showSeconds; + }, + get defaultAlarmSound() { + return settings.defaultAlarmSound; + }, + get vibrationEnabled() { + return settings.vibrationEnabled; + }, + get all() { + return settings; + }, + + /** + * Update a single setting + */ + update(key: K, value: UserSettings[K]) { + settings = { ...settings, [key]: value }; + saveSettings(settings); + }, + + /** + * Update multiple settings + */ + updateMany(updates: Partial) { + settings = { ...settings, ...updates }; + saveSettings(settings); + }, + + /** + * Reset to defaults + */ + reset() { + settings = DEFAULT_SETTINGS; + saveSettings(settings); + }, + + /** + * Initialize (reload from storage) + */ + initialize() { + settings = loadSettings(); + }, +}; diff --git a/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts b/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts new file mode 100644 index 000000000..05942bcfc --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts @@ -0,0 +1,100 @@ +/** + * World Clocks Store - Manages world clock state using Svelte 5 runes + */ + +import { api } from '$lib/api/client'; +import type { WorldClock, CreateWorldClockInput } from '@clock/shared'; + +// State +let worldClocks = $state([]); +let loading = $state(false); +let error = $state(null); + +export const worldClocksStore = { + // Getters + get worldClocks() { + return worldClocks; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all world clocks from the backend + */ + async fetchWorldClocks() { + loading = true; + error = null; + + const response = await api.get('/world-clocks'); + + if (response.error) { + error = response.error; + loading = false; + return { success: false, error: response.error }; + } + + worldClocks = response.data || []; + loading = false; + return { success: true }; + }, + + /** + * Add a new world clock + */ + async addWorldClock(input: CreateWorldClockInput) { + const response = await api.post('/world-clocks', input); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + worldClocks = [...worldClocks, response.data]; + } + return { success: true, data: response.data }; + }, + + /** + * Remove a world clock + */ + async removeWorldClock(id: string) { + const response = await api.delete(`/world-clocks/${id}`); + + if (response.error) { + return { success: false, error: response.error }; + } + + worldClocks = worldClocks.filter((wc) => wc.id !== id); + return { success: true }; + }, + + /** + * Reorder world clocks + */ + async reorder(ids: string[]) { + const response = await api.put('/world-clocks/reorder', { ids }); + + if (response.error) { + return { success: false, error: response.error }; + } + + // Update local order + worldClocks = ids + .map((id) => worldClocks.find((wc) => wc.id === id)) + .filter((wc): wc is WorldClock => wc !== undefined); + + return { success: true }; + }, + + /** + * Clear all world clocks (local state only) + */ + clear() { + worldClocks = []; + error = null; + }, +};