Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
@@ -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"],
},
},
};
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_KEY=

# COPY INTO .env.local
28 changes: 28 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ yarn-error.*
# generated native folders
/ios
/android

# Obsidian vault
/venty/
.obsidian/
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
npx biome check --write --staged --files-ignore-unknown=true
git update-index --again
bash scripts/check-conventions.sh
1 change: 1 addition & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npm run ci
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/

# Язык проекта

Весь контент, комментарии и документация должны быть на русском языке.
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

<!-- Code style, architecture, API contract, terminology, workflow. Read ALWAYS when writing or planning code. -->

@AGENTS.md

<!-- Base context: Expo v56 docs rule, project language (Russian-only). Read ALWAYS. -->

@docs/commands.md

<!-- npm scripts: start/lint/format/test/ci, how to run a single test. Read when running, building or testing. -->

@docs/git_workflow.md

<!-- Branch naming, commit format, MR flow. Read when committing or branching. -->

@docs/about_the_project.md

<!-- Product vision, MVP scope, target audience. Read when business context is needed. -->

@TESTS.md

<!-- Map of unit tests (input / expected / what it catches). Read when adding or changing tests, or touching pure helpers (entities/*/utils, entities/*/mappers, shared/errors). Keep it in sync with the tests. -->
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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-тестами.
34 changes: 34 additions & 0 deletions TESTS.md
Original file line number Diff line number Diff line change
@@ -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. Проверяет безопасное русское сообщение для любых неожиданных ошибок.
6 changes: 2 additions & 4 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"expo": {
"name": "venty",
"slug": "venty",
"scheme": "venty",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
Expand All @@ -21,9 +22,6 @@
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-status-bar"
]
"plugins": ["expo-router", "expo-status-bar"]
}
}
49 changes: 13 additions & 36 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="flex-row flex-wrap gap-3">
{SITE_PAGES.map((page) => (
<Link key={page.href} href={page.href} asChild>
<Text className={linkClassName}>{page.label}</Text>
</Link>
))}
</View>
);
}

export function BackLink() {
return (
<Link href="/" asChild>
<Text className="mb-4 text-base text-blue-600 underline">← Back to all pages</Text>
</Link>
);
}
import { TextLink } from "../src/shared/ui/TextLink";

export default function RootLayout() {
const [client] = useState(() => new QueryClient());
return (
<>
<View className="gap-3 border-b border-gray-200 bg-white px-5 pb-4 pt-6">
<Text className="text-3xl font-bold text-gray-950">Venty</Text>
<PageLinks />
<QueryClientProvider client={client}>
<View className="border-b border-gray-500 p-4">
<Text className="text-3xl font-bold">Venty</Text>
<View className="flex-row gap-4">
<TextLink href="/">Главная</TextLink>
<TextLink href="/create">Создать</TextLink>
</View>
</View>
<Stack screenOptions={{ headerShown: false }} />
</>
</QueryClientProvider>
);
}
87 changes: 63 additions & 24 deletions app/a/[slug].tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingPage />;
if (!activity) return <ActivityNotFound error={loadError} />;

return (
<ScrollView className="flex-1 bg-slate-50">
<View className="gap-4 p-5">
<BackLink />
<Text className="text-2xl font-bold capitalize text-gray-950">{title}</Text>
<Text className="text-base leading-6 text-gray-700">
Dynamic route: /a/{slug}. This page proves nested pages render on web and have a backlink
to the all-pages index.
<Page>
<BackLink />
{activity.cover_url ? (
<Image
source={{ uri: activity.cover_url }}
className="h-48 w-full"
resizeMode="contain"
/>
) : null}
<Text className="text-3xl font-bold">{activity.title}</Text>
<Text>{activity.city}</Text>
<Text>{activity.location_text}</Text>
<Text>{new Date(activity.starts_at).toLocaleString()}</Text>
<Text>{activity.description}</Text>
{activity.status === "cancelled" ? (
<Text className="font-bold text-red-700">ОТМЕНЕНО</Text>
) : null}
<Text>
Идут: {stats.going} Может быть: {stats.maybe} Не могут: {stats.cant}
</Text>
{activity.capacity ? (
<Text>
{stats.going} / {activity.capacity} идут
</Text>

<View className="gap-3 rounded-xl border border-gray-200 bg-white p-4">
<Text className="text-lg font-bold text-gray-950">Other pages</Text>
{SITE_PAGES.filter((page) => page.href !== `/a/${slug}`).map((page) => (
<Link key={page.href} href={page.href} asChild>
<Text className="text-base text-blue-600 underline">{page.label}</Text>
</Link>
))}
</View>
</View>
</ScrollView>
) : null}
{activity.status !== "cancelled" && !hasResponded ? (
<ResponseForm form={form} isSaving={isSaving} onSubmit={submit} />
) : null}
{activity.status !== "cancelled" && hasResponded ? (
<Text className="font-bold">
Ты уже откликнулся с этого устройства.
</Text>
) : null}
<ParticipantList
participants={participants}
loading={participantsLoading}
statusText={getParticipantStatusText}
/>
</Page>
);
}
Loading
Loading