feat: Добавлена базовая структура приложения Home Service с бэкендом на NestJS и фронтендом на Next.js для управления событиями.

This commit is contained in:
2025-12-06 10:50:50 +03:00
parent 850c9d2a9e
commit 07c1285bb9
50 changed files with 22542 additions and 0 deletions

98
backend/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

107
backend/biome.json Normal file
View File

@@ -0,0 +1,107 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"node_modules",
"dist",
"coverage",
"migrations"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExtraBooleanCast": "error",
"noMultipleSpacesInRegularExpressionLiterals": "error",
"noUselessCatch": "error",
"noUselessTypeConstraint": "error",
"noWith": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidConstructorSuper": "error",
"noInvalidNewBuiltin": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedVariables": "warn",
"useIsNan": "error",
"useValidForDirection": "error",
"useYield": "error"
},
"style": {
"noArguments": "error",
"noVar": "error",
"useConst": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDoubleEquals": "warn",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "warn",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noUnsafeNegation": "error",
"useGetterReturn": "error",
"useValidTypeof": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteProperties": "asNeeded"
}
},
"organizeImports": {
"enabled": true
}
}

11
backend/drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
// PGLite will be initialized in code
url: process.env.DATABASE_URL || 'postgresql://localhost/events',
},
});

View File

@@ -0,0 +1,17 @@
CREATE TABLE "events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"emoji" text NOT NULL,
"month" integer NOT NULL,
"day" integer NOT NULL,
"start_year" integer,
"end_month" integer,
"end_day" integer,
"end_year" integer,
"description" text,
"color" text,
"is_active" boolean DEFAULT true NOT NULL,
"tags" text[],
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,126 @@
{
"id": "852b1a6a-31c7-4f44-bdef-a378a18e2c7d",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true
},
"month": {
"name": "month",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"day": {
"name": "day",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"start_year": {
"name": "start_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_month": {
"name": "end_month",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_day": {
"name": "end_day",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"end_year": {
"name": "end_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764421778123,
"tag": "0000_lyrical_gabe_jones",
"breakpoints": true
}
]
}

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

73
backend/package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "@home-service/backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "biome format --write ./src ./test",
"lint": "biome lint --write ./src ./test",
"check": "biome check --write ./src ./test",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@electric-sql/pglite": "^0.3.14",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.1.13"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.19.1",
"@types/supertest": "^6.0.2",
"drizzle-kit": "^0.31.7",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

6433
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

15
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { EventsModule } from './events/events.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
DatabaseModule,
EventsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,61 @@
import { Module, Global } from '@nestjs/common';
import { PGlite } from '@electric-sql/pglite';
import { drizzle, PgliteDatabase } from 'drizzle-orm/pglite';
import * as schema from './schema';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { sql } from 'drizzle-orm';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
const databaseProvider = {
provide: DATABASE_CONNECTION,
useFactory: async (): Promise<PgliteDatabase<typeof schema>> => {
const dbPath = process.env.DATABASE_PATH || './data/events.db';
// Убедимся, что директория существует
const dbDir = dbPath.substring(0, dbPath.lastIndexOf('/'));
if (dbDir && !existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
const client = new PGlite(dbPath);
const db = drizzle(client, { schema });
// Создаем таблицу напрямую вместо использования миграций
try {
await client.exec(`
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
emoji TEXT NOT NULL,
month INTEGER NOT NULL,
day INTEGER NOT NULL,
start_year INTEGER,
end_month INTEGER,
end_day INTEGER,
end_year INTEGER,
description TEXT,
color TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
tags TEXT[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
`);
console.log('✅ Database initialized successfully');
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
return db;
},
};
@Global()
@Module({
providers: [databaseProvider],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,39 @@
import { pgTable, text, integer, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const events = pgTable('events', {
id: uuid('id')
.primaryKey()
.default(sql`gen_random_uuid()`),
title: text('title').notNull(),
emoji: text('emoji').notNull(),
// Дата начала события (обязательно для всех типов)
month: integer('month').notNull(), // 1-12
day: integer('day').notNull(), // 1-31
// Год начала (опционально)
startYear: integer('start_year'),
// Дата окончания (опционально, для продолжительных событий)
endMonth: integer('end_month'), // 1-12
endDay: integer('end_day'), // 1-31
endYear: integer('end_year'),
// Дополнительные поля
description: text('description'),
color: text('color'), // hex формат
isActive: boolean('is_active').notNull().default(true),
tags: text('tags').array(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export type Event = typeof events.$inferSelect;
export type NewEvent = typeof events.$inferInsert;

View File

@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UsePipes,
} from '@nestjs/common';
import { EventsService } from './events.service';
import type { CreateEventDto } from './schemas/event.schema';
import { createEventSchema } from './schemas/event.schema';
import { ZodValidationPipe } from '../pipes/zod-validation.pipe';
@Controller()
export class EventsController {
constructor(private readonly eventsService: EventsService) {}
/**
* Главный endpoint для виджета Glance
*/
@Get('countdown')
async getCountdown() {
return this.eventsService.getCountdownData();
}
/**
* Получить все события
*/
@Get('api/events')
async findAll() {
return this.eventsService.findAll();
}
/**
* Получить одно событие
*/
@Get('api/events/:id')
async findOne(@Param('id') id: string) {
return this.eventsService.findOne(id);
}
/**
* Создать событие
*/
@Post('api/events')
@UsePipes(new ZodValidationPipe(createEventSchema))
async create(@Body() createEventDto: CreateEventDto) {
return this.eventsService.create(createEventDto);
}
/**
* Удалить событие
*/
@Delete('api/events/:id')
async remove(@Param('id') id: string) {
await this.eventsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
@Module({
controllers: [EventsController],
providers: [EventsService],
exports: [EventsService],
})
export class EventsModule {}

View File

@@ -0,0 +1,200 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { PgliteDatabase } from 'drizzle-orm/pglite';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../database/database.module';
import { events, Event } from '../database/schema';
import { CreateEventDto } from './schemas/event.schema';
import {
getEventType,
getNextOccurrence,
calculateTimeDiff,
calculateAnniversary,
calculateDuration,
} from '../utils/datetime.utils';
import { addMonths, getDaysInMonth } from 'date-fns';
@Injectable()
export class EventsService {
constructor(
@Inject(DATABASE_CONNECTION)
private db: PgliteDatabase<typeof import('../database/schema')>,
) {}
async findAll(): Promise<Event[]> {
return this.db.select().from(events).where(eq(events.isActive, true));
}
async findOne(id: string): Promise<Event> {
const [event] = await this.db
.select()
.from(events)
.where(eq(events.id, id));
if (!event) {
throw new NotFoundException(`Event with ID ${id} not found`);
}
return event;
}
async create(createEventDto: CreateEventDto): Promise<Event> {
const [event] = await this.db
.insert(events)
.values(createEventDto)
.returning();
return event;
}
async update(id: string, updateEventDto: Partial<CreateEventDto>): Promise<Event> {
const [event] = await this.db
.update(events)
.set(updateEventDto)
.where(eq(events.id, id))
.returning();
if (!event) {
throw new NotFoundException(`Event with ID ${id} not found`);
}
return event;
}
async remove(id: string): Promise<void> {
await this.db.delete(events).where(eq(events.id, id));
// PGLite doesn't return rowCount, just assume it worked
}
/**
* Получить все события с расчетами для виджета countdown
*/
async getCountdownData() {
const now = new Date();
const allEvents = await this.findAll();
const result = [];
for (const event of allEvents) {
const type = getEventType(event);
const processedEvent: any = {
id: event.id,
type,
emoji: event.emoji,
name: event.title,
description: event.description,
color: event.color,
tags: event.tags,
};
if (type === 'recurring') {
// Обычное повторяющееся событие
const target = getNextOccurrence(now, event.month, event.day);
const { display, days, hours, minutes } = calculateTimeDiff(target, now);
processedEvent.date = target.toISOString().split('T')[0];
processedEvent.display = display;
processedEvent.days = days;
processedEvent.hours = hours;
processedEvent.minutes = minutes;
} else if (type === 'anniversary') {
// Годовщина
const { years, days } = calculateAnniversary(
event.startYear!,
event.month,
event.day,
now,
);
const nextTarget = getNextOccurrence(now, event.month, event.day);
const { display, days: nextDays, hours, minutes } = calculateTimeDiff(nextTarget, now);
processedEvent.date = nextTarget.toISOString().split('T')[0];
processedEvent.startYear = event.startYear;
processedEvent.yearsPassed = years;
processedEvent.daysPassed = days;
processedEvent.display = display;
processedEvent.days = nextDays;
processedEvent.hours = hours;
processedEvent.minutes = minutes;
} else if (type === 'duration') {
// Продолжительное событие
const durationInfo = calculateDuration(
event.month,
event.day,
event.startYear!,
event.endMonth!,
event.endDay!,
event.endYear!,
now,
);
processedEvent.startDate = new Date(event.startYear!, event.month - 1, event.day)
.toISOString()
.split('T')[0];
processedEvent.endDate = new Date(event.endYear!, event.endMonth! - 1, event.endDay!)
.toISOString()
.split('T')[0];
processedEvent.isActive = durationInfo.isActive;
processedEvent.daysUntilStart = durationInfo.daysUntilStart;
processedEvent.daysRemaining = durationInfo.daysRemaining;
processedEvent.totalDays = durationInfo.totalDays;
}
result.push(processedEvent);
}
// Генерируем метки месяцев (как в Python версии)
const months = [];
let daysAccumulated = getDaysInMonth(now) - now.getDate();
for (let i = 0; i < 10; i++) {
const targetDate = addMonths(now, i);
const monthName = new Intl.DateTimeFormat('ru', { month: 'short' }).format(targetDate);
months.push({
name: monthName,
days: daysAccumulated,
});
daysAccumulated += getDaysInMonth(targetDate);
}
// Timeline grid (как в Python версии)
const timelineGrid = [];
// Зона 1: 14 дней
for (let i = 0; i < 14; i++) {
const targetDate = new Date(now);
targetDate.setDate(now.getDate() + i);
timelineGrid.push({
days: i,
date: targetDate.toLocaleDateString('ru-RU'),
weekday: new Intl.DateTimeFormat('ru', { weekday: 'short' }).format(targetDate),
});
}
// Зона 2: 40 недель
for (let i = 1; i <= 40; i++) {
const daysAhead = 14 + i * 7;
const targetDate = new Date(now);
targetDate.setDate(now.getDate() + daysAhead);
timelineGrid.push({
days: daysAhead,
date: targetDate.toLocaleDateString('ru-RU'),
weekday: new Intl.DateTimeFormat('ru', { weekday: 'short' }).format(targetDate),
});
}
return {
events: result,
current: {
year: now.getFullYear(),
month: now.getMonth() + 1,
day: now.getDate(),
},
months,
timelineGrid,
};
}
}

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
// Базовая схема события
export const createEventSchema = z.object({
title: z.string().min(1, 'Название обязательно'),
emoji: z.string().min(1, 'Эмодзи обязательно'),
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
startYear: z.number().int().min(1900).max(2100).optional().nullable(),
endMonth: z.number().int().min(1).max(12).optional().nullable(),
endDay: z.number().int().min(1).max(31).optional().nullable(),
endYear: z.number().int().min(1900).max(2100).optional().nullable(),
description: z.string().optional().nullable(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
isActive: z.boolean().optional().default(true),
tags: z.array(z.string()).optional().nullable(),
});
export const updateEventSchema = createEventSchema.partial();
export const eventResponseSchema = z.object({
id: z.string().uuid(),
type: z.enum(['recurring', 'anniversary', 'duration']),
title: z.string(),
emoji: z.string(),
month: z.number(),
day: z.number(),
startYear: z.number().nullable(),
endMonth: z.number().nullable(),
endDay: z.number().nullable(),
endYear: z.number().nullable(),
description: z.string().nullable(),
color: z.string().nullable(),
isActive: z.boolean(),
tags: z.array(z.string()).nullable(),
createdAt: z.date(),
updatedAt: z.date(),
// Вычисленные поля
date: z.string().optional(),
display: z.string().optional(),
days: z.number().optional(),
hours: z.number().optional(),
minutes: z.number().optional(),
yearsПassed: z.number().optional(),
daysPassed: z.number().optional(),
});
export type CreateEventDto = z.infer<typeof createEventSchema>;
export type UpdateEventDto = z.infer<typeof updateEventSchema>;
export type EventResponseDto = z.infer<typeof eventResponseSchema>;

20
backend/src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3001',
credentials: true,
});
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}`);
console.log(`📊 Countdown endpoint: http://localhost:${port}/countdown`);
}
bootstrap();

View File

@@ -0,0 +1,18 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import type { ZodSchema } from 'zod';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
try {
const parsedValue = this.schema.parse(value);
return parsedValue;
} catch (error) {
throw new BadRequestException('Validation failed', {
cause: error,
});
}
}
}

View File

@@ -0,0 +1,197 @@
import {
addYears,
differenceInDays,
differenceInHours,
differenceInMinutes,
differenceInSeconds,
isAfter,
isBefore,
setMonth,
setDate,
startOfDay,
} from 'date-fns';
export type EventType = 'recurring' | 'anniversary' | 'duration';
/**
* Определяет тип события по заполненным полям
*/
export function getEventType(event: {
startYear?: number | null;
endYear?: number | null;
endMonth?: number | null;
endDay?: number | null;
}): EventType {
const hasEndDate =
event.endYear != null &&
event.endMonth != null &&
event.endDay != null;
if (hasEndDate) {
return 'duration';
}
if (event.startYear != null) {
return 'anniversary';
}
return 'recurring';
}
/**
* Рассчитывает следующее наступление ежегодного события
*/
export function getNextOccurrence(
now: Date,
month: number,
day: number,
): Date {
const currentYear = now.getFullYear();
let target = new Date(currentYear, month - 1, day); // month is 1-indexed
// Если дата уже прошла в этом году, используем следующий год
if (isBefore(target, now)) {
target = addYears(target, 1);
}
return startOfDay(target);
}
/**
* Расч итывает разницу во времени между двумя датами
*/
export function calculateTimeDiff(target: Date, now: Date): {
display: string;
days: number;
hours: number;
minutes: number;
} {
const diff = differenceInSeconds(target, now);
if (diff > 0) {
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
return {
display: formatTimeRemaining(days, hours, minutes),
days,
hours,
minutes,
};
}
return {
display: '✅ Свершилось!',
days: 0,
hours: 0,
minutes: 0,
};
}
/**
* Форматирует оставшееся время в читаемую строку
*/
export function formatTimeRemaining(
days: number,
hours: number,
minutes: number,
): string {
return `${days}д ${hours}ч ${minutes}м`;
}
/**
* Рассчитывает годовщину
*/
export function calculateAnniversary(
startYear: number,
month: number,
day: number,
now: Date,
): {
years: number;
days: number;
} {
// Получаем следующую дату годовщины
const nextOccurrence = getNextOccurrence(now, month, day);
const years = nextOccurrence.getFullYear() - startYear;
// Дополнительные дни от прошлой годовщины
const startDate = new Date(startYear, month - 1, day);
const diffInDays = differenceInDays(now, startDate);
const fullYears = Math.floor(diffInDays / 365.25);
const remainingDays = diffInDays - Math.floor(fullYears * 365.25);
return {
years,
days: remainingDays,
};
}
/**
* Рассчитывает информацию о продолжительном событии
*/
export function calculateDuration(
startMonth: number,
startDay: number,
startYear: number,
endMonth: number,
endDay: number,
endYear: number,
now: Date,
): {
isActive: boolean;
daysUntilStart?: number;
daysUntilEnd?: number;
daysRemaining?: number;
totalDays: number;
} {
const startDate = new Date(startYear, startMonth - 1, startDay);
const endDate = new Date(endYear, endMonth - 1, endDay);
const totalDays = differenceInDays(endDate, startDate);
const isActive = isAfter(now, startDate) && isBefore(now, endDate);
if (isBefore(now, startDate)) {
// Событие еще не началось
return {
isActive: false,
daysUntilStart: differenceInDays(startDate, now),
totalDays,
};
}
if (isAfter(now, endDate)) {
// Событие уже закончилось
return {
isActive: false,
totalDays,
};
}
// Событие активно
return {
isActive: true,
daysRemaining: differenceInDays(endDate, now),
daysUntilEnd: differenceInDays(endDate, now),
totalDays,
};
}
/**
* Проверяет, идет ли событие прямо сейчас
*/
export function isEventActive(
startMonth: number,
startDay: number,
startYear: number,
endMonth: number,
endDay: number,
endYear: number,
now: Date,
): boolean {
const startDate = new Date(startYear, startMonth - 1, startDay);
const endDate = new Date(endYear, endMonth - 1, endDay);
return isAfter(now, startDate) && isBefore(now, endDate);
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}