diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..e471450 --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,55 @@ +/** + * Проверка направления зависимостей по слоям FSD (core.md §2, §5). + * Цепочка: app → features → entities → shared. Импорт только вправо. + * Между slice'ами одного слоя прямые зависимости запрещены. + */ +module.exports = { + forbidden: [ + { + name: "shared-не-импортит-выше", + comment: + "shared — крайний правый слой, не зависит от app/features/entities (core.md §2)", + severity: "error", + from: { path: "^src/shared" }, + to: { path: "^(app|src/(features|entities))" }, + }, + { + name: "entities-не-импортит-выше", + comment: "entities зависит только от shared (core.md §2)", + severity: "error", + from: { path: "^src/entities" }, + to: { path: "^(app|src/features)" }, + }, + { + name: "features-не-импортит-app", + comment: "features зависит от entities/shared, но не от app (core.md §2)", + severity: "error", + from: { path: "^src/features" }, + to: { path: "^app" }, + }, + { + name: "entities-без-кросс-slice", + comment: + "slice в entities не импортит соседний slice — склейка слоем выше (core.md §5)", + severity: "error", + from: { path: "^src/entities/([^/]+)/" }, + to: { path: "^src/entities/([^/]+)/", pathNot: "^src/entities/$1/" }, + }, + { + name: "features-без-кросс-slice", + comment: + "slice в features не импортит соседний slice — склейка слоем выше (core.md §5)", + severity: "error", + from: { path: "^src/features/([^/]+)/" }, + to: { path: "^src/features/([^/]+)/", pathNot: "^src/features/$1/" }, + }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsConfig: { fileName: "tsconfig.json" }, + tsPreCompilationDeps: true, + enhancedResolveOptions: { + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], + }, + }, +}; diff --git a/.env.example b/.env.example index c900474..efed8b1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_KEY= + +# COPY INTO .env.local diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..7ca05f4 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,28 @@ +name: quality + +on: + pull_request: + push: + branches: + - main + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Забрать код + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Настроить Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + cache: npm + + - name: Установить зависимости + run: npm ci + + - name: Проверить Biome и TypeScript + run: npm run ci diff --git a/.gitignore b/.gitignore index d914c32..f529c99 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.* # generated native folders /ios /android + +# Obsidian vault +/venty/ +.obsidian/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..000043e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +npx biome check --write --staged --files-ignore-unknown=true +git update-index --again +bash scripts/check-conventions.sh diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..320512b --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm run ci diff --git a/AGENTS.md b/AGENTS.md index a26b4bb..435416e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,7 @@ -# Expo HAS CHANGED +# Expo ИЗМЕНИЛСЯ -Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code. +Перед написанием кода читай точную документацию по версии: https://docs.expo.dev/versions/v56.0.0/ + +# Язык проекта + +Весь контент, комментарии и документация должны быть на русском языке. diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..71e225a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,27 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@docs/core.md + + + @AGENTS.md + + + +@docs/commands.md + + + +@docs/git_workflow.md + + + +@docs/about_the_project.md + + + +@TESTS.md + + diff --git a/README.md b/README.md index 5b978a8..6ae16ea 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,24 @@ -# project-e -Лента локальных событий происходящих вокруг тебя +# Venty + +Лента локальных событий вокруг тебя. # Стек -- React Native - web, ios, android. Единый UI фреймворк во избежании поддержки разных версий компонентов -- Supabase - BaaS чтобы не делать бэкенд, со временем добавим бэкенд сервис который будет общаться с supabase или постгресом напрямую, но пока сэкономит нам время -- Nativewind - настроил :) -- Zustand - UI стейт -- React Query - server стейт -- React Hook Form / Zod - -Мб понадобится storybook + +- React Native — веб, iOS и Android. Единый UI-фреймворк, чтобы не поддерживать разные версии компонентов. +- Supabase — сервис базы данных, чтобы пока не писать отдельный бэкенд. +- NativeWind — стили через классы. +- Zustand — состояние интерфейса, если понадобится. +- React Query — серверное состояние. +- React Hook Form / Zod — формы и валидация. + +Возможно, позже понадобится Storybook. + +# Документация + +- [Быстрый старт](docs/quickstart.md) — установка, env и запуск проекта локально. +- [Команды](docs/commands.md) — справка по npm-скриптам. +- [О проекте](docs/about_the_project.md) — суть продукта и scope MVP. +- [Архитектура и правила кода](docs/core.md) — слои, стиль, терминология. +- [Git workflow](docs/git_workflow.md) — ветки, формат коммитов, PR. +- [AI-флоу разработки](docs/ai_workflow.md) — как использовать ИИ на каждом этапе. +- [Карта тестов](TESTS.md) — что покрыто unit-тестами. diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 0000000..48e6854 --- /dev/null +++ b/TESTS.md @@ -0,0 +1,34 @@ +# Карта unit-тестов + +Файл поддерживается вместе с unit-тестами. На один тест — не больше двух строк: что подаём, что ожидаем, какую проблему ловим. + +## `src/entities/activity/utils.test.ts` + +- `преобразует название в slug`: ввод — строки с пробелами/символами и строка без латиницы; вывод — slug или `activity`. Проверяет стабильную генерацию безопасной части публичной ссылки. +- `генерирует slug с коротким случайным суффиксом`: ввод — название активности; вывод — `slug-[a-z0-9]{6}`. Проверяет, что публичные ссылки получают случайный суффикс. +- `генерирует edit token из 18 символов`: ввод отсутствует; вывод — токен `[a-z0-9]{18}`. Проверяет формат секретной ссылки управления. +- `проверяет корректность даты`: ввод — валидный `datetime-local`, `12 мая`, пустая строка; вывод — `true/false`. Проверяет защиту от timestamp-ошибки Supabase до запроса. +- `преобразует дату в ISO, если дата корректна`: ввод — валидная и невалидная дата; вывод — ISO или исходная строка. Проверяет корректный payload для поля `starts_at`. +- `преобразует дату с бэка в значение для datetime-local`: ввод — дата с timezone и мусорная строка; вывод — `YYYY-MM-DDTHH:mm` или исходная строка. Проверяет отображение даты на странице управления. +- `возвращает следующий статус активности`: ввод — `active`/`cancelled`; вывод — обратный статус. Проверяет логику отмены и возврата активности. + +## `src/entities/activity/mappers.test.ts` + +- `преобразует форму создания в payload для API`: ввод — провалидированная форма с `null` в необязательных полях; вывод — `null`, ISO-дата, `slug`, `edit_token`, `active`. Проверяет payload создания (пустые строки в `null` чистит схема). +- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом и обложкой; вывод — те же значения. Проверяет, что маппер не затирает введённые данные. +- `преобразует форму обновления в payload для API`: ввод — провалидированная форма редактирования с `null` в необязательных полях; вывод — `null`, ISO-дата и статус. Проверяет payload сохранения активности. + +## `src/entities/participant/utils.test.ts` + +- `возвращает русский текст статуса`: ввод — `going`, `maybe`, `cant`, неизвестный статус; вывод — русские подписи или исходная строка. Проверяет понятное отображение статусов в UI. +- `возвращает статистику участников`: ввод — список участников; вывод — объект `{ going, maybe, cant }`. Проверяет агрегированную статистику откликов одним проходом. + +## `src/entities/participant/mappers.test.ts` + +- `преобразует форму отклика в payload для API`: ввод — форма с пустыми Telegram/comment; вывод — `null` и `activity_id`. Проверяет payload отклика без пустых строк. +- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram/comment; вывод — те же значения. Проверяет, что данные участника не теряются. + +## `src/shared/errors/getRussianErrorMessage.test.ts` + +- `переводит известные технические ошибки`: ввод — timestamp/network ошибки; вывод — русские сообщения. Проверяет, что технические backend/browser ошибки не попадают в UI. +- `возвращает fallback для неизвестной ошибки`: ввод — неизвестная Error и не-Error значение; вывод — fallback. Проверяет безопасное русское сообщение для любых неожиданных ошибок. diff --git a/app.json b/app.json index fae87e4..6c05b2c 100644 --- a/app.json +++ b/app.json @@ -2,6 +2,7 @@ "expo": { "name": "venty", "slug": "venty", + "scheme": "venty", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -21,9 +22,6 @@ "web": { "favicon": "./assets/favicon.png" }, - "plugins": [ - "expo-router", - "expo-status-bar" - ] + "plugins": ["expo-router", "expo-status-bar"] } } diff --git a/app/_layout.tsx b/app/_layout.tsx index a229637..c2a5ab7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,45 +1,22 @@ import "../global.css"; - -import { Link, Stack } from "expo-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Stack } from "expo-router"; +import { useState } from "react"; import { Text, View } from "react-native"; - -export const SITE_PAGES = [ - { href: "/", label: "Home" }, - { href: "/create", label: "Create" }, - { href: "/a/welcome", label: "Welcome article" }, - { href: "/a/supabase", label: "Supabase article" }, -] as const; - -const linkClassName = "text-base text-blue-600 underline"; - -export function PageLinks() { - return ( - - {SITE_PAGES.map((page) => ( - - {page.label} - - ))} - - ); -} - -export function BackLink() { - return ( - - ← Back to all pages - - ); -} +import { TextLink } from "../src/shared/ui/TextLink"; export default function RootLayout() { + const [client] = useState(() => new QueryClient()); return ( - <> - - Venty - + + + Venty + + Главная + Создать + - + ); } diff --git a/app/a/[slug].tsx b/app/a/[slug].tsx index 307d569..176898c 100644 --- a/app/a/[slug].tsx +++ b/app/a/[slug].tsx @@ -1,30 +1,69 @@ -import { Link, useLocalSearchParams } from "expo-router"; -import { ScrollView, Text, View } from "react-native"; -import { BackLink, SITE_PAGES } from "../_layout"; +import { Image, Text } from "react-native"; +import { getParticipantStatusText } from "../../src/entities/participant/utils"; +import { ResponseForm } from "../../src/features/respond-to-activity/ResponseForm"; +import { useRespondToActivityForm } from "../../src/features/respond-to-activity/useRespondToActivityForm"; +import { ActivityNotFound } from "../../src/shared/ui/ActivityNotFound"; +import { BackLink } from "../../src/shared/ui/BackLink"; +import { LoadingPage } from "../../src/shared/ui/LoadingPage"; +import { Page } from "../../src/shared/ui/Page"; +import { ParticipantList } from "../../src/shared/ui/ParticipantList"; -export default function ArticlePage() { - const { slug } = useLocalSearchParams<{ slug: string }>(); - const title = slug ? slug.replaceAll("-", " ") : "article"; +export default function ActivityPage() { + const { + activity, + isLoading, + loadError, + participants, + participantsLoading, + stats, + form, + submit, + isSaving, + hasResponded, + } = useRespondToActivityForm(); + + if (isLoading) return ; + if (!activity) return ; return ( - - - - {title} - - Dynamic route: /a/{slug}. This page proves nested pages render on web and have a backlink - to the all-pages index. + + + {activity.cover_url ? ( + + ) : null} + {activity.title} + {activity.city} + {activity.location_text} + {new Date(activity.starts_at).toLocaleString()} + {activity.description} + {activity.status === "cancelled" ? ( + ОТМЕНЕНО + ) : null} + + Идут: {stats.going} Может быть: {stats.maybe} Не могут: {stats.cant} + + {activity.capacity ? ( + + {stats.going} / {activity.capacity} идут - - - Other pages - {SITE_PAGES.filter((page) => page.href !== `/a/${slug}`).map((page) => ( - - {page.label} - - ))} - - - + ) : null} + {activity.status !== "cancelled" && !hasResponded ? ( + + ) : null} + {activity.status !== "cancelled" && hasResponded ? ( + + Ты уже откликнулся с этого устройства. + + ) : null} + + ); } diff --git a/app/create.tsx b/app/create.tsx index 689e74e..52d792b 100644 --- a/app/create.tsx +++ b/app/create.tsx @@ -1,26 +1,71 @@ -import { Link } from "expo-router"; -import { ScrollView, Text, View } from "react-native"; -import { BackLink } from "./_layout"; +import { Text } from "react-native"; +import { useCreateActivityForm } from "../src/features/create-activity/useCreateActivityForm"; +import { BackLink } from "../src/shared/ui/BackLink"; +import { Button } from "../src/shared/ui/Button"; +import { DateTimeInput } from "../src/shared/ui/DateTimeInput"; +import { ErrorText } from "../src/shared/ui/ErrorText"; +import { FormField } from "../src/shared/ui/FormField"; +import { Page } from "../src/shared/ui/Page"; +import { Textarea } from "../src/shared/ui/Textarea"; export default function CreatePage() { + const { form, submit, isSaving } = useCreateActivityForm(); + const { control, handleSubmit } = form; + const { errors } = form.formState; + return ( - - - - Create - - This page is wired into Expo Router and linked from the global navigation and home page. - - - Next step - - Add your form here, then insert rows into Supabase from this route. - - - Open an article page - - - - + + + Создать активность + + + + + + + +