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

@ -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"]
}