feat(clock): add complete Clock app with backend, web, and landing

Features:
- World clock with timezone support and drag & drop sorting
- Alarms with repeat days, snooze, and custom sounds
- Multiple timers with start/pause/reset controls
- Stopwatch with lap times (local only)
- Pomodoro timer with customizable intervals
- Analog and digital clock widgets
- i18n support (DE, EN, FR, ES, IT)

Stack:
- Backend: NestJS 10, Drizzle ORM, PostgreSQL (port 3017)
- Web: SvelteKit 2.x, Svelte 5 runes, Tailwind CSS 4 (port 5186)
- Landing: Astro 5.x with animated clock hero (port 4323)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-03 15:37:51 +01:00
parent 110c6779a8
commit 2ef457ea23
104 changed files with 7517 additions and 2 deletions

View file

@ -46,7 +46,7 @@ JWT_ACCESS_TOKEN_EXPIRY=15m
JWT_REFRESH_TOKEN_EXPIRY=7d
JWT_ISSUER=manacore
JWT_AUDIENCE=manacore
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:8081
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:5186,http://localhost:8081
CREDITS_SIGNUP_BONUS=150
CREDITS_DAILY_FREE=5
RATE_LIMIT_TTL=60
@ -185,6 +185,13 @@ CONTACTS_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/contacts
CONTACTS_S3_BUCKET=contacts-photos
CONTACTS_S3_PUBLIC_URL=http://localhost:9000/contacts-photos
# Google OAuth for contacts import
# Get credentials from https://console.cloud.google.com/apis/credentials
# Required scopes: https://www.googleapis.com/auth/contacts.readonly
CONTACTS_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
CONTACTS_GOOGLE_CLIENT_SECRET=your-google-client-secret
CONTACTS_GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
# ============================================
# CALENDAR PROJECT
# ============================================
@ -202,6 +209,20 @@ STORAGE_S3_PUBLIC_URL=http://localhost:9000/storage-storage
STORAGE_MAX_FILE_SIZE=104857600
STORAGE_MAX_FILES_PER_UPLOAD=10
# ============================================
# CLOCK PROJECT
# ============================================
CLOCK_BACKEND_PORT=3017
CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
# ============================================
# TODO PROJECT
# ============================================
TODO_BACKEND_PORT=3018
TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
# ============================================
# MANA-GAMES PROJECT
# ============================================

385
apps/clock/CLAUDE.md Normal file
View file

@ -0,0 +1,385 @@
# Clock Project Guide
## Übersicht
**Clock** ist eine vollständige Uhren-App mit Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro. Die App synchronisiert Wecker und Timer zwischen Geräten über ein Backend.
| App | Port | URL |
|-----|------|-----|
| Backend | 3017 | http://localhost:3017 |
| Web App | 5186 | http://localhost:5186 |
| Landing Page | 4323 | http://localhost:4323 |
## Project Structure
```
apps/clock/
├── apps/
│ ├── backend/ # NestJS API server (@clock/backend)
│ │ └── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── db/
│ │ │ ├── database.module.ts
│ │ │ ├── connection.ts
│ │ │ ├── schema/
│ │ │ │ ├── alarms.schema.ts
│ │ │ │ ├── timers.schema.ts
│ │ │ │ ├── world-clocks.schema.ts
│ │ │ │ └── presets.schema.ts
│ │ │ └── seed.ts
│ │ ├── alarm/
│ │ ├── timer/
│ │ ├── world-clock/
│ │ ├── preset/
│ │ └── health/
│ │
│ ├── web/ # SvelteKit web app (@clock/web)
│ │ └── src/
│ │ ├── lib/
│ │ │ ├── api/
│ │ │ ├── stores/
│ │ │ ├── components/
│ │ │ └── i18n/
│ │ └── routes/
│ │ ├── +layout.svelte
│ │ ├── +page.svelte # Dashboard
│ │ ├── alarms/
│ │ ├── timers/
│ │ ├── stopwatch/
│ │ ├── pomodoro/
│ │ ├── world-clock/
│ │ ├── settings/
│ │ ├── feedback/
│ │ └── (auth)/
│ │
│ └── landing/ # Astro landing page (@clock/landing)
├── packages/
│ └── shared/ # Shared types & constants (@clock/shared)
├── package.json
└── CLAUDE.md
```
## Commands
### Root Level (from monorepo root)
```bash
# Alle Apps starten
pnpm clock:dev # Run all clock apps
# Einzelne Apps starten
pnpm dev:clock:backend # Start backend server (port 3017)
pnpm dev:clock:web # Start web app (port 5186)
pnpm dev:clock:landing # Start landing page (port 4323)
pnpm dev:clock:app # Start web + backend together
# Datenbank
pnpm clock:db:push # Push schema to database
pnpm clock:db:studio # Open Drizzle Studio
pnpm clock:db:seed # Seed initial data
# Deploy
pnpm deploy:landing:clock # Deploy landing to Cloudflare Pages
```
### Backend (apps/clock/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed initial data
```
### Web App (apps/clock/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
### Landing Page (apps/clock/apps/landing)
```bash
pnpm dev # Start dev server (port 4323)
pnpm build # Build for production
pnpm preview # Preview build
```
## Technology Stack
| Layer | Technology |
|-------|------------|
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
| **Web** | SvelteKit 2.x, Svelte 5 (runes), Tailwind CSS 4 |
| **Landing** | Astro 5.x, Tailwind CSS |
| **Auth** | Mana Core Auth (JWT) |
| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) |
## Features
### 1. Weltzeituhr
- Zeitzonen-Übersicht mit mehreren Städten
- Zeitdifferenz zum lokalen Standort
- Tag/Nacht-Indikator
- Drag & Drop Sortierung
### 2. Wecker
- Erstelle/Bearbeite/Lösche Wecker
- Wiederholende Wecker (Wochentage wählen)
- Snooze-Funktion (konfigurierbar)
- Verschiedene Alarm-Sounds
- Cross-Device Sync
### 3. Timer
- Multiple Timer gleichzeitig
- Timer-Presets (Quick Select)
- Cross-Device Sync für aktive Timer
- Start/Pause/Reset Kontrollen
### 4. Stoppuhr
- Präzise Zeitmessung
- Rundenzeiten mit Best/Worst Markierung
- Lokal-only (kein Sync nötig)
### 5. Pomodoro
- Arbeit/Pause Zyklen
- Anpassbare Intervalle
- Preset-Auswahl (Klassisch, Kurzer Fokus, Tiefe Arbeit)
- Push-Benachrichtigungen
## API Endpoints
### Health
```
GET /api/v1/health # Health check
```
### Alarms
```
GET /api/v1/alarms # List all alarms
POST /api/v1/alarms # Create alarm
GET /api/v1/alarms/:id # Get alarm
PUT /api/v1/alarms/:id # Update alarm
DELETE /api/v1/alarms/:id # Delete alarm
PATCH /api/v1/alarms/:id/toggle # Toggle enabled
```
### Timers
```
GET /api/v1/timers # List all timers
POST /api/v1/timers # Create timer
GET /api/v1/timers/:id # Get timer
PUT /api/v1/timers/:id # Update timer
DELETE /api/v1/timers/:id # Delete timer
POST /api/v1/timers/:id/start # Start timer
POST /api/v1/timers/:id/pause # Pause timer
POST /api/v1/timers/:id/reset # Reset timer
```
### World Clocks
```
GET /api/v1/world-clocks # List world clocks
POST /api/v1/world-clocks # Add city
DELETE /api/v1/world-clocks/:id # Remove city
PUT /api/v1/world-clocks/reorder # Reorder cities
GET /api/v1/timezones/search # Search timezones
```
### Presets
```
GET /api/v1/presets # List presets
POST /api/v1/presets # Create preset
PUT /api/v1/presets/:id # Update preset
DELETE /api/v1/presets/:id # Delete preset
```
## Database Schema
### alarms
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `label` | VARCHAR(255) | Alarm name |
| `time` | TIME | Alarm time (HH:MM:SS) |
| `enabled` | BOOLEAN | Active flag |
| `repeat_days` | INT[] | [0-6] für Wochentage |
| `snooze_minutes` | INTEGER | Snooze duration |
| `sound` | VARCHAR(100) | Sound identifier |
| `vibrate` | BOOLEAN | Vibration enabled |
### timers
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `label` | VARCHAR(255) | Timer name |
| `duration_seconds` | INTEGER | Total duration |
| `remaining_seconds` | INTEGER | Time left |
| `status` | VARCHAR(20) | idle/running/paused/finished |
| `sound` | VARCHAR(100) | Sound identifier |
### world_clocks
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `timezone` | VARCHAR(100) | IANA timezone |
| `city_name` | VARCHAR(255) | Display name |
| `sort_order` | INTEGER | Display order |
### presets
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner |
| `type` | VARCHAR(20) | timer/pomodoro |
| `name` | VARCHAR(255) | Preset name |
| `duration_seconds` | INTEGER | Duration |
| `settings` | JSONB | Type-specific settings |
## Environment Variables
### Backend (.env)
```env
NODE_ENV=development
PORT=3017
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:5186,http://localhost:8081
DEV_BYPASS_AUTH=true
DEV_USER_ID=your-test-user-id
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3017
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Web App Stores (Svelte 5 Runes)
```typescript
// auth.svelte.ts - Authentication
authStore.isAuthenticated
authStore.user
authStore.signIn(email, password)
authStore.signOut()
authStore.getAccessToken()
// alarms.svelte.ts - Alarms
alarmsStore.alarms
alarmsStore.nextAlarm
alarmsStore.fetchAlarms()
alarmsStore.createAlarm(input)
alarmsStore.toggleAlarm(id)
// timers.svelte.ts - Timers
timersStore.timers
timersStore.activeTimers
timersStore.startTimer(id)
timersStore.pauseTimer(id)
timersStore.resetTimer(id)
// stopwatch.svelte.ts - Stopwatch (local only)
stopwatchStore.isRunning
stopwatchStore.elapsedTime
stopwatchStore.laps
stopwatchStore.start()
stopwatchStore.lap()
stopwatchStore.reset()
// pomodoro.svelte.ts - Pomodoro (local only)
pomodoroStore.phase
pomodoroStore.remainingTime
pomodoroStore.completedSessions
pomodoroStore.start()
pomodoroStore.skip()
pomodoroStore.loadPreset(preset)
// world-clocks.svelte.ts - World Clocks
worldClocksStore.worldClocks
worldClocksStore.addWorldClock(input)
worldClocksStore.removeWorldClock(id)
worldClocksStore.reorderWorldClocks(ids)
```
## Quick Start
### 1. Datenbank erstellen
```bash
# PostgreSQL Container muss laufen
docker compose -f docker-compose.dev.yml up -d postgres
# Datenbank erstellen
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE clock;"
# Schema pushen
pnpm clock:db:push
```
### 2. Apps starten
```bash
# Backend + Web zusammen
pnpm dev:clock:app
# Oder einzeln:
pnpm dev:clock:backend # Terminal 1
pnpm dev:clock:web # Terminal 2
pnpm dev:clock:landing # Terminal 3 (optional)
```
### 3. URLs öffnen
- Web App: http://localhost:5186
- Landing: http://localhost:4323
- API Health: http://localhost:3017/api/v1/health
## Testing API (mit curl)
```bash
# Health Check
curl http://localhost:3017/api/v1/health
# Login (get token)
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
# Wecker abrufen
curl http://localhost:3017/api/v1/alarms \
-H "Authorization: Bearer $TOKEN"
# Neuen Wecker erstellen
curl -X POST http://localhost:3017/api/v1/alarms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"time": "07:00:00", "label": "Aufwachen"}'
# Timer erstellen und starten
TIMER_ID=$(curl -s -X POST http://localhost:3017/api/v1/timers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"durationSeconds": 300, "label": "5 Minuten"}' | jq -r '.id')
curl -X POST http://localhost:3017/api/v1/timers/$TIMER_ID/start \
-H "Authorization: Bearer $TOKEN"
```
## Important Notes
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
3. **Port**: Backend läuft auf Port 3017, Web auf 5186, Landing auf 4323
4. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT)
5. **Theme**: Amber/Orange (#f59e0b) als Primärfarbe
6. **Local Features**: Stoppuhr und Pomodoro laufen lokal ohne Backend-Sync

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/clock',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View file

@ -0,0 +1,55 @@
{
"name": "@clock/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@clock/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,45 @@
import { Controller, Get, Post, Put, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AlarmService } from './alarm.service';
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
@Controller('alarms')
@UseGuards(JwtAuthGuard)
export class AlarmController {
constructor(private readonly alarmService: AlarmService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.alarmService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.alarmService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAlarmDto) {
return this.alarmService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateAlarmDto
) {
return this.alarmService.update(id, user.userId, dto);
}
@Patch(':id/toggle')
async toggle(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.alarmService.toggle(id, user.userId);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.alarmService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AlarmController } from './alarm.controller';
import { AlarmService } from './alarm.service';
@Module({
controllers: [AlarmController],
providers: [AlarmService],
exports: [AlarmService],
})
export class AlarmModule {}

View file

@ -0,0 +1,82 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { alarms, type Alarm } from '../db/schema';
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
@Injectable()
export class AlarmService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Alarm[]> {
return this.db.select().from(alarms).where(eq(alarms.userId, userId));
}
async findById(id: string, userId: string): Promise<Alarm | null> {
const result = await this.db
.select()
.from(alarms)
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Alarm> {
const alarm = await this.findById(id, userId);
if (!alarm) {
throw new NotFoundException(`Alarm with id ${id} not found`);
}
return alarm;
}
async create(userId: string, dto: CreateAlarmDto): Promise<Alarm> {
const result = await this.db
.insert(alarms)
.values({
userId,
label: dto.label,
time: dto.time,
enabled: dto.enabled ?? true,
repeatDays: dto.repeatDays,
snoozeMinutes: dto.snoozeMinutes ?? 5,
sound: dto.sound ?? 'default',
vibrate: dto.vibrate ?? true,
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdateAlarmDto): Promise<Alarm> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(alarms)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.returning();
return result[0];
}
async toggle(id: string, userId: string): Promise<Alarm> {
const alarm = await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(alarms)
.set({
enabled: !alarm.enabled,
updatedAt: new Date(),
})
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(alarms).where(and(eq(alarms.id, id), eq(alarms.userId, userId)));
}
}

View file

@ -0,0 +1,85 @@
import {
IsString,
IsOptional,
IsBoolean,
IsArray,
IsNumber,
Min,
Max,
Matches,
} from 'class-validator';
export class CreateAlarmDto {
@IsOptional()
@IsString()
label?: string;
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
message: 'time must be in HH:MM:SS format',
})
time!: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
@Min(0, { each: true })
@Max(6, { each: true })
repeatDays?: number[];
@IsOptional()
@IsNumber()
@Min(1)
@Max(60)
snoozeMinutes?: number;
@IsOptional()
@IsString()
sound?: string;
@IsOptional()
@IsBoolean()
vibrate?: boolean;
}
export class UpdateAlarmDto {
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
message: 'time must be in HH:MM:SS format',
})
time?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
@Min(0, { each: true })
@Max(6, { each: true })
repeatDays?: number[];
@IsOptional()
@IsNumber()
@Min(1)
@Max(60)
snoozeMinutes?: number;
@IsOptional()
@IsString()
sound?: string;
@IsOptional()
@IsBoolean()
vibrate?: boolean;
}

View file

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { AlarmModule } from './alarm/alarm.module';
import { TimerModule } from './timer/timer.module';
import { WorldClockModule } from './world-clock/world-clock.module';
import { PresetModule } from './preset/preset.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
AlarmModule,
TimerModule,
WorldClockModule,
PresetModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,18 @@
import { pgTable, uuid, varchar, time, boolean, integer, timestamp } from 'drizzle-orm/pg-core';
export const alarms = pgTable('alarms', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
label: varchar('label', { length: 255 }),
time: time('time').notNull(),
enabled: boolean('enabled').default(true).notNull(),
repeatDays: integer('repeat_days').array(), // [0-6] for weekdays (0=Sun)
snoozeMinutes: integer('snooze_minutes').default(5),
sound: varchar('sound', { length: 100 }).default('default'),
vibrate: boolean('vibrate').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Alarm = typeof alarms.$inferSelect;
export type NewAlarm = typeof alarms.$inferInsert;

View file

@ -0,0 +1,4 @@
export * from './alarms.schema';
export * from './timers.schema';
export * from './world-clocks.schema';
export * from './presets.schema';

View file

@ -0,0 +1,24 @@
import { pgTable, uuid, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core';
export interface PresetSettings {
// For pomodoro presets
workDuration?: number; // in seconds
breakDuration?: number; // in seconds
longBreakDuration?: number; // in seconds
sessionsBeforeLongBreak?: number;
// For timer presets
sound?: string;
}
export const presets = pgTable('presets', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'timer' | 'pomodoro'
name: varchar('name', { length: 255 }).notNull(),
durationSeconds: integer('duration_seconds').notNull(),
settings: jsonb('settings').$type<PresetSettings>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Preset = typeof presets.$inferSelect;
export type NewPreset = typeof presets.$inferInsert;

View file

@ -0,0 +1,18 @@
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
export const timers = pgTable('timers', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
label: varchar('label', { length: 255 }),
durationSeconds: integer('duration_seconds').notNull(),
remainingSeconds: integer('remaining_seconds'),
status: varchar('status', { length: 20 }).default('idle').notNull(), // idle, running, paused, finished
startedAt: timestamp('started_at', { withTimezone: true }),
pausedAt: timestamp('paused_at', { withTimezone: true }),
sound: varchar('sound', { length: 100 }).default('default'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Timer = typeof timers.$inferSelect;
export type NewTimer = typeof timers.$inferInsert;

View file

@ -0,0 +1,13 @@
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
export const worldClocks = pgTable('world_clocks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
timezone: varchar('timezone', { length: 100 }).notNull(), // IANA timezone e.g. 'America/New_York'
cityName: varchar('city_name', { length: 255 }).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type WorldClock = typeof worldClocks.$inferSelect;
export type NewWorldClock = typeof worldClocks.$inferInsert;

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'clock-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5186',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3017;
await app.listen(port);
console.log(`Clock backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,65 @@
import { IsString, IsOptional, IsNumber, Min, Max, IsIn, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class PresetSettingsDto {
@IsOptional()
@IsNumber()
@Min(1)
workDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
breakDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
longBreakDuration?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
sessionsBeforeLongBreak?: number;
@IsOptional()
@IsString()
sound?: string;
}
export class CreatePresetDto {
@IsString()
@IsIn(['timer', 'pomodoro'])
type!: string;
@IsString()
name!: string;
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds!: number;
@IsOptional()
@ValidateNested()
@Type(() => PresetSettingsDto)
settings?: PresetSettingsDto;
}
export class UpdatePresetDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds?: number;
@IsOptional()
@ValidateNested()
@Type(() => PresetSettingsDto)
settings?: PresetSettingsDto;
}

View file

@ -0,0 +1,40 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { PresetService } from './preset.service';
import { CreatePresetDto, UpdatePresetDto } from './dto';
@Controller('presets')
@UseGuards(JwtAuthGuard)
export class PresetController {
constructor(private readonly presetService: PresetService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.presetService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.presetService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePresetDto) {
return this.presetService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdatePresetDto
) {
return this.presetService.update(id, user.userId, dto);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.presetService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PresetController } from './preset.controller';
import { PresetService } from './preset.service';
@Module({
controllers: [PresetController],
providers: [PresetService],
exports: [PresetService],
})
export class PresetModule {}

View file

@ -0,0 +1,65 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { presets, type Preset, type PresetSettings } from '../db/schema';
import { CreatePresetDto, UpdatePresetDto } from './dto';
@Injectable()
export class PresetService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Preset[]> {
return this.db.select().from(presets).where(eq(presets.userId, userId));
}
async findById(id: string, userId: string): Promise<Preset | null> {
const result = await this.db
.select()
.from(presets)
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Preset> {
const preset = await this.findById(id, userId);
if (!preset) {
throw new NotFoundException(`Preset with id ${id} not found`);
}
return preset;
}
async create(userId: string, dto: CreatePresetDto): Promise<Preset> {
const result = await this.db
.insert(presets)
.values({
userId,
type: dto.type,
name: dto.name,
durationSeconds: dto.durationSeconds,
settings: dto.settings as PresetSettings,
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdatePresetDto): Promise<Preset> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(presets)
.set({
...dto,
settings: dto.settings as PresetSettings,
})
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(presets).where(and(eq(presets.id, id), eq(presets.userId, userId)));
}
}

View file

@ -0,0 +1,32 @@
import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class CreateTimerDto {
@IsOptional()
@IsString()
label?: string;
@IsNumber()
@Min(1)
@Max(86400) // Max 24 hours
durationSeconds!: number;
@IsOptional()
@IsString()
sound?: string;
}
export class UpdateTimerDto {
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(86400)
durationSeconds?: number;
@IsOptional()
@IsString()
sound?: string;
}

View file

@ -0,0 +1,55 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TimerService } from './timer.service';
import { CreateTimerDto, UpdateTimerDto } from './dto';
@Controller('timers')
@UseGuards(JwtAuthGuard)
export class TimerController {
constructor(private readonly timerService: TimerService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.timerService.findAll(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.findByIdOrThrow(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTimerDto) {
return this.timerService.create(user.userId, dto);
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateTimerDto
) {
return this.timerService.update(id, user.userId, dto);
}
@Post(':id/start')
async start(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.start(id, user.userId);
}
@Post(':id/pause')
async pause(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.pause(id, user.userId);
}
@Post(':id/reset')
async reset(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.timerService.reset(id, user.userId);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.timerService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TimerController } from './timer.controller';
import { TimerService } from './timer.service';
@Module({
controllers: [TimerController],
providers: [TimerService],
exports: [TimerService],
})
export class TimerModule {}

View file

@ -0,0 +1,129 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { timers, type Timer } from '../db/schema';
import { CreateTimerDto, UpdateTimerDto } from './dto';
@Injectable()
export class TimerService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Timer[]> {
return this.db.select().from(timers).where(eq(timers.userId, userId));
}
async findById(id: string, userId: string): Promise<Timer | null> {
const result = await this.db
.select()
.from(timers)
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Timer> {
const timer = await this.findById(id, userId);
if (!timer) {
throw new NotFoundException(`Timer with id ${id} not found`);
}
return timer;
}
async create(userId: string, dto: CreateTimerDto): Promise<Timer> {
const result = await this.db
.insert(timers)
.values({
userId,
label: dto.label,
durationSeconds: dto.durationSeconds,
remainingSeconds: dto.durationSeconds,
status: 'idle',
sound: dto.sound ?? 'default',
})
.returning();
return result[0];
}
async update(id: string, userId: string, dto: UpdateTimerDto): Promise<Timer> {
await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(timers)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async start(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
if (timer.status === 'running') {
throw new BadRequestException('Timer is already running');
}
const result = await this.db
.update(timers)
.set({
status: 'running',
startedAt: new Date(),
pausedAt: null,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async pause(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
if (timer.status !== 'running') {
throw new BadRequestException('Timer is not running');
}
// Calculate remaining seconds
const elapsed = timer.startedAt
? Math.floor((Date.now() - timer.startedAt.getTime()) / 1000)
: 0;
const remaining = Math.max(0, (timer.remainingSeconds ?? timer.durationSeconds) - elapsed);
const result = await this.db
.update(timers)
.set({
status: 'paused',
remainingSeconds: remaining,
pausedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async reset(id: string, userId: string): Promise<Timer> {
const timer = await this.findByIdOrThrow(id, userId);
const result = await this.db
.update(timers)
.set({
status: 'idle',
remainingSeconds: timer.durationSeconds,
startedAt: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
.returning();
return result[0];
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(timers).where(and(eq(timers.id, id), eq(timers.userId, userId)));
}
}

View file

@ -0,0 +1,15 @@
import { IsString, IsArray, IsUUID } from 'class-validator';
export class CreateWorldClockDto {
@IsString()
timezone!: string;
@IsString()
cityName!: string;
}
export class ReorderWorldClocksDto {
@IsArray()
@IsUUID('4', { each: true })
ids!: string[];
}

View file

@ -0,0 +1,41 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { WorldClockService } from './world-clock.service';
import { CreateWorldClockDto, ReorderWorldClocksDto } from './dto';
@Controller('world-clocks')
@UseGuards(JwtAuthGuard)
export class WorldClockController {
constructor(private readonly worldClockService: WorldClockService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.worldClockService.findAll(user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateWorldClockDto) {
return this.worldClockService.create(user.userId, dto);
}
@Put('reorder')
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderWorldClocksDto) {
return this.worldClockService.reorder(user.userId, dto.ids);
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.worldClockService.delete(id, user.userId);
return { success: true };
}
}
@Controller('timezones')
export class TimezoneController {
constructor(private readonly worldClockService: WorldClockService) {}
@Get('search')
async search(@Query('q') query: string) {
return this.worldClockService.searchTimezones(query);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WorldClockController, TimezoneController } from './world-clock.controller';
import { WorldClockService } from './world-clock.service';
@Module({
controllers: [WorldClockController, TimezoneController],
providers: [WorldClockService],
exports: [WorldClockService],
})
export class WorldClockModule {}

View file

@ -0,0 +1,122 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { worldClocks, type WorldClock } from '../db/schema';
import { CreateWorldClockDto } from './dto';
// Common timezones with city names
const TIMEZONES = [
{ timezone: 'America/New_York', city: 'New York' },
{ timezone: 'America/Los_Angeles', city: 'Los Angeles' },
{ timezone: 'America/Chicago', city: 'Chicago' },
{ timezone: 'America/Denver', city: 'Denver' },
{ timezone: 'America/Toronto', city: 'Toronto' },
{ timezone: 'America/Vancouver', city: 'Vancouver' },
{ timezone: 'America/Mexico_City', city: 'Mexico City' },
{ timezone: 'America/Sao_Paulo', city: 'São Paulo' },
{ timezone: 'America/Buenos_Aires', city: 'Buenos Aires' },
{ timezone: 'Europe/London', city: 'London' },
{ timezone: 'Europe/Paris', city: 'Paris' },
{ timezone: 'Europe/Berlin', city: 'Berlin' },
{ timezone: 'Europe/Rome', city: 'Rome' },
{ timezone: 'Europe/Madrid', city: 'Madrid' },
{ timezone: 'Europe/Amsterdam', city: 'Amsterdam' },
{ timezone: 'Europe/Vienna', city: 'Vienna' },
{ timezone: 'Europe/Zurich', city: 'Zurich' },
{ timezone: 'Europe/Moscow', city: 'Moscow' },
{ timezone: 'Europe/Istanbul', city: 'Istanbul' },
{ timezone: 'Asia/Tokyo', city: 'Tokyo' },
{ timezone: 'Asia/Shanghai', city: 'Shanghai' },
{ timezone: 'Asia/Hong_Kong', city: 'Hong Kong' },
{ timezone: 'Asia/Singapore', city: 'Singapore' },
{ timezone: 'Asia/Seoul', city: 'Seoul' },
{ timezone: 'Asia/Mumbai', city: 'Mumbai' },
{ timezone: 'Asia/Dubai', city: 'Dubai' },
{ timezone: 'Asia/Bangkok', city: 'Bangkok' },
{ timezone: 'Asia/Jakarta', city: 'Jakarta' },
{ timezone: 'Australia/Sydney', city: 'Sydney' },
{ timezone: 'Australia/Melbourne', city: 'Melbourne' },
{ timezone: 'Pacific/Auckland', city: 'Auckland' },
{ timezone: 'Pacific/Honolulu', city: 'Honolulu' },
{ timezone: 'Africa/Cairo', city: 'Cairo' },
{ timezone: 'Africa/Johannesburg', city: 'Johannesburg' },
];
@Injectable()
export class WorldClockService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<WorldClock[]> {
return this.db
.select()
.from(worldClocks)
.where(eq(worldClocks.userId, userId))
.orderBy(asc(worldClocks.sortOrder));
}
async findById(id: string, userId: string): Promise<WorldClock | null> {
const result = await this.db
.select()
.from(worldClocks)
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<WorldClock> {
const clock = await this.findById(id, userId);
if (!clock) {
throw new NotFoundException(`World clock with id ${id} not found`);
}
return clock;
}
async create(userId: string, dto: CreateWorldClockDto): Promise<WorldClock> {
// Get the max sort order for this user
const existing = await this.findAll(userId);
const maxOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.sortOrder)) : -1;
const result = await this.db
.insert(worldClocks)
.values({
userId,
timezone: dto.timezone,
cityName: dto.cityName,
sortOrder: maxOrder + 1,
})
.returning();
return result[0];
}
async reorder(userId: string, ids: string[]): Promise<WorldClock[]> {
// Update sort order for each world clock
for (let i = 0; i < ids.length; i++) {
await this.db
.update(worldClocks)
.set({ sortOrder: i })
.where(and(eq(worldClocks.id, ids[i]), eq(worldClocks.userId, userId)));
}
return this.findAll(userId);
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db
.delete(worldClocks)
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)));
}
searchTimezones(query: string): { timezone: string; city: string }[] {
if (!query || query.length < 2) {
return TIMEZONES.slice(0, 10);
}
const lowerQuery = query.toLowerCase();
return TIMEZONES.filter(
(tz) =>
tz.city.toLowerCase().includes(lowerQuery) || tz.timezone.toLowerCase().includes(lowerQuery)
).slice(0, 20);
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto'
},
vite: {
resolve: {
alias: {
'@components': '/src/components',
'@layouts': '/src/layouts'
}
}
}
});

View file

@ -0,0 +1,35 @@
{
"name": "@clock/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --port 4323",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"clean": "rm -rf dist .astro node_modules"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.9.2"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.18",
"@types/node": "^20.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-astro": "^1.0.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.0"
}
}

View file

@ -0,0 +1,37 @@
---
// CTA section for Clock landing page
---
<section class="relative overflow-hidden">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/50 via-dark-bg to-primary-950/50">
</div>
<div class="container relative">
<div class="mx-auto max-w-3xl text-center">
<h2 class="mb-6 text-3xl font-bold md:text-5xl">Bereit, deine Zeit zu meistern?</h2>
<p class="mb-10 text-lg text-gray-400">
Starte jetzt kostenlos und entdecke, wie Clock dein Zeitmanagement revolutioniert. Keine
Kreditkarte erforderlich.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary group text-lg">
Kostenlos starten
<svg
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
</div>
<p class="mt-6 text-sm text-gray-500">Verfügbar für Web, iOS und Android</p>
</div>
</div>
</section>

View file

@ -0,0 +1,68 @@
---
// Features section for Clock landing page
const features = [
{
icon: '🌍',
title: 'Weltzeituhr',
description:
'Behalte mehrere Zeitzonen im Blick. Perfekt für internationale Teams und Reisende.',
},
{
icon: '⏰',
title: 'Wecker',
description:
'Erstelle wiederkehrende Wecker mit verschiedenen Tönen. Snooze-Funktion inklusive.',
},
{
icon: '⏱',
title: 'Timer',
description:
'Starte mehrere Timer gleichzeitig. Speichere deine Lieblings-Presets für schnellen Zugriff.',
},
{
icon: '⏲',
title: 'Stoppuhr',
description: 'Präzise Zeitmessung mit Rundenzeiten. Ideal für Sport und produktives Arbeiten.',
},
{
icon: '🍅',
title: 'Pomodoro',
description:
'Steigere deine Produktivität mit der bewährten Pomodoro-Technik. Anpassbare Intervalle.',
},
{
icon: '🔄',
title: 'Geräte-Sync',
description: 'Deine Wecker und Timer synchronisieren automatisch zwischen all deinen Geräten.',
},
];
---
<section id="features" class="bg-dark-surface">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Funktionen
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">
Alles was du für dein Zeitmanagement brauchst
</h2>
<p class="text-lg text-gray-400">
Von der Weltzeituhr bis zum Pomodoro-Timer - Clock vereint alle Zeit-Tools in einer
eleganten App.
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{
features.map((feature) => (
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all hover:border-primary-500/50 hover:bg-dark-card/80">
<div class="mb-4 text-4xl">{feature.icon}</div>
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
<p class="text-gray-400">{feature.description}</p>
</div>
))
}
</div>
</div>
</section>

View file

@ -0,0 +1,100 @@
---
// Footer for Clock landing page
const currentYear = new Date().getFullYear();
const links = {
product: [
{ label: 'Funktionen', href: '#features' },
{ label: 'Preise', href: '#pricing' },
{ label: 'Updates', href: '#' },
],
resources: [
{ label: 'Dokumentation', href: '#' },
{ label: 'Support', href: '#' },
{ label: 'Blog', href: '#' },
],
legal: [
{ label: 'Datenschutz', href: '#' },
{ label: 'AGB', href: '#' },
{ label: 'Impressum', href: '#' },
],
};
---
<footer class="border-t border-dark-border bg-dark-bg py-16">
<div class="container">
<div class="grid gap-12 md:grid-cols-4">
<!-- Brand -->
<div>
<div class="mb-4 flex items-center gap-2">
<span class="text-2xl">⏰</span>
<span class="text-xl font-bold">Clock</span>
</div>
<p class="text-sm text-gray-400">
Deine Zeit, perfekt organisiert. Weltzeituhr, Wecker, Timer und mehr.
</p>
</div>
<!-- Product Links -->
<div>
<h4 class="mb-4 font-semibold">Produkt</h4>
<ul class="space-y-2">
{
links.product.map((link) => (
<li>
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Resources Links -->
<div>
<h4 class="mb-4 font-semibold">Ressourcen</h4>
<ul class="space-y-2">
{
links.resources.map((link) => (
<li>
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Legal Links -->
<div>
<h4 class="mb-4 font-semibold">Rechtliches</h4>
<ul class="space-y-2">
{
links.legal.map((link) => (
<li>
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
{link.label}
</a>
</li>
))
}
</ul>
</div>
</div>
<div
class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row"
>
<p class="text-sm text-gray-500">
&copy; {currentYear} Clock. Alle Rechte vorbehalten.
</p>
<p class="text-sm text-gray-500">
Ein Produkt von <a href="https://manacore.app" class="text-primary-400 hover:underline"
>Mana Core</a
>
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,141 @@
---
// Hero section for Clock landing page
---
<section class="relative overflow-hidden py-20 md:py-32">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"></div>
<!-- Grid pattern -->
<div class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"></div>
<div class="container relative">
<div class="mx-auto max-w-4xl text-center">
<!-- Badge -->
<div
class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Zeit intelligent verwalten</span>
</div>
<!-- Headline -->
<h1 class="mb-6 text-4xl font-bold leading-tight md:text-6xl lg:text-7xl">
Deine Zeit,
<span class="gradient-text">perfekt organisiert</span>
</h1>
<!-- Subheadline -->
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro - alles in einer App. Synchronisiere
zwischen Geräten und verpasse nie wieder einen wichtigen Moment.
</p>
<!-- CTA Buttons -->
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary group text-lg">
Kostenlos starten
<svg
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a href="#features" class="btn btn-secondary"> Funktionen entdecken </a>
</div>
<!-- Social proof -->
<div class="mt-16 flex flex-col items-center gap-4">
<div class="flex -space-x-2">
{
[1, 2, 3, 4, 5].map((i) => (
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600" />
))
}
</div>
<p class="text-sm text-gray-500">
<span class="font-semibold text-white">1000+</span> Nutzer vertrauen Clock
</p>
</div>
</div>
<!-- Clock Preview -->
<div class="relative mx-auto mt-16 max-w-lg">
<div
class="absolute -inset-4 rounded-full bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"
>
</div>
<!-- Animated Clock Face -->
<div
class="relative mx-auto h-72 w-72 rounded-full border-4 border-dark-border bg-dark-card shadow-2xl"
>
<!-- Hour markers -->
{
[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg, i) => (
<div
class="absolute left-1/2 top-1/2 h-3 w-1 -translate-x-1/2 bg-gray-400"
style={`transform: translateX(-50%) rotate(${deg}deg) translateY(-130px)`}
/>
))
}
<!-- Clock hands -->
<div
class="absolute left-1/2 top-1/2 h-20 w-1.5 -translate-x-1/2 origin-bottom rounded-full bg-white animate-hour"
style="transform: translateX(-50%) rotate(45deg); transform-origin: bottom center;"
>
</div>
<div
class="absolute left-1/2 top-1/2 h-28 w-1 -translate-x-1/2 origin-bottom rounded-full bg-white animate-minute"
style="transform: translateX(-50%) rotate(120deg); transform-origin: bottom center;"
>
</div>
<div
class="absolute left-1/2 top-1/2 h-32 w-0.5 -translate-x-1/2 origin-bottom rounded-full bg-primary-500 animate-second"
style="transform: translateX(-50%) rotate(270deg); transform-origin: bottom center;"
>
</div>
<!-- Center dot -->
<div
class="absolute left-1/2 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-500"
>
</div>
<!-- Digital time overlay -->
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 text-center">
<div class="font-mono text-2xl font-light text-gray-400" id="digital-time">00:00</div>
</div>
</div>
</div>
</div>
</section>
<script>
function updateClock() {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const digitalTime = document.getElementById('digital-time');
if (digitalTime) {
digitalTime.textContent = `${hours}:${minutes}`;
}
}
updateClock();
setInterval(updateClock, 1000);
</script>

View file

@ -0,0 +1,45 @@
---
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
}
const {
title = 'Clock - Uhren, Wecker & Timer',
description = 'Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro in einer App. Synchronisiere zwischen Geräten. Kostenlos starten.',
} = Astro.props;
---
<!doctype html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- SEO Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>{title}</title>
</head>
<body class="antialiased">
<slot />
</body>
</html>

View file

@ -0,0 +1,159 @@
---
import Layout from '@layouts/Layout.astro';
import Hero from '@components/Hero.astro';
import Features from '@components/Features.astro';
import CTA from '@components/CTA.astro';
import Footer from '@components/Footer.astro';
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt für Einzelpersonen',
features: [
{ text: 'Unbegrenzte Wecker', included: true },
{ text: 'Unbegrenzte Timer', included: true },
{ text: 'Stoppuhr', included: true },
{ text: 'Pomodoro-Timer', included: true },
{ text: 'Geräte-Sync', included: false },
{ text: 'Push-Benachrichtigungen', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#',
},
},
{
name: 'Pro',
price: '2,99',
period: '/Monat',
description: 'Für Power-User',
features: [
{ text: 'Alles aus Free', included: true },
{ text: 'Geräte-Sync', included: true },
{ text: 'Push-Benachrichtigungen', included: true },
{ text: 'Unbegrenzte Weltuhr-Städte', included: true },
{ text: 'Eigene Alarm-Sounds', included: true },
{ text: 'Widgets', included: true },
],
cta: {
text: 'Pro starten',
href: '#',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Team',
price: '4,99',
period: '/Monat',
description: 'Für Teams & Familien',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Bis zu 5 Nutzer', included: true },
{ text: 'Geteilte Timer', included: true },
{ text: 'Team-Statistiken', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'Priority Support', included: true },
],
cta: {
text: 'Team erstellen',
href: '#',
},
},
];
---
<Layout
title="Clock - Uhren, Wecker & Timer"
description="Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro in einer App. Synchronisiere zwischen Geräten. Kostenlos starten."
>
<Hero />
<Features />
<!-- Pricing Section -->
<section id="pricing" class="bg-dark-bg">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span
class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400"
>
Preise
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
</div>
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{
pricingPlans.map((plan) => (
<div
class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}
>
{plan.badge && (
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
{plan.badge}
</div>
)}
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
<div class="mb-6">
<span class="text-4xl font-bold">{plan.price}€</span>
<span class="text-gray-500">{plan.period}</span>
</div>
<ul class="mb-8 space-y-3">
{plan.features.map((feature) => (
<li
class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}
>
{feature.included ? (
<svg
class="h-5 w-5 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg
class="h-5 w-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
{feature.text}
</li>
))}
</ul>
<a
href={plan.cta.href}
class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}
>
{plan.cta.text}
</a>
</div>
))
}
</div>
</div>
</section>
<CTA />
<Footer />
</Layout>

View file

@ -0,0 +1,90 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-background-page: #0a0a0a;
--color-background-card: #1a1a1a;
--color-background-card-hover: #242424;
--color-text-primary: #ffffff;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-border: #262626;
--color-border-hover: #3f3f3f;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-bg text-white;
}
}
@layer components {
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.btn {
@apply inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium transition-all duration-200;
}
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600;
}
.btn-secondary {
@apply border border-dark-border bg-dark-card text-white hover:bg-dark-surface;
}
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
}
section {
@apply py-20 md:py-28;
}
}
/* Animate clock hands */
@keyframes rotate-hour {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes rotate-minute {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes rotate-second {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-hour {
animation: rotate-hour 43200s linear infinite;
}
.animate-minute {
animation: rotate-minute 3600s linear infinite;
}
.animate-second {
animation: rotate-second 60s linear infinite;
}

View file

@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: {
extend: {
colors: {
// Clock app theme - amber/orange
primary: {
DEFAULT: '#f59e0b',
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03'
},
dark: {
bg: '#0a0a0a',
surface: '#111111',
card: '#1a1a1a',
border: '#262626'
},
// CSS variable mappings for shared-landing-ui compatibility
background: {
page: 'var(--color-background-page, #0a0a0a)',
card: 'var(--color-background-card, #1a1a1a)',
'card-hover': 'var(--color-background-card-hover, #242424)'
},
text: {
primary: 'var(--color-text-primary, #ffffff)',
secondary: 'var(--color-text-secondary, #d1d5db)',
muted: 'var(--color-text-muted, #9ca3af)'
},
border: {
DEFAULT: 'var(--color-border, #262626)',
hover: 'var(--color-border-hover, #3f3f3f)'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif']
}
}
},
plugins: [require('@tailwindcss/typography')]
};

View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -0,0 +1,3 @@
name = "clock-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View file

@ -0,0 +1,49 @@
{
"name": "@clock/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@clock/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,399 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
/* Clock-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Clock-specific */
--clock-size: 280px;
--timer-display-size: 120px;
}
}
/* Analog Clock Styles */
.clock-face {
position: relative;
width: var(--clock-size);
height: var(--clock-size);
border-radius: 50%;
background-color: hsl(var(--color-surface));
border: 4px solid hsl(var(--color-border));
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.clock-hand {
position: absolute;
bottom: 50%;
left: 50%;
transform-origin: bottom center;
border-radius: 9999px;
}
.clock-hand.hour {
width: 6px;
height: 30%;
margin-left: -3px;
background-color: hsl(var(--color-foreground));
}
.clock-hand.minute {
width: 4px;
height: 40%;
margin-left: -2px;
background-color: hsl(var(--color-foreground));
}
.clock-hand.second {
width: 2px;
height: 45%;
margin-left: -1px;
background-color: hsl(var(--color-primary));
}
.clock-center {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
margin: -6px;
border-radius: 50%;
background-color: hsl(var(--color-primary));
}
/* Clock markers */
.clock-marker {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
}
.clock-marker.hour-marker {
width: 3px;
height: 10px;
background-color: hsl(var(--color-foreground));
}
.clock-marker.minute-marker {
width: 1px;
height: 6px;
background-color: hsl(var(--color-muted-foreground));
}
/* Digital Clock Styles */
.digital-clock {
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-variant-numeric: tabular-nums;
}
.digital-clock-large {
font-size: 4rem;
font-weight: 200;
letter-spacing: -0.02em;
}
.digital-clock-medium {
font-size: 2.5rem;
font-weight: 300;
}
.digital-clock-small {
font-size: 1.5rem;
font-weight: 400;
}
/* Timer Display */
.timer-display {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: var(--timer-display-size);
font-weight: 200;
line-height: 1;
font-variant-numeric: tabular-nums;
}
/* Stopwatch lap list */
.lap-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.lap-item:last-child {
border-bottom: none;
}
.lap-item.best {
color: hsl(var(--color-success));
}
.lap-item.worst {
color: hsl(var(--color-error));
}
/* Alarm Card */
.alarm-card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
transition: all var(--transition-base);
}
.alarm-card:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.alarm-card.disabled {
opacity: 0.6;
}
/* World Clock Card */
.world-clock-card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
}
.world-clock-card .city-name {
font-size: 1.125rem;
font-weight: 500;
}
.world-clock-card .timezone-info {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.world-clock-card .time-display {
font-size: 2rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
}
/* Pomodoro Progress Ring */
.pomodoro-ring {
stroke: hsl(var(--color-primary));
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1s linear;
}
.pomodoro-ring-bg {
stroke: hsl(var(--color-muted));
}
/* Card styles */
.card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
cursor: pointer;
border: none;
background: transparent;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--color-foreground));
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
.btn-icon {
padding: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-xl {
padding: 1rem 2rem;
font-size: 1.125rem;
}
/* Input styles */
.input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* Time Input (for alarm) */
.time-input {
font-size: 2rem;
font-weight: 300;
text-align: center;
width: 5rem;
padding: 0.5rem;
font-variant-numeric: tabular-nums;
}
/* Toggle Switch */
.toggle {
position: relative;
width: 44px;
height: 24px;
background-color: hsl(var(--color-muted));
border-radius: var(--radius-full);
cursor: pointer;
transition: background-color var(--transition-base);
}
.toggle.active {
background-color: hsl(var(--color-primary));
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform var(--transition-base);
}
.toggle.active::after {
transform: translateX(20px);
}
/* Day of week selector (for alarm repeat) */
.day-selector {
display: flex;
gap: var(--spacing-xs);
}
.day-selector button {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.day-selector button:hover {
border-color: hsl(var(--color-primary));
}
.day-selector button.active {
background-color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
/* Scrollbar styling */
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clock</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,38 @@
/**
* Alarms API client
*/
import { api } from './client';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
export const alarmsApi = {
/**
* Get all alarms for the current user
*/
getAll: () => api.get<Alarm[]>('/alarms'),
/**
* Get a single alarm by ID
*/
getById: (id: string) => api.get<Alarm>(`/alarms/${id}`),
/**
* Create a new alarm
*/
create: (data: CreateAlarmInput) => api.post<Alarm>('/alarms', data),
/**
* Update an existing alarm
*/
update: (id: string, data: UpdateAlarmInput) => api.put<Alarm>(`/alarms/${id}`, data),
/**
* Delete an alarm
*/
delete: (id: string) => api.delete<void>(`/alarms/${id}`),
/**
* Toggle alarm enabled state
*/
toggle: (id: string) => api.patch<Alarm>(`/alarms/${id}/toggle`),
};

View file

@ -0,0 +1,80 @@
/**
* API Client for Clock backend
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017/api/v1';
export interface ApiResponse<T> {
data?: T;
error?: string;
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
error: errorData.message || `HTTP error ${response.status}`,
};
}
// Handle 204 No Content
if (response.status === 204) {
return { data: undefined as T };
}
const data = await response.json();
return { data };
} catch (error) {
console.error('API Error:', error);
return {
error: error instanceof Error ? error.message : 'Network error',
};
}
}
// Convenience methods
export const api = {
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};

View file

@ -0,0 +1,33 @@
/**
* Presets API client
*/
import { api } from './client';
import type { Preset, CreatePresetInput, UpdatePresetInput } from '@clock/shared';
export const presetsApi = {
/**
* Get all presets for the current user
*/
getAll: () => api.get<Preset[]>('/presets'),
/**
* Get presets by type
*/
getByType: (type: 'timer' | 'pomodoro') => api.get<Preset[]>(`/presets?type=${type}`),
/**
* Create a new preset
*/
create: (data: CreatePresetInput) => api.post<Preset>('/presets', data),
/**
* Update an existing preset
*/
update: (id: string, data: UpdatePresetInput) => api.put<Preset>(`/presets/${id}`, data),
/**
* Delete a preset
*/
delete: (id: string) => api.delete<void>(`/presets/${id}`),
};

View file

@ -0,0 +1,48 @@
/**
* Timers API client
*/
import { api } from './client';
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
export const timersApi = {
/**
* Get all timers for the current user
*/
getAll: () => api.get<Timer[]>('/timers'),
/**
* Get a single timer by ID
*/
getById: (id: string) => api.get<Timer>(`/timers/${id}`),
/**
* Create a new timer
*/
create: (data: CreateTimerInput) => api.post<Timer>('/timers', data),
/**
* Update an existing timer
*/
update: (id: string, data: UpdateTimerInput) => api.put<Timer>(`/timers/${id}`, data),
/**
* Delete a timer
*/
delete: (id: string) => api.delete<void>(`/timers/${id}`),
/**
* Start a timer
*/
start: (id: string) => api.post<Timer>(`/timers/${id}/start`),
/**
* Pause a timer
*/
pause: (id: string) => api.post<Timer>(`/timers/${id}/pause`),
/**
* Reset a timer
*/
reset: (id: string) => api.post<Timer>(`/timers/${id}/reset`),
};

View file

@ -0,0 +1,36 @@
/**
* World Clocks API client
*/
import { api } from './client';
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
export const worldClocksApi = {
/**
* Get all world clocks for the current user
*/
getAll: () => api.get<WorldClock[]>('/world-clocks'),
/**
* Create a new world clock entry
*/
create: (data: CreateWorldClockInput) => api.post<WorldClock>('/world-clocks', data),
/**
* Delete a world clock entry
*/
delete: (id: string) => api.delete<void>(`/world-clocks/${id}`),
/**
* Reorder world clocks
*/
reorder: (ids: string[]) => api.put<WorldClock[]>('/world-clocks/reorder', { ids }),
/**
* Search for timezones
*/
searchTimezones: (query: string) =>
api.get<{ timezone: string; city: string }[]>(
`/timezones/search?q=${encodeURIComponent(query)}`
),
};

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { toast, type Toast } from '$lib/stores/toast';
let toasts: Toast[] = [];
toast.subscribe((value) => {
toasts = value;
});
function getToastClasses(type: Toast['type']) {
switch (type) {
case 'success':
return 'bg-green-500 text-white';
case 'error':
return 'bg-red-500 text-white';
case 'warning':
return 'bg-yellow-500 text-black';
default:
return 'bg-blue-500 text-white';
}
}
function getIcon(type: Toast['type']) {
switch (type) {
case 'success':
return '✓';
case 'error':
return '✕';
case 'warning':
return '⚠';
default:
return '';
}
}
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each toasts as t (t.id)}
<div
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg transition-all duration-300 {getToastClasses(
t.type
)}"
>
<span class="text-lg">{getIcon(t.type)}</span>
<span class="flex-1">{t.message}</span>
<button onclick={() => toast.remove(t.id)} class="ml-2 opacity-70 hover:opacity-100">
</button>
</div>
{/each}
</div>

View file

@ -0,0 +1,55 @@
/**
* i18n setup for Clock app
* Supports: DE, EN, FR, ES, IT
*/
import { browser } from '$app/environment';
import { init, register, locale, getLocaleFromNavigator } from 'svelte-i18n';
// Supported locales
export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Register locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
register('it', () => import('./locales/it.json'));
// Get initial locale
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const saved = localStorage.getItem('clock-locale');
if (saved && supportedLocales.includes(saved as SupportedLocale)) {
return saved as SupportedLocale;
}
// Fall back to browser language
const browserLocale = getLocaleFromNavigator();
if (browserLocale) {
const shortLocale = browserLocale.split('-')[0] as SupportedLocale;
if (supportedLocales.includes(shortLocale)) {
return shortLocale;
}
}
}
// Default to German
return 'de';
}
// Initialize
init({
fallbackLocale: 'de',
initialLocale: getInitialLocale(),
});
// Set locale and persist
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('clock-locale', newLocale);
}
}

View file

@ -0,0 +1,149 @@
{
"app": {
"name": "Clock",
"loading": "Laden..."
},
"nav": {
"dashboard": "Übersicht",
"alarms": "Wecker",
"timers": "Timer",
"stopwatch": "Stoppuhr",
"pomodoro": "Pomodoro",
"worldClock": "Weltzeituhr",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"logout": "Abmelden",
"forgotPassword": "Passwort vergessen",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen"
},
"dashboard": {
"title": "Übersicht",
"nextAlarm": "Nächster Wecker",
"activeTimers": "Aktive Timer",
"worldClocks": "Weltuhr"
},
"alarm": {
"title": "Wecker",
"add": "Wecker hinzufügen",
"edit": "Wecker bearbeiten",
"delete": "Wecker löschen",
"label": "Bezeichnung",
"time": "Zeit",
"repeat": "Wiederholen",
"sound": "Ton",
"snooze": "Schlummern",
"snoozeMinutes": "{minutes} Minuten",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"noAlarms": "Keine Wecker eingestellt",
"days": {
"sun": "So",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa"
},
"once": "Einmalig",
"daily": "Täglich",
"weekdays": "Wochentags",
"weekends": "Am Wochenende",
"custom": "Benutzerdefiniert"
},
"timer": {
"title": "Timer",
"add": "Timer hinzufügen",
"start": "Start",
"pause": "Pause",
"reset": "Zurücksetzen",
"stop": "Stopp",
"delete": "Löschen",
"label": "Bezeichnung",
"duration": "Dauer",
"hours": "Stunden",
"minutes": "Minuten",
"seconds": "Sekunden",
"noTimers": "Keine Timer aktiv",
"presets": "Schnellauswahl",
"finished": "Timer abgelaufen!"
},
"stopwatch": {
"title": "Stoppuhr",
"start": "Start",
"stop": "Stopp",
"lap": "Runde",
"reset": "Zurücksetzen",
"laps": "Runden",
"noLaps": "Noch keine Runden",
"best": "Beste",
"worst": "Längste",
"total": "Gesamt"
},
"pomodoro": {
"title": "Pomodoro",
"work": "Arbeiten",
"break": "Pause",
"longBreak": "Lange Pause",
"sessions": "Sitzungen",
"sessionsCompleted": "{count} von {total} Sitzungen",
"start": "Start",
"pause": "Pause",
"skip": "Überspringen",
"reset": "Zurücksetzen",
"presets": {
"classic": "Klassisch",
"shortFocus": "Kurzer Fokus",
"deepWork": "Tiefe Arbeit"
},
"settings": {
"workDuration": "Arbeitszeit",
"breakDuration": "Pausenzeit",
"longBreakDuration": "Lange Pause",
"sessionsBeforeLongBreak": "Sitzungen bis zur langen Pause"
}
},
"worldClock": {
"title": "Weltzeituhr",
"add": "Stadt hinzufügen",
"search": "Stadt oder Zeitzone suchen...",
"noClocks": "Keine Städte hinzugefügt",
"difference": "{hours} Std. {direction}",
"ahead": "voraus",
"behind": "zurück",
"same": "Gleiche Zeit"
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",
"appearance": "Darstellung",
"sounds": "Töne",
"notifications": "Benachrichtigungen",
"language": "Sprache",
"theme": "Design",
"darkMode": "Dunkelmodus",
"clockFormat": "Uhrzeitformat",
"format24h": "24 Stunden",
"format12h": "12 Stunden (AM/PM)"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"confirm": "Bestätigen",
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg"
}
}

View file

@ -0,0 +1,149 @@
{
"app": {
"name": "Clock",
"loading": "Loading..."
},
"nav": {
"dashboard": "Dashboard",
"alarms": "Alarms",
"timers": "Timers",
"stopwatch": "Stopwatch",
"pomodoro": "Pomodoro",
"worldClock": "World Clock",
"settings": "Settings",
"feedback": "Feedback"
},
"auth": {
"login": "Sign In",
"register": "Sign Up",
"logout": "Sign Out",
"forgotPassword": "Forgot Password",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"dashboard": {
"title": "Dashboard",
"nextAlarm": "Next Alarm",
"activeTimers": "Active Timers",
"worldClocks": "World Clocks"
},
"alarm": {
"title": "Alarms",
"add": "Add Alarm",
"edit": "Edit Alarm",
"delete": "Delete Alarm",
"label": "Label",
"time": "Time",
"repeat": "Repeat",
"sound": "Sound",
"snooze": "Snooze",
"snoozeMinutes": "{minutes} minutes",
"enabled": "Enabled",
"disabled": "Disabled",
"noAlarms": "No alarms set",
"days": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
},
"once": "Once",
"daily": "Daily",
"weekdays": "Weekdays",
"weekends": "Weekends",
"custom": "Custom"
},
"timer": {
"title": "Timers",
"add": "Add Timer",
"start": "Start",
"pause": "Pause",
"reset": "Reset",
"stop": "Stop",
"delete": "Delete",
"label": "Label",
"duration": "Duration",
"hours": "Hours",
"minutes": "Minutes",
"seconds": "Seconds",
"noTimers": "No active timers",
"presets": "Quick Select",
"finished": "Timer finished!"
},
"stopwatch": {
"title": "Stopwatch",
"start": "Start",
"stop": "Stop",
"lap": "Lap",
"reset": "Reset",
"laps": "Laps",
"noLaps": "No laps yet",
"best": "Best",
"worst": "Worst",
"total": "Total"
},
"pomodoro": {
"title": "Pomodoro",
"work": "Work",
"break": "Break",
"longBreak": "Long Break",
"sessions": "Sessions",
"sessionsCompleted": "{count} of {total} sessions",
"start": "Start",
"pause": "Pause",
"skip": "Skip",
"reset": "Reset",
"presets": {
"classic": "Classic",
"shortFocus": "Short Focus",
"deepWork": "Deep Work"
},
"settings": {
"workDuration": "Work Duration",
"breakDuration": "Break Duration",
"longBreakDuration": "Long Break Duration",
"sessionsBeforeLongBreak": "Sessions Before Long Break"
}
},
"worldClock": {
"title": "World Clock",
"add": "Add City",
"search": "Search city or timezone...",
"noClocks": "No cities added",
"difference": "{hours} hrs {direction}",
"ahead": "ahead",
"behind": "behind",
"same": "Same time"
},
"settings": {
"title": "Settings",
"general": "General",
"appearance": "Appearance",
"sounds": "Sounds",
"notifications": "Notifications",
"language": "Language",
"theme": "Theme",
"darkMode": "Dark Mode",
"clockFormat": "Clock Format",
"format24h": "24 Hours",
"format12h": "12 Hours (AM/PM)"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"loading": "Loading...",
"error": "Error",
"success": "Success"
}
}

View file

@ -0,0 +1,149 @@
{
"app": {
"name": "Clock",
"loading": "Cargando..."
},
"nav": {
"dashboard": "Panel",
"alarms": "Alarmas",
"timers": "Temporizadores",
"stopwatch": "Cronómetro",
"pomodoro": "Pomodoro",
"worldClock": "Reloj mundial",
"settings": "Ajustes",
"feedback": "Feedback"
},
"auth": {
"login": "Iniciar sesión",
"register": "Registrarse",
"logout": "Cerrar sesión",
"forgotPassword": "Olvidé mi contraseña",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña"
},
"dashboard": {
"title": "Panel",
"nextAlarm": "Próxima alarma",
"activeTimers": "Temporizadores activos",
"worldClocks": "Relojes mundiales"
},
"alarm": {
"title": "Alarmas",
"add": "Agregar alarma",
"edit": "Editar alarma",
"delete": "Eliminar alarma",
"label": "Etiqueta",
"time": "Hora",
"repeat": "Repetir",
"sound": "Sonido",
"snooze": "Posponer",
"snoozeMinutes": "{minutes} minutos",
"enabled": "Activada",
"disabled": "Desactivada",
"noAlarms": "No hay alarmas configuradas",
"days": {
"sun": "Dom",
"mon": "Lun",
"tue": "Mar",
"wed": "Mié",
"thu": "Jue",
"fri": "Vie",
"sat": "Sáb"
},
"once": "Una vez",
"daily": "Diario",
"weekdays": "Días laborables",
"weekends": "Fines de semana",
"custom": "Personalizado"
},
"timer": {
"title": "Temporizadores",
"add": "Agregar temporizador",
"start": "Iniciar",
"pause": "Pausar",
"reset": "Reiniciar",
"stop": "Detener",
"delete": "Eliminar",
"label": "Etiqueta",
"duration": "Duración",
"hours": "Horas",
"minutes": "Minutos",
"seconds": "Segundos",
"noTimers": "No hay temporizadores activos",
"presets": "Selección rápida",
"finished": "¡Temporizador terminado!"
},
"stopwatch": {
"title": "Cronómetro",
"start": "Iniciar",
"stop": "Detener",
"lap": "Vuelta",
"reset": "Reiniciar",
"laps": "Vueltas",
"noLaps": "Aún no hay vueltas",
"best": "Mejor",
"worst": "Peor",
"total": "Total"
},
"pomodoro": {
"title": "Pomodoro",
"work": "Trabajo",
"break": "Descanso",
"longBreak": "Descanso largo",
"sessions": "Sesiones",
"sessionsCompleted": "{count} de {total} sesiones",
"start": "Iniciar",
"pause": "Pausar",
"skip": "Saltar",
"reset": "Reiniciar",
"presets": {
"classic": "Clásico",
"shortFocus": "Enfoque corto",
"deepWork": "Trabajo profundo"
},
"settings": {
"workDuration": "Duración del trabajo",
"breakDuration": "Duración del descanso",
"longBreakDuration": "Duración del descanso largo",
"sessionsBeforeLongBreak": "Sesiones antes del descanso largo"
}
},
"worldClock": {
"title": "Reloj mundial",
"add": "Agregar ciudad",
"search": "Buscar ciudad o zona horaria...",
"noClocks": "No hay ciudades agregadas",
"difference": "{hours} hrs {direction}",
"ahead": "adelante",
"behind": "atrás",
"same": "Misma hora"
},
"settings": {
"title": "Ajustes",
"general": "General",
"appearance": "Apariencia",
"sounds": "Sonidos",
"notifications": "Notificaciones",
"language": "Idioma",
"theme": "Tema",
"darkMode": "Modo oscuro",
"clockFormat": "Formato de hora",
"format24h": "24 horas",
"format12h": "12 horas (AM/PM)"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Agregar",
"confirm": "Confirmar",
"yes": "Sí",
"no": "No",
"ok": "OK",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito"
}
}

View file

@ -0,0 +1,149 @@
{
"app": {
"name": "Clock",
"loading": "Chargement..."
},
"nav": {
"dashboard": "Tableau de bord",
"alarms": "Alarmes",
"timers": "Minuteries",
"stopwatch": "Chronomètre",
"pomodoro": "Pomodoro",
"worldClock": "Horloge mondiale",
"settings": "Paramètres",
"feedback": "Feedback"
},
"auth": {
"login": "Connexion",
"register": "Inscription",
"logout": "Déconnexion",
"forgotPassword": "Mot de passe oublié",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe"
},
"dashboard": {
"title": "Tableau de bord",
"nextAlarm": "Prochaine alarme",
"activeTimers": "Minuteries actives",
"worldClocks": "Horloges mondiales"
},
"alarm": {
"title": "Alarmes",
"add": "Ajouter une alarme",
"edit": "Modifier l'alarme",
"delete": "Supprimer l'alarme",
"label": "Libellé",
"time": "Heure",
"repeat": "Répéter",
"sound": "Son",
"snooze": "Répétition",
"snoozeMinutes": "{minutes} minutes",
"enabled": "Activée",
"disabled": "Désactivée",
"noAlarms": "Aucune alarme définie",
"days": {
"sun": "Dim",
"mon": "Lun",
"tue": "Mar",
"wed": "Mer",
"thu": "Jeu",
"fri": "Ven",
"sat": "Sam"
},
"once": "Une fois",
"daily": "Quotidien",
"weekdays": "Jours de semaine",
"weekends": "Week-ends",
"custom": "Personnalisé"
},
"timer": {
"title": "Minuteries",
"add": "Ajouter une minuterie",
"start": "Démarrer",
"pause": "Pause",
"reset": "Réinitialiser",
"stop": "Arrêter",
"delete": "Supprimer",
"label": "Libellé",
"duration": "Durée",
"hours": "Heures",
"minutes": "Minutes",
"seconds": "Secondes",
"noTimers": "Aucune minuterie active",
"presets": "Sélection rapide",
"finished": "Minuterie terminée!"
},
"stopwatch": {
"title": "Chronomètre",
"start": "Démarrer",
"stop": "Arrêter",
"lap": "Tour",
"reset": "Réinitialiser",
"laps": "Tours",
"noLaps": "Pas encore de tours",
"best": "Meilleur",
"worst": "Pire",
"total": "Total"
},
"pomodoro": {
"title": "Pomodoro",
"work": "Travail",
"break": "Pause",
"longBreak": "Longue pause",
"sessions": "Sessions",
"sessionsCompleted": "{count} sur {total} sessions",
"start": "Démarrer",
"pause": "Pause",
"skip": "Passer",
"reset": "Réinitialiser",
"presets": {
"classic": "Classique",
"shortFocus": "Focus court",
"deepWork": "Travail profond"
},
"settings": {
"workDuration": "Durée de travail",
"breakDuration": "Durée de pause",
"longBreakDuration": "Durée de longue pause",
"sessionsBeforeLongBreak": "Sessions avant longue pause"
}
},
"worldClock": {
"title": "Horloge mondiale",
"add": "Ajouter une ville",
"search": "Rechercher ville ou fuseau horaire...",
"noClocks": "Aucune ville ajoutée",
"difference": "{hours} h {direction}",
"ahead": "d'avance",
"behind": "de retard",
"same": "Même heure"
},
"settings": {
"title": "Paramètres",
"general": "Général",
"appearance": "Apparence",
"sounds": "Sons",
"notifications": "Notifications",
"language": "Langue",
"theme": "Thème",
"darkMode": "Mode sombre",
"clockFormat": "Format d'heure",
"format24h": "24 heures",
"format12h": "12 heures (AM/PM)"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"add": "Ajouter",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"ok": "OK",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès"
}
}

View file

@ -0,0 +1,149 @@
{
"app": {
"name": "Clock",
"loading": "Caricamento..."
},
"nav": {
"dashboard": "Panoramica",
"alarms": "Sveglie",
"timers": "Timer",
"stopwatch": "Cronometro",
"pomodoro": "Pomodoro",
"worldClock": "Orologio mondiale",
"settings": "Impostazioni",
"feedback": "Feedback"
},
"auth": {
"login": "Accedi",
"register": "Registrati",
"logout": "Esci",
"forgotPassword": "Password dimenticata",
"email": "Email",
"password": "Password",
"confirmPassword": "Conferma password"
},
"dashboard": {
"title": "Panoramica",
"nextAlarm": "Prossima sveglia",
"activeTimers": "Timer attivi",
"worldClocks": "Orologi mondiali"
},
"alarm": {
"title": "Sveglie",
"add": "Aggiungi sveglia",
"edit": "Modifica sveglia",
"delete": "Elimina sveglia",
"label": "Etichetta",
"time": "Ora",
"repeat": "Ripeti",
"sound": "Suono",
"snooze": "Posticipa",
"snoozeMinutes": "{minutes} minuti",
"enabled": "Attiva",
"disabled": "Disattiva",
"noAlarms": "Nessuna sveglia impostata",
"days": {
"sun": "Dom",
"mon": "Lun",
"tue": "Mar",
"wed": "Mer",
"thu": "Gio",
"fri": "Ven",
"sat": "Sab"
},
"once": "Una volta",
"daily": "Giornaliero",
"weekdays": "Giorni feriali",
"weekends": "Fine settimana",
"custom": "Personalizzato"
},
"timer": {
"title": "Timer",
"add": "Aggiungi timer",
"start": "Avvia",
"pause": "Pausa",
"reset": "Reimposta",
"stop": "Ferma",
"delete": "Elimina",
"label": "Etichetta",
"duration": "Durata",
"hours": "Ore",
"minutes": "Minuti",
"seconds": "Secondi",
"noTimers": "Nessun timer attivo",
"presets": "Selezione rapida",
"finished": "Timer terminato!"
},
"stopwatch": {
"title": "Cronometro",
"start": "Avvia",
"stop": "Ferma",
"lap": "Giro",
"reset": "Reimposta",
"laps": "Giri",
"noLaps": "Nessun giro ancora",
"best": "Migliore",
"worst": "Peggiore",
"total": "Totale"
},
"pomodoro": {
"title": "Pomodoro",
"work": "Lavoro",
"break": "Pausa",
"longBreak": "Pausa lunga",
"sessions": "Sessioni",
"sessionsCompleted": "{count} di {total} sessioni",
"start": "Avvia",
"pause": "Pausa",
"skip": "Salta",
"reset": "Reimposta",
"presets": {
"classic": "Classico",
"shortFocus": "Focus breve",
"deepWork": "Lavoro profondo"
},
"settings": {
"workDuration": "Durata lavoro",
"breakDuration": "Durata pausa",
"longBreakDuration": "Durata pausa lunga",
"sessionsBeforeLongBreak": "Sessioni prima della pausa lunga"
}
},
"worldClock": {
"title": "Orologio mondiale",
"add": "Aggiungi città",
"search": "Cerca città o fuso orario...",
"noClocks": "Nessuna città aggiunta",
"difference": "{hours} ore {direction}",
"ahead": "avanti",
"behind": "indietro",
"same": "Stessa ora"
},
"settings": {
"title": "Impostazioni",
"general": "Generale",
"appearance": "Aspetto",
"sounds": "Suoni",
"notifications": "Notifiche",
"language": "Lingua",
"theme": "Tema",
"darkMode": "Modalità scura",
"clockFormat": "Formato ora",
"format24h": "24 ore",
"format12h": "12 ore (AM/PM)"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"add": "Aggiungi",
"confirm": "Conferma",
"yes": "Sì",
"no": "No",
"ok": "OK",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo"
}
}

View file

@ -0,0 +1,135 @@
/**
* Alarms Store - Manages alarm state using Svelte 5 runes
*/
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
import { alarmsApi } from '$lib/api/alarms';
// State
let alarms = $state<Alarm[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const alarmsStore = {
// Getters
get alarms() {
return alarms;
},
get loading() {
return loading;
},
get error() {
return error;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
get nextAlarm() {
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const enabled = alarms.filter((a) => a.enabled);
if (enabled.length === 0) return null;
// Find the next alarm based on time
let nextAlarm: Alarm | null = null;
let minDiff = Infinity;
for (const alarm of enabled) {
const [hours, minutes] = alarm.time.split(':').map(Number);
const alarmTime = hours * 60 + minutes;
let diff = alarmTime - currentTime;
if (diff < 0) diff += 24 * 60; // Tomorrow
if (diff < minDiff) {
minDiff = diff;
nextAlarm = alarm;
}
}
return nextAlarm;
},
/**
* Fetch all alarms from the API
*/
async fetchAlarms() {
loading = true;
error = null;
const result = await alarmsApi.getAll();
if (result.error) {
error = result.error;
} else if (result.data) {
alarms = result.data;
}
loading = false;
},
/**
* Create a new alarm
*/
async createAlarm(input: CreateAlarmInput) {
const result = await alarmsApi.create(input);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
alarms = [...alarms, result.data];
}
return { success: true };
},
/**
* Update an existing alarm
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
const result = await alarmsApi.update(id, input);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
}
return { success: true };
},
/**
* Delete an alarm
*/
async deleteAlarm(id: string) {
const result = await alarmsApi.delete(id);
if (result.error) {
return { success: false, error: result.error };
}
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
},
/**
* Toggle an alarm's enabled state
*/
async toggleAlarm(id: string) {
const result = await alarmsApi.toggle(id);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
}
return { success: true };
},
};

View file

@ -0,0 +1,185 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,8 @@
/**
* Navigation store for sidebar mode state
*/
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,238 @@
/**
* Pomodoro Store - Local pomodoro timer state using Svelte 5 runes
*/
import { browser } from '$app/environment';
import { DEFAULT_POMODORO_SETTINGS } from '@clock/shared';
export type PomodoroPhase = 'work' | 'break' | 'longBreak';
// Settings
let workDuration = $state(DEFAULT_POMODORO_SETTINGS.workDuration!);
let breakDuration = $state(DEFAULT_POMODORO_SETTINGS.breakDuration!);
let longBreakDuration = $state(DEFAULT_POMODORO_SETTINGS.longBreakDuration!);
let sessionsBeforeLongBreak = $state(DEFAULT_POMODORO_SETTINGS.sessionsBeforeLongBreak!);
// State
let phase = $state<PomodoroPhase>('work');
let isRunning = $state(false);
let remainingTime = $state(workDuration);
let completedSessions = $state(0);
let startTime = $state<number | null>(null);
let pausedTimeRemaining = $state(workDuration);
// Animation frame for updating time
let animationFrameId: number | null = null;
function updateTime() {
if (startTime !== null && isRunning) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
remainingTime = Math.max(0, pausedTimeRemaining - elapsed);
if (remainingTime <= 0) {
handlePhaseComplete();
} else {
animationFrameId = requestAnimationFrame(updateTime);
}
}
}
function handlePhaseComplete() {
isRunning = false;
startTime = null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
// Play notification sound
if (browser && 'Notification' in window && Notification.permission === 'granted') {
new Notification('Pomodoro', {
body:
phase === 'work'
? 'Arbeitszeit beendet! Zeit für eine Pause.'
: 'Pause beendet! Bereit für die nächste Sitzung?',
});
}
// Advance to next phase
if (phase === 'work') {
completedSessions++;
if (completedSessions % sessionsBeforeLongBreak === 0) {
phase = 'longBreak';
remainingTime = longBreakDuration;
pausedTimeRemaining = longBreakDuration;
} else {
phase = 'break';
remainingTime = breakDuration;
pausedTimeRemaining = breakDuration;
}
} else {
phase = 'work';
remainingTime = workDuration;
pausedTimeRemaining = workDuration;
}
}
export const pomodoroStore = {
// Getters
get phase() {
return phase;
},
get isRunning() {
return isRunning;
},
get remainingTime() {
return remainingTime;
},
get completedSessions() {
return completedSessions;
},
get sessionsBeforeLongBreak() {
return sessionsBeforeLongBreak;
},
get currentPhaseDuration() {
switch (phase) {
case 'work':
return workDuration;
case 'break':
return breakDuration;
case 'longBreak':
return longBreakDuration;
}
},
get progress() {
const total = this.currentPhaseDuration;
return ((total - remainingTime) / total) * 100;
},
get formattedTime() {
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
},
// Settings getters
get settings() {
return {
workDuration,
breakDuration,
longBreakDuration,
sessionsBeforeLongBreak,
};
},
/**
* Start the timer
*/
start() {
if (!isRunning) {
isRunning = true;
startTime = Date.now();
updateTime();
}
},
/**
* Pause the timer
*/
pause() {
if (isRunning) {
isRunning = false;
pausedTimeRemaining = remainingTime;
startTime = null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
},
/**
* Toggle start/pause
*/
toggle() {
if (isRunning) {
this.pause();
} else {
this.start();
}
},
/**
* Skip to next phase
*/
skip() {
this.pause();
handlePhaseComplete();
},
/**
* Reset the pomodoro timer
*/
reset() {
isRunning = false;
phase = 'work';
remainingTime = workDuration;
pausedTimeRemaining = workDuration;
completedSessions = 0;
startTime = null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
},
/**
* Update settings
*/
updateSettings(settings: {
workDuration?: number;
breakDuration?: number;
longBreakDuration?: number;
sessionsBeforeLongBreak?: number;
}) {
if (settings.workDuration !== undefined) {
workDuration = settings.workDuration;
}
if (settings.breakDuration !== undefined) {
breakDuration = settings.breakDuration;
}
if (settings.longBreakDuration !== undefined) {
longBreakDuration = settings.longBreakDuration;
}
if (settings.sessionsBeforeLongBreak !== undefined) {
sessionsBeforeLongBreak = settings.sessionsBeforeLongBreak;
}
// Reset to work phase with new duration if not running
if (!isRunning && phase === 'work') {
remainingTime = workDuration;
pausedTimeRemaining = workDuration;
}
},
/**
* Load preset
*/
loadPreset(preset: {
workDuration: number;
breakDuration: number;
longBreakDuration: number;
sessionsBeforeLongBreak: number;
}) {
this.pause();
this.updateSettings(preset);
this.reset();
},
/**
* Request notification permission
*/
async requestNotificationPermission() {
if (browser && 'Notification' in window) {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
},
};

View file

@ -0,0 +1,150 @@
/**
* Stopwatch Store - Local-only stopwatch state using Svelte 5 runes
*/
export interface Lap {
number: number;
time: number; // milliseconds
splitTime: number; // total time at lap
}
// State
let isRunning = $state(false);
let elapsedTime = $state(0); // milliseconds
let laps = $state<Lap[]>([]);
let startTime = $state<number | null>(null);
let pausedTime = $state(0);
// Animation frame for updating time
let animationFrameId: number | null = null;
function updateTime() {
if (startTime !== null && isRunning) {
elapsedTime = pausedTime + (Date.now() - startTime);
animationFrameId = requestAnimationFrame(updateTime);
}
}
export const stopwatchStore = {
// Getters
get isRunning() {
return isRunning;
},
get elapsedTime() {
return elapsedTime;
},
get laps() {
return laps;
},
get formattedTime() {
return formatTime(elapsedTime);
},
get bestLap() {
if (laps.length < 2) return null;
return laps.reduce((best, lap) => (lap.time < best.time ? lap : best));
},
get worstLap() {
if (laps.length < 2) return null;
return laps.reduce((worst, lap) => (lap.time > worst.time ? lap : worst));
},
/**
* Start the stopwatch
*/
start() {
if (!isRunning) {
isRunning = true;
startTime = Date.now();
updateTime();
}
},
/**
* Pause the stopwatch
*/
pause() {
if (isRunning) {
isRunning = false;
pausedTime = elapsedTime;
startTime = null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
},
/**
* Toggle start/pause
*/
toggle() {
if (isRunning) {
this.pause();
} else {
this.start();
}
},
/**
* Record a lap
*/
lap() {
if (elapsedTime > 0) {
const lastLapTime = laps.length > 0 ? laps[laps.length - 1].splitTime : 0;
const lapTime = elapsedTime - lastLapTime;
laps = [
...laps,
{
number: laps.length + 1,
time: lapTime,
splitTime: elapsedTime,
},
];
}
},
/**
* Reset the stopwatch
*/
reset() {
isRunning = false;
elapsedTime = 0;
laps = [];
startTime = null;
pausedTime = 0;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
},
};
/**
* Format milliseconds to HH:MM:SS.ms
*/
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.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
/**
* Format milliseconds to MM:SS.ms (short format for laps)
*/
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);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,117 @@
/**
* Theme store for Clock app
* Manages light/dark mode and theme variants
*/
import { browser } from '$app/environment';
import {
THEME_VARIANTS,
type ThemeVariant,
type ThemeMode,
THEME_DEFINITIONS,
} from '@manacore/shared-theme';
// Storage keys
const MODE_KEY = 'clock-theme-mode';
const VARIANT_KEY = 'clock-theme-variant';
// State
let mode = $state<ThemeMode>('system');
let variant = $state<ThemeVariant>('amber');
let isDark = $state(false);
// Get system preference
function getSystemPrefersDark(): boolean {
if (!browser) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Apply theme to document
function applyTheme() {
if (!browser) return;
// Determine if dark mode
const shouldBeDark = mode === 'system' ? getSystemPrefersDark() : mode === 'dark';
isDark = shouldBeDark;
// Apply to document
document.documentElement.classList.toggle('dark', shouldBeDark);
document.documentElement.setAttribute('data-theme', variant);
}
// Listen for system preference changes
if (browser) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (mode === 'system') {
applyTheme();
}
});
}
export const theme = {
// Getters
get mode() {
return mode;
},
get variant() {
return variant;
},
get isDark() {
return isDark;
},
get variants() {
return THEME_VARIANTS;
},
/**
* Initialize theme from localStorage
*/
initialize() {
if (!browser) return;
// Load saved preferences
const savedMode = localStorage.getItem(MODE_KEY) as ThemeMode | null;
const savedVariant = localStorage.getItem(VARIANT_KEY) as ThemeVariant | null;
if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
mode = savedMode;
}
if (savedVariant && THEME_VARIANTS.includes(savedVariant)) {
variant = savedVariant;
}
applyTheme();
},
/**
* Set theme mode
*/
setMode(newMode: ThemeMode) {
mode = newMode;
if (browser) {
localStorage.setItem(MODE_KEY, newMode);
}
applyTheme();
},
/**
* Toggle between light and dark
*/
toggleMode() {
const newMode = isDark ? 'light' : 'dark';
this.setMode(newMode);
},
/**
* Set theme variant
*/
setVariant(newVariant: ThemeVariant) {
if (!THEME_VARIANTS.includes(newVariant)) return;
variant = newVariant;
if (browser) {
localStorage.setItem(VARIANT_KEY, newVariant);
}
applyTheme();
},
};

View file

@ -0,0 +1,154 @@
/**
* Timers Store - Manages timer state using Svelte 5 runes
*/
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { timersApi } from '$lib/api/timers';
// State
let timers = $state<Timer[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const timersStore = {
// Getters
get timers() {
return timers;
},
get loading() {
return loading;
},
get error() {
return error;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
get runningTimers() {
return timers.filter((t) => t.status === 'running');
},
/**
* Fetch all timers from the API
*/
async fetchTimers() {
loading = true;
error = null;
const result = await timersApi.getAll();
if (result.error) {
error = result.error;
} else if (result.data) {
timers = result.data;
}
loading = false;
},
/**
* Create a new timer
*/
async createTimer(input: CreateTimerInput) {
const result = await timersApi.create(input);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
timers = [...timers, result.data];
}
return { success: true, data: result.data };
},
/**
* Update an existing timer
*/
async updateTimer(id: string, input: UpdateTimerInput) {
const result = await timersApi.update(id, input);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
timers = timers.map((t) => (t.id === id ? result.data! : t));
}
return { success: true };
},
/**
* Delete a timer
*/
async deleteTimer(id: string) {
const result = await timersApi.delete(id);
if (result.error) {
return { success: false, error: result.error };
}
timers = timers.filter((t) => t.id !== id);
return { success: true };
},
/**
* Start a timer
*/
async startTimer(id: string) {
const result = await timersApi.start(id);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
timers = timers.map((t) => (t.id === id ? result.data! : t));
}
return { success: true };
},
/**
* Pause a timer
*/
async pauseTimer(id: string) {
const result = await timersApi.pause(id);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
timers = timers.map((t) => (t.id === id ? result.data! : t));
}
return { success: true };
},
/**
* Reset a timer
*/
async resetTimer(id: string) {
const result = await timersApi.reset(id);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
timers = timers.map((t) => (t.id === id ? result.data! : t));
}
return { success: true };
},
/**
* Update local timer state (for countdown display)
*/
updateLocalTimer(id: string, remainingSeconds: number) {
timers = timers.map((t) => (t.id === id ? { ...t, remainingSeconds } : t));
},
};

View file

@ -0,0 +1,46 @@
/**
* Toast notification store
*/
import { writable } from 'svelte/store';
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info' | 'warning';
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
function addToast(message: string, type: Toast['type'] = 'info', duration = 5000) {
const id = crypto.randomUUID();
const toast: Toast = { id, message, type, duration };
update((toasts) => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
return id;
}
function removeToast(id: string) {
update((toasts) => toasts.filter((t) => t.id !== id));
}
return {
subscribe,
success: (message: string, duration?: number) => addToast(message, 'success', duration),
error: (message: string, duration?: number) => addToast(message, 'error', duration),
info: (message: string, duration?: number) => addToast(message, 'info', duration),
warning: (message: string, duration?: number) => addToast(message, 'warning', duration),
remove: removeToast,
};
}
export const toast = createToastStore();

View file

@ -0,0 +1,120 @@
/**
* World Clocks Store - Manages world clock state using Svelte 5 runes
*/
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
import { worldClocksApi } from '$lib/api/world-clocks';
// State
let worldClocks = $state<WorldClock[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const worldClocksStore = {
// Getters
get worldClocks() {
return worldClocks;
},
get loading() {
return loading;
},
get error() {
return error;
},
get sortedWorldClocks() {
return [...worldClocks].sort((a, b) => a.sortOrder - b.sortOrder);
},
/**
* Fetch all world clocks from the API
*/
async fetchWorldClocks() {
loading = true;
error = null;
const result = await worldClocksApi.getAll();
if (result.error) {
error = result.error;
} else if (result.data) {
worldClocks = result.data;
}
loading = false;
},
/**
* Add a new world clock
*/
async addWorldClock(input: CreateWorldClockInput) {
const result = await worldClocksApi.create(input);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
worldClocks = [...worldClocks, result.data];
}
return { success: true };
},
/**
* Remove a world clock
*/
async removeWorldClock(id: string) {
const result = await worldClocksApi.delete(id);
if (result.error) {
return { success: false, error: result.error };
}
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
},
/**
* Reorder world clocks
*/
async reorderWorldClocks(ids: string[]) {
const result = await worldClocksApi.reorder(ids);
if (result.error) {
return { success: false, error: result.error };
}
if (result.data) {
worldClocks = result.data;
}
return { success: true };
},
/**
* Get time info for a timezone
*/
getTimeForTimezone(timezone: string) {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('de-DE', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const localOffset = now.getTimezoneOffset();
const targetDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
const targetOffset = (now.getTime() - targetDate.getTime()) / (1000 * 60) + localOffset;
return {
time: formatter.format(now),
offsetHours: Math.round(-targetOffset / 60),
};
} catch {
return { time: '--:--:--', offsetHours: 0 };
}
},
};

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let success = $state(false);
let loading = $state(false);
async function handleResetPassword(email: string) {
loading = true;
error = '';
success = false;
const result = await authStore.resetPassword(email);
if (result.success) {
success = true;
} else {
error = result.error || 'Passwort-Zurücksetzung fehlgeschlagen';
}
loading = false;
}
</script>
<ForgotPasswordPage
appName="Clock"
appLogo=""
{loading}
{error}
{success}
onSubmit={handleResetPassword}
loginHref="/login"
/>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let loading = $state(false);
async function handleLogin(email: string, password: string) {
loading = true;
error = '';
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/');
} else {
error = result.error || 'Login fehlgeschlagen';
}
loading = false;
}
</script>
<LoginPage
appName="Clock"
appLogo=""
{loading}
{error}
onSubmit={handleLogin}
registerHref="/register"
forgotPasswordHref="/forgot-password"
/>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let loading = $state(false);
async function handleRegister(email: string, password: string) {
loading = true;
error = '';
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
// Show verification message or redirect to verification page
goto('/login?registered=true');
} else {
goto('/');
}
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
loading = false;
}
</script>
<RegisterPage
appName="Clock"
appLogo=""
{loading}
{error}
onSubmit={handleRegister}
loginHref="/login"
/>

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { page } from '$app/stores';
function handleGoHome() {
window.location.href = '/';
}
function handleGoBack() {
window.history.back();
}
</script>
<svelte:head>
<title>Error - Clock</title>
</svelte:head>
<div class="error-page">
<div class="error-container">
<div class="error-icon">
{#if $page.status === 404}
<svg
xmlns="http://www.w3.org/2000/svg"
width="80"
height="80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="80"
height="80"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
{/if}
</div>
<h1>{$page.status || 500}</h1>
{#if $page.status === 404}
<h2>Seite nicht gefunden</h2>
<p>Die Seite, die du suchst, existiert nicht oder wurde verschoben.</p>
{:else if $page.status === 500}
<h2>Serverfehler</h2>
<p>Es ist ein Fehler auf dem Server aufgetreten. Bitte versuche es später erneut.</p>
{:else}
<h2>Etwas ist schiefgelaufen</h2>
<p>{$page.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}</p>
{/if}
<div class="error-actions">
<button class="btn btn-primary" onclick={handleGoHome}> Zur Startseite </button>
<button class="btn btn-secondary" onclick={handleGoBack}> Zurück </button>
</div>
</div>
</div>
<style>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: hsl(var(--color-background));
}
.error-container {
max-width: 600px;
text-align: center;
}
.error-icon {
margin: 0 auto 2rem;
color: hsl(var(--color-muted-foreground));
opacity: 0.6;
}
h1 {
font-size: 6rem;
font-weight: 700;
color: hsl(var(--color-primary));
margin: 0 0 1rem 0;
line-height: 1;
}
h2 {
font-size: 1.75rem;
color: hsl(var(--color-foreground));
margin: 0 0 1rem 0;
}
p {
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0 2rem 0;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.error-page {
padding: 1.5rem;
}
h1 {
font-size: 4rem;
}
h2 {
font-size: 1.5rem;
}
p {
font-size: 1rem;
}
.error-actions {
flex-direction: column;
width: 100%;
}
.error-actions .btn {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,266 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import '../app.css';
// App switcher items
const appItems = getPillAppItems('clock');
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Check if current route is an auth route (no navigation needed)
let isAuthRoute = $derived(
$page.url.pathname.startsWith('/login') ||
$page.url.pathname.startsWith('/register') ||
$page.url.pathname.startsWith('/forgot-password')
);
// Navigation items for Clock
const navItems: PillNavItem[] = [
{ href: '/', label: 'Übersicht', icon: 'home' },
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
{ href: '/timers', label: 'Timer', icon: 'clock' },
{ href: '/stopwatch', label: 'Stoppuhr', icon: 'activity' },
{ href: '/pomodoro', label: 'Pomodoro', icon: 'target' },
{ href: '/world-clock', label: 'Weltzeituhr', icon: 'globe' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-8)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('clock-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('clock-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('clock-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<ToastContainer />
{#if isAuthRoute}
<!-- Auth routes: no navigation, just render content -->
{@render children()}
{:else if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Clock"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#f59e0b"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
transition: all 300ms ease;
position: relative;
z-index: 0;
}
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
// Current time state
let currentTime = $state(new Date());
let interval: ReturnType<typeof setInterval> | null = null;
// Derived time values
let hours = $derived(currentTime.getHours());
let minutes = $derived(currentTime.getMinutes());
let seconds = $derived(currentTime.getSeconds());
// Formatted time strings
let timeString = $derived(
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
);
let dateString = $derived(
currentTime.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
);
// Clock hand rotations
let secondRotation = $derived((seconds / 60) * 360);
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
onMount(() => {
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
});
onDestroy(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<div class="space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-3xl font-bold text-foreground">{$_('dashboard.title')}</h1>
<p class="mt-2 text-muted-foreground">{dateString}</p>
</div>
<!-- Main Clock Display -->
<div class="flex flex-col items-center gap-8 lg:flex-row lg:justify-center lg:gap-16">
<!-- Analog Clock -->
<div class="clock-face">
<!-- Hour markers -->
{#each Array(12) as _, i}
<div
class="clock-marker hour-marker"
style="transform: translateX(-50%) rotate({i * 30}deg) translateY(-130px)"
></div>
{/each}
<!-- Minute markers -->
{#each Array(60) as _, i}
{#if i % 5 !== 0}
<div
class="clock-marker minute-marker"
style="transform: translateX(-50%) rotate({i * 6}deg) translateY(-134px)"
></div>
{/if}
{/each}
<!-- Clock hands -->
<div
class="clock-hand hour"
style="transform: translateX(-50%) rotate({hourRotation}deg)"
></div>
<div
class="clock-hand minute"
style="transform: translateX(-50%) rotate({minuteRotation}deg)"
></div>
<div
class="clock-hand second"
style="transform: translateX(-50%) rotate({secondRotation}deg)"
></div>
<!-- Center dot -->
<div class="clock-center"></div>
</div>
<!-- Digital Clock -->
<div class="text-center">
<div class="digital-clock digital-clock-large text-foreground">
{timeString}
</div>
</div>
</div>
<!-- Quick Access Cards -->
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Next Alarm Card -->
<a href="/alarms" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<span class="text-xl">🔔</span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.nextAlarm')}</p>
<p class="font-medium text-foreground">Nicht eingestellt</p>
</div>
</div>
</a>
<!-- Active Timers Card -->
<a href="/timers" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<span class="text-xl"></span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.activeTimers')}</p>
<p class="font-medium text-foreground">0 aktiv</p>
</div>
</div>
</a>
<!-- Stopwatch Card -->
<a href="/stopwatch" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<span class="text-xl"></span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('nav.stopwatch')}</p>
<p class="font-medium text-foreground">Bereit</p>
</div>
</div>
</a>
<!-- World Clock Card -->
<a href="/world-clock" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500/10">
<span class="text-xl">🌍</span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.worldClocks')}</p>
<p class="font-medium text-foreground">0 Städte</p>
</div>
</div>
</a>
</div>
<!-- Pomodoro Quick Start -->
<div class="card mt-6">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div>
<h3 class="text-lg font-semibold text-foreground">{$_('pomodoro.title')}</h3>
<p class="text-sm text-muted-foreground">Starte eine fokussierte Arbeitssitzung</p>
</div>
<a href="/pomodoro" class="btn btn-primary btn-lg">
{$_('pomodoro.start')}
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,255 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import type { CreateAlarmInput } from '@clock/shared';
import { ALARM_SOUNDS } from '@clock/shared';
// Form state
let showForm = $state(false);
let editingId = $state<string | null>(null);
let formTime = $state('07:00');
let formLabel = $state('');
let formRepeatDays = $state<number[]>([]);
let formSound = $state('default');
let formSnoozeMinutes = $state(5);
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
onMount(async () => {
if (authStore.isAuthenticated) {
await alarmsStore.fetchAlarms();
}
});
function openNewForm() {
editingId = null;
formTime = '07:00';
formLabel = '';
formRepeatDays = [];
formSound = 'default';
formSnoozeMinutes = 5;
showForm = true;
}
function openEditForm(alarm: any) {
editingId = alarm.id;
formTime = alarm.time.slice(0, 5); // HH:MM
formLabel = alarm.label || '';
formRepeatDays = alarm.repeatDays || [];
formSound = alarm.sound || 'default';
formSnoozeMinutes = alarm.snoozeMinutes || 5;
showForm = true;
}
function closeForm() {
showForm = false;
editingId = null;
}
function toggleDay(day: number) {
if (formRepeatDays.includes(day)) {
formRepeatDays = formRepeatDays.filter((d) => d !== day);
} else {
formRepeatDays = [...formRepeatDays, day];
}
}
async function handleSubmit() {
const input: CreateAlarmInput = {
time: formTime + ':00',
label: formLabel || undefined,
repeatDays: formRepeatDays.length > 0 ? formRepeatDays : undefined,
sound: formSound,
snoozeMinutes: formSnoozeMinutes,
};
let result;
if (editingId) {
result = await alarmsStore.updateAlarm(editingId, input);
} else {
result = await alarmsStore.createAlarm(input);
}
if (result.success) {
toast.success(editingId ? 'Wecker aktualisiert' : 'Wecker erstellt');
closeForm();
} 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);
}
function getRepeatText(days: number[] | null) {
if (!days || days.length === 0) return 'Einmalig';
if (days.length === 7) return 'Täglich';
if (
days.length === 5 &&
days.includes(1) &&
days.includes(2) &&
days.includes(3) &&
days.includes(4) &&
days.includes(5)
)
return 'Wochentags';
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Am Wochenende';
return days.map((d) => dayNames[d]).join(', ');
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">{$_('alarm.title')}</h1>
<button class="btn btn-primary" onclick={openNewForm}>
+ {$_('alarm.add')}
</button>
</div>
<!-- Alarm List -->
{#if alarmsStore.loading}
<div class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
></div>
</div>
{:else if alarmsStore.alarms.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('alarm.noAlarms')}</p>
<button class="btn btn-primary mt-4" onclick={openNewForm}>
{$_('alarm.add')}
</button>
</div>
{:else}
<div class="space-y-3">
{#each alarmsStore.alarms as alarm (alarm.id)}
<div class="alarm-card" class:disabled={!alarm.enabled}>
<div class="flex items-center justify-between">
<div class="flex-1">
<button class="text-left w-full" onclick={() => openEditForm(alarm)}>
<div class="text-3xl font-light text-foreground">
{alarm.time.slice(0, 5)}
</div>
{#if alarm.label}
<p class="mt-1 text-sm font-medium text-foreground">{alarm.label}</p>
{/if}
<p class="mt-1 text-sm text-muted-foreground">
{getRepeatText(alarm.repeatDays)}
</p>
</button>
</div>
<div class="flex items-center gap-4">
<button
class="text-muted-foreground hover:text-error"
onclick={() => handleDelete(alarm.id)}
>
🗑
</button>
<button
class="toggle"
class:active={alarm.enabled}
onclick={() => handleToggle(alarm.id)}
></button>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Form Modal -->
{#if showForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md">
<h2 class="mb-4 text-xl font-semibold">
{editingId ? $_('alarm.edit') : $_('alarm.add')}
</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<!-- Time -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
<input type="time" class="input time-input" bind:value={formTime} />
</div>
<!-- Label -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.label')}</label>
<input
type="text"
class="input"
placeholder="Arbeit, Sport, etc."
bind:value={formLabel}
/>
</div>
<!-- Repeat Days -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('alarm.repeat')}</label>
<div class="day-selector">
{#each dayNames as day, i}
<button
type="button"
class:active={formRepeatDays.includes(i)}
onclick={() => toggleDay(i)}
>
{day}
</button>
{/each}
</div>
</div>
<!-- Sound -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
<select class="input" bind:value={formSound}>
{#each ALARM_SOUNDS as sound}
<option value={sound.id}>{sound.nameDE}</option>
{/each}
</select>
</div>
<!-- Snooze -->
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
<select class="input" bind:value={formSnoozeMinutes}>
<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>
<!-- Actions -->
<div class="flex gap-3">
<button type="button" class="btn btn-secondary flex-1" onclick={closeForm}>
{$_('common.cancel')}
</button>
<button type="submit" class="btn btn-primary flex-1">
{$_('common.save')}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { MANA_APPS, APP_URLS } from '@manacore/shared-branding';
</script>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-foreground">Alle Apps</h1>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each MANA_APPS.filter((app) => !app.comingSoon) as app}
<a
href={APP_URLS[app.id]?.dev || '#'}
class="card flex items-center gap-4 transition-all hover:border-primary/50"
style="border-left: 4px solid {app.color}"
>
<img src={app.icon} alt={app.name} class="h-12 w-12 rounded-lg" />
<div>
<h3 class="font-semibold">{app.name}</h3>
<p class="text-sm text-muted-foreground">{app.description.de}</p>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const feedbackService = createFeedbackService({
appName: 'clock',
apiUrl: 'http://localhost:3001', // Mana Core API
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {
const token = await authStore.getAccessToken();
return feedbackService.submit({
...data,
token: token || undefined,
});
}
</script>
<FeedbackPage appName="Clock" onSubmit={handleSubmit} userEmail={authStore.user?.email} />

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<SubscriptionPage user={authStore.user} appName="Clock" />

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { pomodoroStore } from '$lib/stores/pomodoro.svelte';
import { POMODORO_PRESETS } from '@clock/shared';
// SVG circle properties
const radius = 120;
const circumference = 2 * Math.PI * radius;
let strokeDashoffset = $derived(circumference - (pomodoroStore.progress / 100) * circumference);
let phaseLabel = $derived(
{
work: $_('pomodoro.work'),
break: $_('pomodoro.break'),
longBreak: $_('pomodoro.longBreak'),
}[pomodoroStore.phase]
);
let phaseColor = $derived(
{
work: 'hsl(var(--color-primary))',
break: 'hsl(var(--color-success))',
longBreak: 'hsl(var(--color-info))',
}[pomodoroStore.phase]
);
onMount(() => {
// Request notification permission
pomodoroStore.requestNotificationPermission();
});
function loadPreset(preset: (typeof POMODORO_PRESETS)[number]) {
pomodoroStore.loadPreset({
workDuration: preset.workDuration,
breakDuration: preset.breakDuration,
longBreakDuration: preset.longBreakDuration,
sessionsBeforeLongBreak: preset.sessionsBeforeLongBreak,
});
}
</script>
<div class="flex flex-col items-center space-y-8">
<!-- Header -->
<h1 class="text-2xl font-bold text-foreground">{$_('pomodoro.title')}</h1>
<!-- Phase indicator -->
<div class="text-center">
<span
class="inline-block rounded-full px-4 py-1 text-sm font-medium"
style="background-color: {phaseColor}; color: white;"
>
{phaseLabel}
</span>
</div>
<!-- Progress Ring -->
<div class="relative">
<svg width="280" height="280" class="-rotate-90">
<!-- Background circle -->
<circle
cx="140"
cy="140"
r={radius}
fill="none"
stroke="hsl(var(--color-muted))"
stroke-width="8"
/>
<!-- Progress circle -->
<circle
cx="140"
cy="140"
r={radius}
fill="none"
stroke={phaseColor}
stroke-width="8"
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={strokeDashoffset}
class="transition-all duration-1000 ease-linear"
/>
</svg>
<!-- Time display -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="digital-clock text-5xl font-light text-foreground">
{pomodoroStore.formattedTime}
</span>
<span class="mt-2 text-sm text-muted-foreground">
{$_('pomodoro.sessionsCompleted', {
values: {
count: pomodoroStore.completedSessions,
total: pomodoroStore.sessionsBeforeLongBreak,
},
})}
</span>
</div>
</div>
<!-- Controls -->
<div class="flex gap-4">
{#if pomodoroStore.isRunning}
<button class="btn btn-secondary btn-xl" onclick={() => pomodoroStore.pause()}>
{$_('pomodoro.pause')}
</button>
{:else}
<button class="btn btn-primary btn-xl" onclick={() => pomodoroStore.start()}>
{$_('pomodoro.start')}
</button>
{/if}
<button class="btn btn-ghost btn-xl" onclick={() => pomodoroStore.skip()}>
{$_('pomodoro.skip')}
</button>
<button class="btn btn-ghost btn-xl" onclick={() => pomodoroStore.reset()}>
{$_('pomodoro.reset')}
</button>
</div>
<!-- Sessions Progress -->
<div class="flex gap-2">
{#each Array(pomodoroStore.sessionsBeforeLongBreak) as _, i}
<div
class="h-3 w-3 rounded-full transition-colors"
class:bg-primary={i <
pomodoroStore.completedSessions % pomodoroStore.sessionsBeforeLongBreak}
class:bg-muted={i >=
pomodoroStore.completedSessions % pomodoroStore.sessionsBeforeLongBreak}
></div>
{/each}
</div>
<!-- Presets -->
<div class="card w-full max-w-md">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">{$_('timer.presets')}</h3>
<div class="grid gap-2 sm:grid-cols-3">
{#each POMODORO_PRESETS as preset}
<button class="btn btn-secondary btn-sm text-left" onclick={() => loadPreset(preset)}>
<div>
<div class="font-medium">{preset.nameDE}</div>
<div class="text-xs text-muted-foreground">
{preset.workDuration / 60}:{preset.breakDuration / 60} min
</div>
</div>
</button>
{/each}
</div>
</div>
<!-- Current Settings -->
<div class="card w-full max-w-md">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">Aktuelle Einstellungen</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-muted-foreground">{$_('pomodoro.settings.workDuration')}:</span>
<span class="ml-1 font-medium">{pomodoroStore.settings.workDuration / 60} min</span>
</div>
<div>
<span class="text-muted-foreground">{$_('pomodoro.settings.breakDuration')}:</span>
<span class="ml-1 font-medium">{pomodoroStore.settings.breakDuration / 60} min</span>
</div>
<div>
<span class="text-muted-foreground">{$_('pomodoro.settings.longBreakDuration')}:</span>
<span class="ml-1 font-medium">{pomodoroStore.settings.longBreakDuration / 60} min</span>
</div>
<div>
<span class="text-muted-foreground">Sitzungen:</span>
<span class="ml-1 font-medium">{pomodoroStore.settings.sessionsBeforeLongBreak}</span>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<ProfilePage user={authStore.user} appName="Clock" />

View file

@ -0,0 +1,163 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales } from '$lib/i18n';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
// Settings state
let clockFormat = $state<'24h' | '12h'>('24h');
// Load settings from localStorage
if (typeof localStorage !== 'undefined') {
const savedFormat = localStorage.getItem('clock-format');
if (savedFormat === '12h') {
clockFormat = '12h';
}
}
function setClockFormat(format: '24h' | '12h') {
clockFormat = format;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('clock-format', format);
}
}
const languageNames: Record<string, string> = {
de: 'Deutsch',
en: 'English',
fr: 'Français',
es: 'Español',
it: 'Italiano',
};
</script>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-foreground">{$_('settings.title')}</h1>
<!-- Appearance Section -->
<div class="card">
<h2 class="mb-4 text-lg font-semibold">{$_('settings.appearance')}</h2>
<!-- Theme Mode -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium">{$_('settings.darkMode')}</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
class:btn-primary={theme.mode === 'light'}
class:btn-secondary={theme.mode !== 'light'}
onclick={() => theme.setMode('light')}
>
☀️ Light
</button>
<button
class="btn btn-sm"
class:btn-primary={theme.mode === 'dark'}
class:btn-secondary={theme.mode !== 'dark'}
onclick={() => theme.setMode('dark')}
>
🌙 Dark
</button>
<button
class="btn btn-sm"
class:btn-primary={theme.mode === 'system'}
class:btn-secondary={theme.mode !== 'system'}
onclick={() => theme.setMode('system')}
>
💻 System
</button>
</div>
</div>
<!-- Theme Variant -->
<div>
<label class="mb-2 block text-sm font-medium">{$_('settings.theme')}</label>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
{#each theme.variants as variant}
<button
class="flex flex-col items-center gap-1 rounded-lg border-2 p-3 transition-colors"
class:border-primary={theme.variant === variant}
class:border-transparent={theme.variant !== variant}
onclick={() => theme.setVariant(variant)}
>
<span class="text-xl">{THEME_DEFINITIONS[variant].icon}</span>
<span class="text-xs">{THEME_DEFINITIONS[variant].label}</span>
</button>
{/each}
</div>
</div>
</div>
<!-- General Section -->
<div class="card">
<h2 class="mb-4 text-lg font-semibold">{$_('settings.general')}</h2>
<!-- Language -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium">{$_('settings.language')}</label>
<select
class="input"
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
>
{#each supportedLocales as locale}
<option value={locale}>{languageNames[locale]}</option>
{/each}
</select>
</div>
<!-- Clock Format -->
<div>
<label class="mb-2 block text-sm font-medium">{$_('settings.clockFormat')}</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
class:btn-primary={clockFormat === '24h'}
class:btn-secondary={clockFormat !== '24h'}
onclick={() => setClockFormat('24h')}
>
{$_('settings.format24h')}
</button>
<button
class="btn btn-sm"
class:btn-primary={clockFormat === '12h'}
class:btn-secondary={clockFormat !== '12h'}
onclick={() => setClockFormat('12h')}
>
{$_('settings.format12h')}
</button>
</div>
</div>
</div>
<!-- Notifications Section -->
<div class="card">
<h2 class="mb-4 text-lg font-semibold">{$_('settings.notifications')}</h2>
<p class="text-sm text-muted-foreground">
Benachrichtigungen werden für Wecker, Timer und Pomodoro-Sitzungen verwendet.
</p>
<button
class="btn btn-secondary mt-4"
onclick={async () => {
if ('Notification' in window) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
new Notification('Clock', {
body: 'Benachrichtigungen sind jetzt aktiviert!',
});
}
}
}}
>
Benachrichtigungen aktivieren
</button>
</div>
<!-- Sounds Section -->
<div class="card">
<h2 class="mb-4 text-lg font-semibold">{$_('settings.sounds')}</h2>
<p class="text-sm text-muted-foreground">
Töne können für einzelne Wecker und Timer in deren Einstellungen angepasst werden.
</p>
</div>
</div>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { stopwatchStore, formatTime, formatLapTime } from '$lib/stores/stopwatch.svelte';
</script>
<div class="flex flex-col items-center space-y-8">
<!-- Header -->
<h1 class="text-2xl font-bold text-foreground">{$_('stopwatch.title')}</h1>
<!-- Time Display -->
<div class="digital-clock text-6xl font-light text-foreground sm:text-7xl">
{stopwatchStore.formattedTime}
</div>
<!-- Controls -->
<div class="flex gap-4">
{#if stopwatchStore.isRunning}
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.pause()}>
{$_('stopwatch.stop')}
</button>
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.lap()}>
{$_('stopwatch.lap')}
</button>
{:else if stopwatchStore.elapsedTime > 0}
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
{$_('stopwatch.start')}
</button>
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.reset()}>
{$_('stopwatch.reset')}
</button>
{:else}
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
{$_('stopwatch.start')}
</button>
{/if}
</div>
<!-- Laps -->
{#if stopwatchStore.laps.length > 0}
<div class="card w-full max-w-md">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">
{$_('stopwatch.laps')} ({stopwatchStore.laps.length})
</h3>
<div class="max-h-64 overflow-y-auto">
{#each [...stopwatchStore.laps].reverse() as lap (lap.number)}
{@const isBest = stopwatchStore.bestLap?.number === lap.number}
{@const isWorst = stopwatchStore.worstLap?.number === lap.number}
<div class="lap-item" class:best={isBest} class:worst={isWorst}>
<span class="text-sm">
Runde {lap.number}
{#if isBest}
<span class="ml-1 text-xs">({$_('stopwatch.best')})</span>
{:else if isWorst}
<span class="ml-1 text-xs">({$_('stopwatch.worst')})</span>
{/if}
</span>
<span class="font-mono text-sm">
{formatLapTime(lap.time)}
</span>
</div>
{/each}
</div>
<div class="mt-3 flex justify-between border-t border-border pt-3">
<span class="text-sm font-medium">{$_('stopwatch.total')}</span>
<span class="font-mono text-sm font-medium">
{formatTime(stopwatchStore.elapsedTime)}
</span>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS, THEME_VARIANTS } from '@manacore/shared-theme';
</script>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-foreground">Alle Themes</h1>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each THEME_VARIANTS as variant}
{@const def = THEME_DEFINITIONS[variant]}
<button
class="card text-left transition-all hover:border-primary/50"
class:border-primary={theme.variant === variant}
onclick={() => theme.setVariant(variant)}
>
<div class="flex items-center gap-3">
<span class="text-3xl">{def.icon}</span>
<div>
<h3 class="font-semibold">{def.label}</h3>
<p class="text-sm text-muted-foreground">{def.description}</p>
</div>
</div>
{#if theme.variant === variant}
<div class="mt-3 text-sm text-primary">✓ Aktiv</div>
{/if}
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,270 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { timersStore } from '$lib/stores/timers.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
// Form state
let showForm = $state(false);
let formHours = $state(0);
let formMinutes = $state(5);
let formSeconds = $state(0);
let formLabel = $state('');
// Local countdown intervals
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
onMount(async () => {
if (authStore.isAuthenticated) {
await timersStore.fetchTimers();
}
});
onDestroy(() => {
// Clear all intervals
intervals.forEach((interval) => clearInterval(interval));
});
function startLocalCountdown(timerId: string, remainingSeconds: number) {
// Clear existing interval if any
if (intervals.has(timerId)) {
clearInterval(intervals.get(timerId));
}
const interval = setInterval(() => {
const timer = timersStore.timers.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);
timersStore.updateLocalTimer(timerId, newRemaining);
if (newRemaining === 0) {
clearInterval(interval);
intervals.delete(timerId);
toast.success($_('timer.finished'));
}
}, 1000);
intervals.set(timerId, interval);
}
function openForm() {
formHours = 0;
formMinutes = 5;
formSeconds = 0;
formLabel = '';
showForm = true;
}
function closeForm() {
showForm = false;
}
async function createTimer() {
const durationSeconds = formHours * 3600 + formMinutes * 60 + formSeconds;
if (durationSeconds <= 0) {
toast.error('Bitte eine gültige Zeit eingeben');
return;
}
const result = await timersStore.createTimer({
durationSeconds,
label: formLabel || undefined,
});
if (result.success) {
toast.success('Timer erstellt');
closeForm();
} else {
toast.error(result.error || 'Fehler beim Erstellen');
}
}
async function createQuickTimer(seconds: number) {
const result = await timersStore.createTimer({
durationSeconds: seconds,
});
if (result.success && result.data) {
await timersStore.startTimer(result.data.id);
startLocalCountdown(result.data.id, seconds);
}
}
async function handleStart(id: string) {
const result = await timersStore.startTimer(id);
if (result.success) {
const timer = timersStore.timers.find((t) => t.id === id);
if (timer) {
startLocalCountdown(id, timer.remainingSeconds || timer.durationSeconds);
}
}
}
async function handlePause(id: string) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
await timersStore.pauseTimer(id);
}
async function handleReset(id: string) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
await timersStore.resetTimer(id);
}
async function handleDelete(id: string) {
if (intervals.has(id)) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
const result = await timersStore.deleteTimer(id);
if (result.success) {
toast.success('Timer gelöscht');
}
}
function getTimerDisplay(timer: any) {
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
return formatDuration(remaining);
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">{$_('timer.title')}</h1>
<button class="btn btn-primary" onclick={openForm}>
+ {$_('timer.add')}
</button>
</div>
<!-- Quick Timer Presets -->
<div class="card">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">{$_('timer.presets')}</h3>
<div class="flex flex-wrap gap-2">
{#each QUICK_TIMER_PRESETS as preset}
<button class="btn btn-secondary btn-sm" onclick={() => createQuickTimer(preset.seconds)}>
{preset.label}
</button>
{/each}
</div>
</div>
<!-- Timer List -->
{#if timersStore.loading}
<div class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
></div>
</div>
{:else if timersStore.timers.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('timer.noTimers')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each timersStore.timers as timer (timer.id)}
<div class="card">
{#if timer.label}
<p class="mb-2 text-sm font-medium text-muted-foreground">{timer.label}</p>
{/if}
<div class="timer-display text-4xl font-light text-foreground">
{getTimerDisplay(timer)}
</div>
<!-- Progress bar -->
<div class="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
style="width: {((timer.remainingSeconds || timer.durationSeconds) /
timer.durationSeconds) *
100}%"
></div>
</div>
<!-- Controls -->
<div class="mt-4 flex gap-2">
{#if timer.status === 'running'}
<button class="btn btn-secondary flex-1" onclick={() => handlePause(timer.id)}>
{$_('timer.pause')}
</button>
{:else}
<button class="btn btn-primary flex-1" onclick={() => handleStart(timer.id)}>
{$_('timer.start')}
</button>
{/if}
<button class="btn btn-ghost" onclick={() => handleReset(timer.id)}> </button>
<button class="btn btn-ghost text-error" onclick={() => handleDelete(timer.id)}>
🗑
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Form Modal -->
{#if showForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md">
<h2 class="mb-4 text-xl font-semibold">{$_('timer.add')}</h2>
<form
onsubmit={(e) => {
e.preventDefault();
createTimer();
}}
>
<!-- Duration -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('timer.duration')}</label>
<div class="flex gap-2">
<div class="flex-1">
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.hours')}</label>
<input type="number" class="input" min="0" max="99" bind:value={formHours} />
</div>
<div class="flex-1">
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.minutes')}</label
>
<input type="number" class="input" min="0" max="59" bind:value={formMinutes} />
</div>
<div class="flex-1">
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.seconds')}</label
>
<input type="number" class="input" min="0" max="59" bind:value={formSeconds} />
</div>
</div>
</div>
<!-- Label -->
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('timer.label')}</label>
<input type="text" class="input" placeholder="Optional" bind:value={formLabel} />
</div>
<!-- Actions -->
<div class="flex gap-3">
<button type="button" class="btn btn-secondary flex-1" onclick={closeForm}>
{$_('common.cancel')}
</button>
<button type="submit" class="btn btn-primary flex-1">
{$_('common.add')}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,254 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { POPULAR_TIMEZONES } from '@clock/shared';
// State
let showAddModal = $state(false);
let searchQuery = $state('');
let currentTime = $state(new Date());
let interval: ReturnType<typeof setInterval> | null = null;
// Filtered timezones based on search
let filteredTimezones = $derived(
searchQuery
? POPULAR_TIMEZONES.filter(
(tz) =>
tz.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
tz.timezone.toLowerCase().includes(searchQuery.toLowerCase())
)
: POPULAR_TIMEZONES
);
onMount(async () => {
if (authStore.isAuthenticated) {
await worldClocksStore.fetchWorldClocks();
}
// Update time every second
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,
});
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 {
// Get local offset
const localOffset = currentTime.getTimezoneOffset();
// Get target timezone offset
const targetDate = new Date(currentTime.toLocaleString('en-US', { timeZone: timezone }));
const localDate = new Date(currentTime.toLocaleString('en-US', { timeZone: 'UTC' }));
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 $_('worldClock.same');
} else if (diffHours > 0) {
return `+${diffHours}h`;
} else {
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>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">{$_('worldClock.title')}</h1>
<button class="btn btn-primary" onclick={openAddModal}>
+ {$_('worldClock.add')}
</button>
</div>
<!-- World Clock List -->
{#if worldClocksStore.loading}
<div class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
></div>
</div>
{:else if worldClocksStore.sortedWorldClocks.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>
<button class="btn btn-primary mt-4" onclick={openAddModal}>
{$_('worldClock.add')}
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each worldClocksStore.sortedWorldClocks as clock (clock.id)}
{@const isDay = isDaytime(clock.timezone)}
<div class="world-clock-card relative">
<!-- Delete button -->
<button
class="absolute right-3 top-3 text-muted-foreground hover:text-error"
onclick={() => removeCity(clock.id)}
>
</button>
<!-- Day/Night indicator -->
<div class="mb-2 flex items-center gap-2">
<span class="text-xl">{isDay ? '☀️' : '🌙'}</span>
<span class="city-name">{clock.cityName}</span>
</div>
<!-- Time -->
<div class="time-display">
{getTimeForTimezone(clock.timezone)}
</div>
<!-- Date and offset -->
<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}
<!-- Add City Modal -->
{#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">{$_('worldClock.add')}</h2>
<button class="text-muted-foreground hover:text-foreground" onclick={closeAddModal}>
</button>
</div>
<!-- Search -->
<input
type="text"
class="input mb-4"
placeholder={$_('worldClock.search')}
bind:value={searchQuery}
/>
<!-- Timezone list -->
<div class="flex-1 overflow-y-auto -mx-4 px-4">
{#each filteredTimezones as tz}
{@const alreadyAdded = worldClocksStore.worldClocks.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>

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,45 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5186,
strictPort: true,
},
ssr: {
noExternal: [
'@clock/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
optimizeDeps: {
exclude: [
'@clock/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
});

23
apps/clock/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "clock",
"version": "1.0.0",
"private": true,
"description": "Clock App - World Clock, Alarms, Timers & Stylish Clock Widgets",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @clock/backend dev",
"dev:web": "pnpm --filter @clock/web dev",
"dev:landing": "pnpm --filter @clock/landing dev",
"dev:mobile": "pnpm --filter @clock/mobile dev",
"build": "turbo run build",
"lint": "turbo run lint",
"clean": "turbo run clean",
"db:push": "pnpm --filter @clock/backend db:push",
"db:studio": "pnpm --filter @clock/backend db:studio",
"db:seed": "pnpm --filter @clock/backend db:seed"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -0,0 +1,19 @@
{
"name": "@clock/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./constants": "./src/constants/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,77 @@
// Popular timezones with city names
export const POPULAR_TIMEZONES = [
{ timezone: 'America/New_York', city: 'New York', region: 'Americas' },
{ timezone: 'America/Los_Angeles', city: 'Los Angeles', region: 'Americas' },
{ timezone: 'America/Chicago', city: 'Chicago', region: 'Americas' },
{ timezone: 'America/Toronto', city: 'Toronto', region: 'Americas' },
{ timezone: 'America/Sao_Paulo', city: 'São Paulo', region: 'Americas' },
{ timezone: 'Europe/London', city: 'London', region: 'Europe' },
{ timezone: 'Europe/Paris', city: 'Paris', region: 'Europe' },
{ timezone: 'Europe/Berlin', city: 'Berlin', region: 'Europe' },
{ timezone: 'Europe/Rome', city: 'Rome', region: 'Europe' },
{ timezone: 'Europe/Madrid', city: 'Madrid', region: 'Europe' },
{ timezone: 'Europe/Amsterdam', city: 'Amsterdam', region: 'Europe' },
{ timezone: 'Europe/Vienna', city: 'Vienna', region: 'Europe' },
{ timezone: 'Europe/Zurich', city: 'Zurich', region: 'Europe' },
{ timezone: 'Europe/Moscow', city: 'Moscow', region: 'Europe' },
{ timezone: 'Asia/Tokyo', city: 'Tokyo', region: 'Asia' },
{ timezone: 'Asia/Shanghai', city: 'Shanghai', region: 'Asia' },
{ timezone: 'Asia/Hong_Kong', city: 'Hong Kong', region: 'Asia' },
{ timezone: 'Asia/Singapore', city: 'Singapore', region: 'Asia' },
{ timezone: 'Asia/Seoul', city: 'Seoul', region: 'Asia' },
{ timezone: 'Asia/Mumbai', city: 'Mumbai', region: 'Asia' },
{ timezone: 'Asia/Dubai', city: 'Dubai', region: 'Asia' },
{ timezone: 'Australia/Sydney', city: 'Sydney', region: 'Oceania' },
{ timezone: 'Australia/Melbourne', city: 'Melbourne', region: 'Oceania' },
{ timezone: 'Pacific/Auckland', city: 'Auckland', region: 'Oceania' },
] as const;
// Available alarm sounds
export const ALARM_SOUNDS = [
{ id: 'default', name: 'Default', nameDE: 'Standard' },
{ id: 'gentle', name: 'Gentle', nameDE: 'Sanft' },
{ id: 'classic', name: 'Classic', nameDE: 'Klassisch' },
{ id: 'digital', name: 'Digital', nameDE: 'Digital' },
{ id: 'nature', name: 'Nature', nameDE: 'Natur' },
{ id: 'chime', name: 'Chime', nameDE: 'Glockenspiel' },
] as const;
// Timer presets
export const QUICK_TIMER_PRESETS = [
{ label: '1 min', seconds: 60 },
{ label: '3 min', seconds: 180 },
{ label: '5 min', seconds: 300 },
{ label: '10 min', seconds: 600 },
{ label: '15 min', seconds: 900 },
{ label: '30 min', seconds: 1800 },
{ label: '45 min', seconds: 2700 },
{ label: '1 hour', seconds: 3600 },
] as const;
// Pomodoro presets
export const POMODORO_PRESETS = [
{
name: 'Classic Pomodoro',
nameDE: 'Klassischer Pomodoro',
workDuration: 25 * 60,
breakDuration: 5 * 60,
longBreakDuration: 15 * 60,
sessionsBeforeLongBreak: 4,
},
{
name: 'Short Focus',
nameDE: 'Kurzer Fokus',
workDuration: 15 * 60,
breakDuration: 3 * 60,
longBreakDuration: 10 * 60,
sessionsBeforeLongBreak: 4,
},
{
name: 'Deep Work',
nameDE: 'Tiefes Arbeiten',
workDuration: 50 * 60,
breakDuration: 10 * 60,
longBreakDuration: 30 * 60,
sessionsBeforeLongBreak: 3,
},
] as const;

View file

@ -0,0 +1,2 @@
export * from './types';
export * from './constants';

View file

@ -0,0 +1,55 @@
export interface Alarm {
id: string;
userId: string;
label: string | null;
time: string; // HH:MM:SS format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
createdAt: string;
updatedAt: string;
}
export interface CreateAlarmInput {
label?: string;
time: string;
enabled?: boolean;
repeatDays?: number[];
snoozeMinutes?: number;
sound?: string;
vibrate?: boolean;
}
export interface UpdateAlarmInput {
label?: string;
time?: string;
enabled?: boolean;
repeatDays?: number[];
snoozeMinutes?: number;
sound?: string;
vibrate?: boolean;
}
export type RepeatDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export const REPEAT_DAY_LABELS = {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
} as const;
export const REPEAT_DAY_LABELS_DE = {
0: 'So',
1: 'Mo',
2: 'Di',
3: 'Mi',
4: 'Do',
5: 'Fr',
6: 'Sa',
} as const;

View file

@ -0,0 +1,4 @@
export * from './alarm';
export * from './timer';
export * from './world-clock';
export * from './preset';

View file

@ -0,0 +1,42 @@
export type PresetType = 'timer' | 'pomodoro';
export interface PresetSettings {
// For pomodoro presets
workDuration?: number;
breakDuration?: number;
longBreakDuration?: number;
sessionsBeforeLongBreak?: number;
// For timer presets
sound?: string;
}
export interface Preset {
id: string;
userId: string;
type: PresetType;
name: string;
durationSeconds: number;
settings: PresetSettings | null;
createdAt: string;
}
export interface CreatePresetInput {
type: PresetType;
name: string;
durationSeconds: number;
settings?: PresetSettings;
}
export interface UpdatePresetInput {
name?: string;
durationSeconds?: number;
settings?: PresetSettings;
}
// Default pomodoro settings
export const DEFAULT_POMODORO_SETTINGS: PresetSettings = {
workDuration: 25 * 60, // 25 minutes
breakDuration: 5 * 60, // 5 minutes
longBreakDuration: 15 * 60, // 15 minutes
sessionsBeforeLongBreak: 4,
};

View file

@ -0,0 +1,49 @@
export type TimerStatus = 'idle' | 'running' | 'paused' | 'finished';
export interface Timer {
id: string;
userId: string;
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: TimerStatus;
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateTimerInput {
label?: string;
durationSeconds: number;
sound?: string;
}
export interface UpdateTimerInput {
label?: string;
durationSeconds?: number;
sound?: string;
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
export function parseDuration(formatted: string): number {
const parts = formatted.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return parts[0];
}

View file

@ -0,0 +1,18 @@
export interface WorldClock {
id: string;
userId: string;
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
createdAt: string;
}
export interface CreateWorldClockInput {
timezone: string;
cityName: string;
}
export interface TimezoneInfo {
timezone: string;
city: string;
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show more