mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:46:41 +02:00
✨ 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:
parent
110c6779a8
commit
2ef457ea23
104 changed files with 7517 additions and 2 deletions
12
apps/clock/apps/backend/drizzle.config.ts
Normal file
12
apps/clock/apps/backend/drizzle.config.ts
Normal 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,
|
||||
});
|
||||
10
apps/clock/apps/backend/nest-cli.json
Normal file
10
apps/clock/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
55
apps/clock/apps/backend/package.json
Normal file
55
apps/clock/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
apps/clock/apps/backend/src/alarm/alarm.controller.ts
Normal file
45
apps/clock/apps/backend/src/alarm/alarm.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/alarm/alarm.module.ts
Normal file
10
apps/clock/apps/backend/src/alarm/alarm.module.ts
Normal 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 {}
|
||||
82
apps/clock/apps/backend/src/alarm/alarm.service.ts
Normal file
82
apps/clock/apps/backend/src/alarm/alarm.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
85
apps/clock/apps/backend/src/alarm/dto/index.ts
Normal file
85
apps/clock/apps/backend/src/alarm/dto/index.ts
Normal 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;
|
||||
}
|
||||
26
apps/clock/apps/backend/src/app.module.ts
Normal file
26
apps/clock/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
38
apps/clock/apps/backend/src/db/connection.ts
Normal file
38
apps/clock/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
apps/clock/apps/backend/src/db/database.module.ts
Normal file
28
apps/clock/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
18
apps/clock/apps/backend/src/db/schema/alarms.schema.ts
Normal file
18
apps/clock/apps/backend/src/db/schema/alarms.schema.ts
Normal 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;
|
||||
4
apps/clock/apps/backend/src/db/schema/index.ts
Normal file
4
apps/clock/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './alarms.schema';
|
||||
export * from './timers.schema';
|
||||
export * from './world-clocks.schema';
|
||||
export * from './presets.schema';
|
||||
24
apps/clock/apps/backend/src/db/schema/presets.schema.ts
Normal file
24
apps/clock/apps/backend/src/db/schema/presets.schema.ts
Normal 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;
|
||||
18
apps/clock/apps/backend/src/db/schema/timers.schema.ts
Normal file
18
apps/clock/apps/backend/src/db/schema/timers.schema.ts
Normal 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;
|
||||
13
apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts
Normal file
13
apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts
Normal 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;
|
||||
13
apps/clock/apps/backend/src/health/health.controller.ts
Normal file
13
apps/clock/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/clock/apps/backend/src/health/health.module.ts
Normal file
7
apps/clock/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/clock/apps/backend/src/main.ts
Normal file
40
apps/clock/apps/backend/src/main.ts
Normal 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();
|
||||
65
apps/clock/apps/backend/src/preset/dto/index.ts
Normal file
65
apps/clock/apps/backend/src/preset/dto/index.ts
Normal 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;
|
||||
}
|
||||
40
apps/clock/apps/backend/src/preset/preset.controller.ts
Normal file
40
apps/clock/apps/backend/src/preset/preset.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/preset/preset.module.ts
Normal file
10
apps/clock/apps/backend/src/preset/preset.module.ts
Normal 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 {}
|
||||
65
apps/clock/apps/backend/src/preset/preset.service.ts
Normal file
65
apps/clock/apps/backend/src/preset/preset.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
32
apps/clock/apps/backend/src/timer/dto/index.ts
Normal file
32
apps/clock/apps/backend/src/timer/dto/index.ts
Normal 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;
|
||||
}
|
||||
55
apps/clock/apps/backend/src/timer/timer.controller.ts
Normal file
55
apps/clock/apps/backend/src/timer/timer.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/timer/timer.module.ts
Normal file
10
apps/clock/apps/backend/src/timer/timer.module.ts
Normal 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 {}
|
||||
129
apps/clock/apps/backend/src/timer/timer.service.ts
Normal file
129
apps/clock/apps/backend/src/timer/timer.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
15
apps/clock/apps/backend/src/world-clock/dto/index.ts
Normal file
15
apps/clock/apps/backend/src/world-clock/dto/index.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
122
apps/clock/apps/backend/src/world-clock/world-clock.service.ts
Normal file
122
apps/clock/apps/backend/src/world-clock/world-clock.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
apps/clock/apps/backend/tsconfig.json
Normal file
25
apps/clock/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue