mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
35f4bd48de
commit
e870270734
131 changed files with 1524 additions and 5969 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
108
apps/times/apps/web/src/lib/components/clock/WorldMap.svelte
Normal file
108
apps/times/apps/web/src/lib/components/clock/WorldMap.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
85
apps/times/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
85
apps/times/apps/web/src/lib/stores/alarms.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
163
apps/times/apps/web/src/lib/stores/countdown-timers.svelte.ts
Normal file
163
apps/times/apps/web/src/lib/stores/countdown-timers.svelte.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
113
apps/times/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal file
113
apps/times/apps/web/src/lib/stores/session-alarms.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
171
apps/times/apps/web/src/lib/stores/session-timers.svelte.ts
Normal file
171
apps/times/apps/web/src/lib/stores/session-timers.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
194
apps/times/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
194
apps/times/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal 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')}`;
|
||||
}
|
||||
65
apps/times/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal file
65
apps/times/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
100
apps/times/apps/web/src/routes/(app)/clock/+page.svelte
Normal file
100
apps/times/apps/web/src/routes/(app)/clock/+page.svelte
Normal 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>
|
||||
292
apps/times/apps/web/src/routes/(app)/clock/alarms/+page.svelte
Normal file
292
apps/times/apps/web/src/routes/(app)/clock/alarms/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
286
apps/times/apps/web/src/routes/(app)/clock/timers/+page.svelte
Normal file
286
apps/times/apps/web/src/routes/(app)/clock/timers/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue