# TMC Test Task — Claude Code Prompt > Запусти этот промт из корня `A:\my-apps\tmc-test-task` --- ## Контекст и цель Ты создаёшь fullstack-приложение для тестового задания. В текущей директории создай две папки: `backend` и `frontend`. Начни с бэкенда, затем фронтенд. Работай итеративно — сначала scaffold, потом реализация по фичам. --- ## Структура репозитория ``` tmc-test-task/ ├── backend/ │ ├── src/ │ │ ├── index.ts │ │ ├── routes/ │ │ │ └── items.ts │ │ ├── services/ │ │ │ ├── itemsStore.ts ← in-memory хранилище │ │ │ └── queue.ts ← очередь с дедупликацией │ │ ├── middleware/ │ │ │ └── batcher.ts ← батчинг запросов │ │ └── swagger.ts │ ├── eslint.config.js │ ├── tsconfig.json │ └── package.json └── frontend/ ├── app/ │ ├── components/ │ │ ├── LeftPanel.vue │ │ └── RightPanel.vue │ ├── composables/ │ │ └── useItems.ts │ ├── services/ ← сюда генерится axios-клиент │ └── app.vue ├── eslint.config.js ├── nuxt.config.ts └── package.json ``` --- ## BACKEND ### Технологии - **Runtime**: Node.js + TypeScript - **Framework**: Express.js - **Package manager**: pnpm - **Dev runner**: `tsx watch` - **Build**: `tsc && tsc-alias` - **Lint**: `@antfu/eslint-config` - **Docs**: `swagger-jsdoc` + `swagger-ui-express` ### package.json (backend) ```json { "name": "tmc-backend", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc && tsc-alias", "start": "node dist/index.js", "lint": "eslint ." } } ``` Зависимости: `express`, `swagger-jsdoc`, `swagger-ui-express`, `cors` Dev: `tsx`, `tsc-alias`, `typescript`, `@types/node`, `@types/express`, `@types/swagger-jsdoc`, `@types/swagger-ui-express`, `@types/cors`, `eslint`, `@antfu/eslint-config` ### tsconfig.json (backend) ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"], "exclude": ["node_modules", "dist"] } ``` ### eslint.config.js (backend) ```js import antfu from '@antfu/eslint-config' export default antfu({ typescript: true, node: true, formatters: true, rules: { 'no-console': 'warn', 'node/prefer-global/process': 'off', }, }) ``` ### Бизнес-логика #### In-memory хранилище (`itemsStore.ts`) - При старте приложения инициализировать массив `allItems` с ID от 1 до 1 000 000 - `selectedIds: Set` — множество выбранных ID - `orderedSelected: number[]` — массив для сохранения порядка drag&drop - Методы: - `getItems(page, limit, search)` — возвращает НЕвыбранные элементы с пагинацией и фильтрацией по ID (строковый поиск по ID) - `getSelectedItems(page, limit, search)` — возвращает выбранные в порядке `orderedSelected` - `addItem(id)` — добавить новый элемент (id может быть любым числом) - `selectItem(id)` — переместить в правое окно - `deselectItem(id)` — вернуть в левое окно - `reorderSelected(ids: number[])` — сохранить новый порядок #### Очередь с дедупликацией (`queue.ts`) Реализовать `RequestQueue` класс: - Очередь принимает задачи типа: `'add' | 'select' | 'deselect' | 'reorder'` - **Дедупликация**: для `add`, `select`, `deselect` — ключ дедупликации это `${type}:${id}`. Если такая задача уже в очереди (не выполнена), повторно не добавлять - **Гарантия**: одно значение не будет добавлено повторно (проверять и в store) - Батчинг: - Задачи `add` — флашатся раз в **10 секунд** (или при накоплении 100 задач) - Задачи `select`, `deselect`, `reorder` — флашатся раз в **1 секунду** - При флаше — выполнять все задачи из батча атомарно #### Swagger (`swagger.ts`) Настроить `swagger-jsdoc` + `swagger-ui-express`: - Документация доступна по `GET /docs` - JSON-схема по `GET /docs/json` (нужен для генерации клиента во фронте) - Описать все модели и ответы ### API Routes Все роуты с JSDoc-комментариями для swagger. Базовый путь: `/api` #### `GET /api/items` Возвращает НЕвыбранные элементы (левое окно). Query params: - `page` (number, default: 1) - `limit` (number, default: 20, max: 20) - `search` (string, optional) — фильтр по ID (поиск подстроки в строке ID) Response: ```json { "data": [{ "id": 1 }, { "id": 2 }], "total": 999980, "page": 1, "limit": 20, "hasMore": true } ``` #### `GET /api/items/selected` Возвращает выбранные элементы (правое окно) в сохранённом порядке. Query params: - `page` (number, default: 1) - `limit` (number, default: 20, max: 20) - `search` (string, optional) Response: аналогично выше. #### `POST /api/items/add` Добавить элемент в очередь на добавление. Body: ```json { "id": 1000001 } ``` Response: ```json { "queued": true, "id": 1000001 } ``` Ошибки: 400 если `id` не передан или не число. #### `POST /api/items/select` Добавить элемент в очередь на выбор (переместить вправо). Body: ```json { "id": 42 } ``` Response: ```json { "queued": true } ``` #### `POST /api/items/deselect` Вернуть элемент в левое окно. Body: ```json { "id": 42 } ``` #### `PUT /api/items/reorder` Сохранить новый порядок выбранных элементов после drag&drop. Body: ```json { "ids": [5, 3, 1, 42] } ``` Response: ```json { "queued": true } ``` ### CORS и порт - Порт: `1337` - CORS: разрешить `http://localhost:3000` (фронт на nuxt) - Swagger UI: `GET /docs`, JSON: `GET /docs/json` --- ## FRONTEND ### Технологии - **Framework**: Nuxt 3 - **UI**: `@nuxt/ui` (последняя версия) - **Styling**: Tailwind CSS (через `@nuxt/ui`), SASS - **Package manager**: pnpm - **Lint**: `@antfu/eslint-config` + `vue-tsc` - **API клиент**: axios + автогенерация через `swagger-typescript-api` ### package.json (frontend) — scripts ```json { "scripts": { "dev": "nuxt dev", "build": "nuxt build", "preview": "nuxt preview", "lint": "eslint .", "typecheck": "vue-tsc --noEmit", "gen:api": "npx swagger-typescript-api generate --path http://localhost:1337/docs/json --output ./app/services --name api.ts --axios --unwrap-response-data" } } ``` Зависимости: `@nuxt/ui`, `axios`, `vue-draggable-next` (drag&drop) Dev: `eslint`, `@antfu/eslint-config`, `vue-tsc`, `typescript`, `sass`, `swagger-typescript-api` ### nuxt.config.ts ```ts export default defineNuxtConfig({ modules: ['@nuxt/ui'], compatibilityDate: '2024-11-01', devtools: { enabled: true }, css: ['~/assets/styles/main.scss'], runtimeConfig: { public: { apiBase: process.env.API_BASE ?? 'http://localhost:1337/api', }, }, }) ``` ### eslint.config.js (frontend) ```js import antfu from '@antfu/eslint-config' export default antfu({ typescript: true, vue: true, formatters: true, rules: { 'no-console': 'warn', }, }) ``` ### UI и функционал #### Главная страница (`app/app.vue` или `app/pages/index.vue`) Разметка: два равных контейнера в ряд (50/50 или flex/grid), высота — весь экран минус header. #### Левое окно (компонент `LeftPanel.vue`) - Заголовок: "All items" - Инпут поиска: фильтрация по ID (debounce 300ms) - Кнопка "Add item": открывает modal/popover с инпутом для ввода ID → POST /api/items/add - Список элементов: - Каждый элемент: `#ID` + кнопка "→" для выбора (POST /api/items/select) - Инфинити-скролл: IntersectionObserver на sentinel-элементе в конце списка, подгружать по 20 элементов - При поиске — сбрасывать страницу, начинать с 1 - `hasMore` из ответа API определяет, показывать ли sentinel #### Правое окно (компонент `RightPanel.vue`) - Заголовок: "Selected items" - Инпут поиска: фильтрация по ID (debounce 300ms) - Список с drag&drop (библиотека `vue-draggable-next`): - Drag&drop работает для видимого (в том числе отфильтрованного) списка - После drop: PUT /api/items/reorder с полным новым порядком - Каждый элемент: drag-handle иконка + `#ID` + кнопка "←" для возврата (POST /api/items/deselect) - Инфинити-скролл аналогично левому окну #### Composable (`composables/useItems.ts`) Создать composable `useItems()` с методами: - `fetchLeft(reset?)` — загрузить/дозагрузить левую панель - `fetchRight(reset?)` — загрузить/дозагрузить правую панель - `selectItem(id)` — выбрать элемент - `deselectItem(id)` — вернуть - `reorderSelected(ids)` — сохранить порядок - `addItem(id)` — добавить новый элемент - Реактивные состояния: `leftItems`, `rightItems`, `leftSearch`, `rightSearch`, `leftLoading`, `rightLoading` Использовать axios для запросов. После каждой мутации (select/deselect/add) — перезагрузить обе панели (reset + fetch). #### Сохранение состояния **На сервере** хранится выбор и порядок (in-memory). Фронт при монтировании делает `fetchLeft()` и `fetchRight()` — данные восстанавливаются из сервера. Поиск не сохраняется (сбрасывается при перезагрузке страницы — это правильное поведение). --- ## Порядок выполнения 1. Создать директории `backend/` и `frontend/` 2. **Backend**: 1. Инициализировать `pnpm init` + установить зависимости 2. Создать `tsconfig.json`, `eslint.config.js` 3. Реализовать `itemsStore.ts` 4. Реализовать `queue.ts` 5. Реализовать роуты в `routes/items.ts` 6. Настроить swagger в `swagger.ts` 7. Собрать всё в `index.ts` 8. Проверить `pnpm dev` — должен стартовать на порту 1337 3. **Frontend**: 1. `pnpm dlx nuxi@latest init frontend` (или вручную scaffold) 2. Установить зависимости 3. Создать `nuxt.config.ts`, `eslint.config.js` 4. Создать структуру папок 5. Реализовать composable `useItems.ts` 6. Реализовать `LeftPanel.vue` 7. Реализовать `RightPanel.vue` 8. Собрать главную страницу 9. Добавить скрипт `gen:api` и сгенерировать клиент (бэк должен быть запущен) --- ## Важные детали реализации - Инфинити-скролл: не забудь `disconnect()` observer при unmount - Дедупликация в очереди: если `POST /api/items/select` вызывается дважды для одного ID — второй запрос игнорируется до выполнения первого - При инициализации 1М элементов — не держать в памяти объекты, только числа (массив `number[]` весит ~8MB, приемлемо) - Типы из swagger: после `gen:api` в `app/services/api.ts` будет класс `Api` с axios. Использовать его в composable через `const api = new Api({ baseURL: config.public.apiBase })` - CORS: обязательно, иначе фронт не достучится до бэка - Батчинг 10 сек для `add` — при разработке можно уменьшить до 3 сек для теста, но в финальной версии должно быть 10 сек - `reorder` через очередь: drag&drop может вызываться часто, дедуплицировать по типу (последний вызов побеждает, предыдущие отменяются — реализовать как "debounced queue" для `reorder`) --- ## Что должно работать в итоге - `http://localhost:1337/docs` — Swagger UI с описанием всех эндпоинтов - `http://localhost:1337/docs/json` — OpenAPI JSON (для генерации клиента) - `http://localhost:3000` — фронт с двумя панелями - Левая панель: поиск + инфинити-скролл + добавление + перемещение вправо - Правая панель: поиск + инфинити-скролл + drag&drop + перемещение влево - Состояние сохраняется при перезагрузке страницы (данные на сервере) - Все запросы через очередь с дедупликацией и батчингом