Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5907429
chore(cfg): добавлен expo-image-picker и плагин галереи
anu-mdl Jun 28, 2026
28fa4dc
feat(shared): generic JSON-хелперы device-хранилища
anu-mdl Jun 28, 2026
95fca13
feat(shared): ui-компоненты Avatar, MultiSelect, PhotoPicker
anu-mdl Jun 28, 2026
e681c6d
feat(activity): локальный список «Мои ивенты»
anu-mdl Jun 28, 2026
ebe632c
feat(participant): локальный список откликов «Посещено»
anu-mdl Jun 28, 2026
675b287
feat(profile): сущность профиля (device-local)
anu-mdl Jun 28, 2026
9c2e6f9
feat(features): запись созданных активностей и откликов в локальные с…
anu-mdl Jun 28, 2026
b6fe7be
feat(features): сценарий редактирования профиля
anu-mdl Jun 28, 2026
4a39a48
feat(app): экраны профиля и ссылка «Профиль» в шапке
anu-mdl Jun 28, 2026
7579054
docs: карта тестов профиля и уточнение продукт-дока
anu-mdl Jun 28, 2026
ed8d50b
feat(activity): лента скрывает прошедшие и сортирует по ближайшим
anu-mdl Jun 28, 2026
670b00a
feat(activity): фильтр ленты по городу и поиску
anu-mdl Jun 28, 2026
c4cbe2c
feat(app): поля фильтра города и поиска в ленте
anu-mdl Jun 28, 2026
d73e720
feat(activity): расчёт остатка мест из capacity
anu-mdl Jun 28, 2026
6d92fcc
feat(app): показ остатка мест на странице ивента
anu-mdl Jun 28, 2026
6334671
feat(shared): хелпер шеринга публичной ссылки
anu-mdl Jun 28, 2026
b5ebf38
feat(app): кнопка «Поделиться» на странице и в управлении
anu-mdl Jun 28, 2026
989ef32
feat(participant): отклик сведён к статусу «иду»
anu-mdl Jun 28, 2026
dd6f944
feat(features): форма отклика в один тап
anu-mdl Jun 28, 2026
d719f0f
feat(app): одно число статистики откликов
anu-mdl Jun 28, 2026
debf841
feat(activity): device-local лайки
anu-mdl Jun 28, 2026
9b6046b
feat(features): хук лайка активности
anu-mdl Jun 28, 2026
7769f9e
feat(app): кнопка лайка на странице ивента
anu-mdl Jun 28, 2026
fc08562
test(activity): схема создания и дедуп локального списка
anu-mdl Jun 28, 2026
c62dbb8
test(participant): схема отклика «иду» и дедуп откликов
anu-mdl Jun 28, 2026
fda2574
test(shared): ветвление шеринга ссылки
anu-mdl Jun 28, 2026
881b10e
docs: карта тестов фазы 7
anu-mdl Jun 28, 2026
94f9d35
feat(activity): поле chat_url в типах, схеме и маппере
anu-mdl Jun 28, 2026
808e6cc
feat(app): поля ссылки на чат в формах создания и управления
anu-mdl Jun 28, 2026
5735b24
feat(features): префилл chat_url из Telegram профиля при создании
anu-mdl Jun 28, 2026
c1d68af
feat(shared): ChatLinkButton — переход во внешний чат
anu-mdl Jun 28, 2026
6a76a06
feat(app): кнопка чата на странице ивента
anu-mdl Jun 28, 2026
dca28d2
docs: добавлен бэклог задач
anu-mdl Jun 28, 2026
26f4a77
feat(profile): TikTok и Discord в соцсетях профиля
anu-mdl Jun 28, 2026
38eccb5
refactor(participant): убрано поле комментария из отклика
anu-mdl Jun 28, 2026
d548026
feat(activity): валидация chat_url по мессенджерам и префилл из Telegram
anu-mdl Jun 28, 2026
c68e8a5
add cycles to events
anu-mdl Jul 6, 2026
dba6ab4
fix(features): доработки по ревью PR #12
anu-mdl Jul 8, 2026
587f9da
feat(activity): напоминания о встречах через локальные уведомления
anu-mdl Jul 12, 2026
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
118 changes: 111 additions & 7 deletions TESTS.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router", "expo-status-bar"]
"plugins": [
"expo-router",
"expo-status-bar",
[
"expo-image-picker",
{
"photosPermission": "Venty использует фото из галереи для аватара профиля.",
"microphonePermission": false
}
],
"expo-notifications"
]
}
}
17 changes: 16 additions & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import "../global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Notifications from "expo-notifications";
import { Stack } from "expo-router";
import { useState } from "react";
import { Text, View } from "react-native";
import { Platform, Text, View } from "react-native";
import { TextLink } from "../src/shared/ui/TextLink";

// Показ запланированного напоминания, пока приложение на переднем плане.
// На web expo-notifications нет — обработчик ставим только на нативе.
if (Platform.OS !== "web") {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}

export default function RootLayout() {
const [client] = useState(() => new QueryClient());
return (
Expand All @@ -14,6 +28,7 @@ export default function RootLayout() {
<View className="flex-row gap-4">
<TextLink href="/">Главная</TextLink>
<TextLink href="/create">Создать</TextLink>
<TextLink href="/profile">Профиль</TextLink>
</View>
</View>
<Stack screenOptions={{ headerShown: false }} />
Expand Down
48 changes: 40 additions & 8 deletions app/a/[slug].tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Image, Text } from "react-native";
import { getCapacityState } from "../../src/entities/activity/utils";
import { getParticipantStatusText } from "../../src/entities/participant/utils";
import { useLikeActivity } from "../../src/features/like-activity/useLikeActivity";
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 { Button } from "../../src/shared/ui/Button";
import { ChatLinkButton } from "../../src/shared/ui/ChatLinkButton";
import { LoadingPage } from "../../src/shared/ui/LoadingPage";
import { Page } from "../../src/shared/ui/Page";
import { ParticipantList } from "../../src/shared/ui/ParticipantList";
import { ShareButton } from "../../src/shared/ui/ShareButton";

export default function ActivityPage() {
const {
Expand All @@ -20,6 +25,7 @@ export default function ActivityPage() {
submit,
isSaving,
hasResponded,
reminderScheduled,
} = useRespondToActivityForm();

if (isLoading) return <LoadingPage />;
Expand All @@ -43,14 +49,11 @@ export default function ActivityPage() {
{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>
) : null}
<Text>Идут: {stats.going}</Text>
<CapacityNotice capacity={activity.capacity} going={stats.going} />
<LikeButton slug={activity.slug} />
<ShareButton slug={activity.slug} title={activity.title} />
{activity.chat_url ? <ChatLinkButton url={activity.chat_url} /> : null}
{activity.status !== "cancelled" && !hasResponded ? (
<ResponseForm form={form} isSaving={isSaving} onSubmit={submit} />
) : null}
Expand All @@ -59,6 +62,7 @@ export default function ActivityPage() {
Ты уже откликнулся с этого устройства.
</Text>
) : null}
{reminderScheduled ? <Text>Напомним за 2 часа до начала.</Text> : null}
<ParticipantList
participants={participants}
loading={participantsLoading}
Expand All @@ -67,3 +71,31 @@ export default function ActivityPage() {
</Page>
);
}

function LikeButton({ slug }: { slug: string }) {
const { isLiked, toggle, isToggling } = useLikeActivity(slug);

return (
<Button
title={isLiked ? "♥ Нравится" : "♡ Нравится"}
onPress={toggle}
disabled={isToggling}
/>
);
}

type CapacityNoticeProps = {
capacity: number | null;
going: number;
};

function CapacityNotice({ capacity, going }: CapacityNoticeProps) {
const state = getCapacityState({ capacity, going });
if (state.kind === "unlimited") return null;

if (state.kind === "full") {
return <Text className="font-bold text-red-700">Мест нет</Text>;
}

return <Text>Осталось мест: {state.left}</Text>;
}
79 changes: 20 additions & 59 deletions app/create.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,32 @@
import { useState } from "react";
import { Text } from "react-native";
import { useCreateActivityForm } from "../src/features/create-activity/useCreateActivityForm";
import { CreateActivityForm } from "../src/features/create-activity/CreateActivityForm";
import { CreateSeriesForm } from "../src/features/create-series/CreateSeriesForm";
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";
import { Select } from "../src/shared/ui/Select";

type Mode = "activity" | "series";

const modeOptions: { value: Mode; label: string }[] = [
{ value: "activity", label: "разовое" },
{ value: "series", label: "регулярное" },
];

export default function CreatePage() {
const { form, submit, isSaving } = useCreateActivityForm();
const { control, handleSubmit } = form;
const { errors } = form.formState;
const [mode, setMode] = useState<Mode>("activity");

return (
<Page>
<BackLink />
<Text className="text-2xl font-bold">Создать активность</Text>
<FormField
control={control}
name="title"
label="название, обязательно"
error={errors.title?.message}
/>
<FormField
control={control}
name="description"
label="описание"
component={Textarea}
error={errors.description?.message}
/>
<FormField
control={control}
name="city"
label="город, обязательно"
error={errors.city?.message}
/>
<FormField
control={control}
name="location_text"
label="место текстом"
error={errors.location_text?.message}
/>
<FormField
control={control}
name="starts_at"
label="дата и время, обязательно"
component={DateTimeInput}
error={errors.starts_at?.message}
/>
<FormField
control={control}
name="capacity"
label="лимит мест, число или пусто"
error={errors.capacity?.message}
/>
<FormField
control={control}
name="cover_url"
label="ссылка на обложку"
error={errors.cover_url?.message}
/>
<Button
title={isSaving ? "Сохраняем..." : "Создать"}
onPress={handleSubmit(submit)}
/>
<ErrorText>{errors.root?.server?.message}</ErrorText>
<Text className="text-2xl font-bold">Создать</Text>
<Select
label="тип мероприятия"
value={mode}
onChange={setMode}
options={modeOptions}
/>
{mode === "activity" ? <CreateActivityForm /> : <CreateSeriesForm />}
</Page>
);
}
130 changes: 110 additions & 20 deletions app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,127 @@
import { useState } from "react";
import { Text, View } from "react-native";
import { useActivities } from "../src/entities/activity/hooks";
import type { Activity } from "../src/entities/activity/types";
import { useSeries } from "../src/entities/series/hooks";
import type { SeriesWithMeetings } from "../src/entities/series/types";
import {
formatRecurrenceText,
listOccurrences,
} from "../src/entities/series/utils";
import { ErrorText } from "../src/shared/ui/ErrorText";
import { Input } from "../src/shared/ui/Input";
import { LoadingText } from "../src/shared/ui/LoadingText";
import { Page } from "../src/shared/ui/Page";
import { TextLink } from "../src/shared/ui/TextLink";

type FeedItem = { key: string; date: number; node: React.ReactNode };

export default function HomePage() {
const activities = useActivities();
const [city, setCity] = useState("");
const [query, setQuery] = useState("");
const filter = { city: city.trim(), query: query.trim() };
const activities = useActivities(filter);
const series = useSeries(filter);
const items = buildFeed(activities.data ?? [], series.data ?? []);
const isLoading = activities.isLoading || series.isLoading;

return (
<Page>
<Text className="text-4xl font-bold">Venty</Text>
<Text>Уродливые ссылки на активности, чтобы делать что-то с людьми</Text>
<TextLink href="/create">[Создать активность]</TextLink>
<Text className="text-2xl font-bold">Последние активности:</Text>
{activities.isLoading ? <LoadingText /> : null}
<Text>Уродливые ссылки на мероприятия, чтобы делать что-то с людьми</Text>
<TextLink href="/create">[Создать мероприятие]</TextLink>
<Input
label="Город"
value={city}
onChangeText={setCity}
placeholder="Например, Алматы"
/>
<Input
label="Поиск по названию"
value={query}
onChangeText={setQuery}
placeholder="Что ищем?"
/>
<Text className="text-2xl font-bold">Ближайшие мероприятия:</Text>
{isLoading ? <LoadingText /> : null}
<ErrorText
error={activities.error}
fallback="Не удалось загрузить активности"
error={activities.error ?? series.error}
fallback="Не удалось загрузить ленту"
/>
{activities.data?.length === 0 ? (
<Text>Активностей пока нет.</Text>
{!isLoading && items.length === 0 ? (
<Text>Мероприятий пока нет.</Text>
) : null}
<View className="gap-2">
{activities.data?.map((activity) => (
<View key={activity.id} className="border-b border-gray-300 py-2">
<Text>
{activity.title} — {activity.city} —{" "}
{new Date(activity.starts_at).toLocaleString()}
</Text>
<TextLink href={`/a/${activity.slug}`}>открыть</TextLink>
</View>
))}
</View>
<View className="gap-2">{items.map((item) => item.node)}</View>
</Page>
);
}

// Активности и серии в одной ленте по возрастанию даты ближайшей встречи.
function buildFeed(
activities: Activity[],
series: SeriesWithMeetings[],
): FeedItem[] {
const activityItems = activities.map(toActivityItem);
const seriesItems = series.map(toSeriesItem);
return [...activityItems, ...seriesItems].sort((a, b) => a.date - b.date);
}

function toActivityItem(activity: Activity): FeedItem {
const startsAt = new Date(activity.starts_at);
return {
key: `activity-${activity.id}`,
date: startsAt.getTime(),
node: (
<View
key={`activity-${activity.id}`}
className="border-b border-gray-300 py-2"
>
<Text>
{activity.title} — {activity.city} — {startsAt.toLocaleString()}
</Text>
<TextLink href={`/a/${activity.slug}`}>открыть</TextLink>
</View>
),
};
}

function toSeriesItem(series: SeriesWithMeetings): FeedItem {
const next = nextMeetingDate(series);
const schedule = formatRecurrenceText(series.recurrence, series.starts_at);
return {
key: `series-${series.id}`,
date: next?.getTime() ?? Number.POSITIVE_INFINITY,
node: (
<View
key={`series-${series.id}`}
className="border-b border-gray-300 py-2"
>
<Text>
{series.title} — {series.city} · серия
</Text>
<Text className="text-gray-600">
{next
? `${schedule} · ближайшая: ${next.toLocaleString()}`
: `${schedule} · ближайших встреч нет`}
</Text>
<TextLink href={`/s/${series.slug}`}>открыть</TextLink>
</View>
),
};
}

// Ближайшая встреча в будущем: активный слот или следующая дата по правилу.
// Будущих встреч нет — возвращаем null, чтобы не поднимать серию прошедшим стартом.
function nextMeetingDate(series: SeriesWithMeetings): Date | null {
const now = Date.now();
const fromMeetings = series.meetings
.filter((meeting) => meeting.status === "active")
.map((meeting) => new Date(meeting.starts_at).getTime());
const fromRule = listOccurrences(series.recurrence, series.starts_at).map(
(iso) => new Date(iso).getTime(),
);
const upcoming = [...fromMeetings, ...fromRule]
.filter((time) => time >= now)
.sort((a, b) => a - b);
return upcoming.length > 0 ? new Date(upcoming[0]) : null;
}
Comment on lines +113 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Найти определения и использования listOccurrences, а также участок с nextMeetingDate.
git ls-files | rg '(^|/)(app/index\.tsx|.*recurrence.*|.*occurrence.*|.*meeting.*|.*series.*)\.(ts|tsx|js|jsx)$' || true
rg -n "function nextMeetingDate|const nextMeetingDate|listOccurrences\\(" app/index.tsx .

Repository: linkerkz/project-e

Length of output: 2251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Посмотреть сигнатуру listOccurrences и связанные значения horizon.
rg -n "horizon\\s*=|listOccurrences\\s*\\(" . --glob '!**/node_modules/**'

Repository: linkerkz/project-e

Length of output: 1300


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Понять, как listOccurrences использует horizon и что именно покрывают тесты.
wc -l src/entities/series/utils.ts src/entities/series/utils.test.ts app/index.tsx
sed -n '1,220p' src/entities/series/utils.ts
printf '\n--- TESTS ---\n'
sed -n '1,220p' src/entities/series/utils.test.ts
printf '\n--- APP ---\n'
sed -n '100,145p' app/index.tsx

Repository: linkerkz/project-e

Length of output: 10590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Понять реализацию listOccurrences и поведение horizon в тестах.
wc -l src/entities/series/utils.ts src/entities/series/utils.test.ts app/index.tsx
sed -n '1,220p' src/entities/series/utils.ts
printf '\n--- TESTS ---\n'
sed -n '1,220p' src/entities/series/utils.test.ts
printf '\n--- APP ---\n'
sed -n '100,145p' app/index.tsx

Repository: linkerkz/project-e

Length of output: 10590


nextMeetingDate не должен использовать дефолтный horizon = 5

Для длинных повторяющихся серий listOccurrences(...) обрежет будущие даты, и лента покажет «ближайших встреч нет», хотя они есть. Передайте здесь отдельный больший горизонт или ищите первую будущую дату без фиксированного лимита.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/index.tsx` around lines 113 - 127, Update nextMeetingDate so its
listOccurrences call does not use the default horizon of 5; pass a sufficiently
large dedicated horizon or use an unbounded way to find the first future
recurrence. Preserve the existing active-meeting candidates, filtering, sorting,
and null behavior when no future date exists.

3 changes: 2 additions & 1 deletion app/manage/[editToken].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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";
import { ShareButton } from "../../src/shared/ui/ShareButton";

export default function ManagePage() {
const {
Expand All @@ -30,7 +31,7 @@ export default function ManagePage() {
<BackLink />
<Text className="text-2xl font-bold">Управление активностью</Text>
<Text>Публичная ссылка: {publicUrl}</Text>
<Text>Копируй и отправляй вручную</Text>
<ShareButton slug={activity.slug} title={activity.title} />
<ManageActivityForm
form={form}
status={activity.status}
Expand Down
Loading
Loading