Feat/profile - #9
Conversation
Выбор фото профиля через системную галерею (локальный URI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
readDeviceJson/writeDeviceJson/removeDeviceValue поверх булевых флагов — slice'ы не дёргают AsyncStorage напрямую. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Аватар-плейсхолдер, мультивыбор интересов и выбор фото из галереи. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
readMyActivities/addMyActivity (дедуп по slug) + activityKeys.mine, useMyActivities, useTrackMyActivity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
readMyResponses/addMyResponse (дедуп по slug) + participantKeys.mine, useMyResponses, useTrackMyResponse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
types/schemas/utils/storage/hooks/mappers + unit-тесты. Профиль живёт в AsyncStorage, без серверного аккаунта. 4 соцсети (telegram, instagram, whatsapp, website), фото — локальный URI без URL-валидации. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…писки На успешном создании/отклике дописываем в device-list через useTrackMyActivity/useTrackMyResponse. Сбой записи не роняет операцию. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useEditProfileForm (RHF + zodResolver + useSaveProfile, префилл через mapProfileToForm) и EditProfileForm с выбором фото и интересов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Просмотр профиля (фото, статистика, соцсети наружу, «Мои ивенты»), экран редактирования, пустое состояние. Ссылка в шапку _layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TESTS.md дополнен секциями profile (schemas/mappers/utils). В about_the_project уточнены открытое создание ивентов и модерация. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds a complete profile feature: types, Zod schema, device JSON storage, React Query hooks, form mappers/utils, ChangesProfile feature and local tracking
Project documentation update
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/features/create-activity/useCreateActivityForm.ts (1)
40-58: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winНе держите
submit()на некритичном локальном трекинге.После
mutation.mutateAsync()isSavingуже станетfalse, аsubmit()всё ещё ждётrememberCreated(). В это окно форму можно отправить повторно и получить дубль активности на сервере. Раз локальная запись у вас не критична, здесь лучше запускать её безawaitи сразу делатьrouter.replace().Возможная правка
- await rememberCreated(activity); + void rememberCreated(activity); router.replace(`/manage/${activity.edit_token}`);🤖 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 `@src/features/create-activity/useCreateActivityForm.ts` around lines 40 - 58, В submit() лишнее ожидание rememberCreated(activity) держит форму в подвешенном состоянии после mutation.mutateAsync(), из-за чего возможна повторная отправка и дубли активности; в useCreateActivityForm нужно запускать rememberCreated() без await и сразу выполнять router.replace(`/manage/${activity.edit_token}`), сохранив текущую обработку ошибок через setFormServerError и не меняя поведение rememberCreated().
🧹 Nitpick comments (5)
src/entities/profile/mappers.test.ts (1)
35-53: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winДобавьте полное сравнение в тест заполненных полей.
Во втором тесте используются точечные проверки
toBeиnot.toHavePropertyвместоtoEqual. ЕслиmapProfileToFormдобавит лишнее поле или не превратитnullв""дляwebsite, тест не поймает регрессию. Сделайте полное сравнение объекта, как в первом тесте.- expect(form.city).toBe("Москва"); - expect(form.socials.telegram).toBe("`@anna`"); - expect(form).not.toHaveProperty("registeredAt"); + expect(form).toEqual({ + fullName: "Анна", + city: "Москва", + categories: ["Спорт"], + photoUrl: "https://example.com/p.jpg", + socials: { + telegram: "`@anna`", + instagram: "", + whatsapp: "", + website: "", + }, + });🤖 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 `@src/entities/profile/mappers.test.ts` around lines 35 - 53, The filled-fields test for mapProfileToForm is only asserting a couple of properties, so it can miss extra fields or incorrect null-to-empty-string mapping. Update the test in mappers.test.ts to use a full object comparison with toEqual, mirroring the first test, and explicitly verify the socials object and omission of registeredAt through the expected object shape rather than separate partial assertions.src/entities/profile/utils.ts (1)
35-42: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winПустая строка в соцсети проходит как заполненная.
getFilledSocialsпроверяетurl != null, но после схемыprofileSchemaпустые строки должны статьnull. Если данные попадут в обход схемы (напрямую в хранилище), пустая строка отобразится как активная ссылка. Добавьте защиту:- if (url != null) filled.push({ key, label, url }); + if (url != null && url !== "") filled.push({ key, label, url });Или, ещё лучше, вынесите
isNonEmptyStringв shared. Это согласуется с контрактом схемы и защищает от некорректных данных в хранилище.🤖 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 `@src/entities/profile/utils.ts` around lines 35 - 42, The getFilledSocials helper currently treats any non-null social URL as filled, so an empty string can still be rendered as an active link when data bypasses profileSchema. Update getFilledSocials in src/entities/profile/utils.ts to reject empty strings as well as null/undefined, ideally by reusing a shared isNonEmptyString guard if available or extracting one to shared, and keep the filtering tied to the existing socialOptions/ProfileSocials flow.src/entities/profile/utils.test.ts (1)
20-23: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winДобавьте граничный тест для даты около полуночи UTC.
Текущий тест не ловит баг с timezone. Добавьте кейс, где UTC-дата близка к полуночи, чтобы зафиксировать ожидаемое поведение:
it("форматирует дату регистрации, мусор отдаёт как есть", () => { expect(formatRegisteredAt("2026-03-05T10:00")).toBe("05.03.2026"); + // Граничный случай: полночь UTC должна остаться той же датой в любой timezone + expect(formatRegisteredAt("2026-03-05T23:30:00.000Z")).toBe("05.03.2026"); expect(formatRegisteredAt("не дата")).toBe("не дата"); });Это предотвратит регрессию при фиксе
getUTCDate()/getUTCMonth().🤖 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 `@src/entities/profile/utils.test.ts` around lines 20 - 23, The current `formatRegisteredAt` test only covers a normal timestamp and invalid input, so it misses the timezone edge case near midnight UTC. Add a boundary test in `utils.test.ts` around `formatRegisteredAt` using a UTC timestamp close to midnight to lock in the expected formatted day and prevent regressions when the implementation uses `getUTCDate()`/`getUTCMonth()`.src/shared/ui/MultiSelect.tsx (1)
6-8: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winСузьте тип
option.labelдо того, что реально рендерится.Сейчас
label: ReactNode, ноButtonпринимает строковыйtitle, а шаблонная строка на Line 32 превратит JSX-узел в[object Object]. Либо сузьтеOption["label"]доstring, либо меняйтеButtonна рендерchildren.Also applies to: 30-33
🤖 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 `@src/shared/ui/MultiSelect.tsx` around lines 6 - 8, Shrink the MultiSelect option label type to match what is actually rendered: `Option["label"]` is currently `ReactNode`, but `MultiSelect` uses it in `Button` as a string title and in the template string path, which can turn JSX into `[object Object]`. Update the `Option` type and the `MultiSelect`/`Button` usage so the `label` is either constrained to `string` or rendered as children instead of being interpolated, keeping the `Option` and `MultiSelect` symbols aligned with the real UI behavior.src/features/respond-to-activity/useRespondToActivityForm.ts (1)
54-75: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winНе блокируйте завершение отклика локальной записью.
Коммент прямо говорит, что этот список некритичен, но
submit()всё равно ждётrememberResponse(). Это добавляет лишнюю задержку после уже успешного серверного отклика. Здесь лучше запускать локальный трекинг безawait.Возможная правка
- await rememberResponse(activity, values.status); + void rememberResponse(activity, values.status);🤖 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 `@src/features/respond-to-activity/useRespondToActivityForm.ts` around lines 54 - 75, В `submit()` из `useRespondToActivityForm` не нужно ждать `rememberResponse()` после успешной отправки отклика на сервер: локальный трекинг «Посещено» некритичен и не должен добавлять задержку. Вызов `rememberResponse(activity, values.status)` сделайте без `await`, сохранив текущую обработку ошибок через `setFormServerError` для основного запроса и оставив `track.mutateAsync` в `rememberResponse` с подавлением его ошибок.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@app/profile/edit.tsx`:
- Around line 13-17: The screen header in the profile form is hardcoded to the
edit flow, so the “Создать профиль” path from EmptyProfile still shows
“Редактировать профиль.” Update the title in the profile page component (the one
rendering Page, BackLink, and EditProfileForm) to depend on whether a saved
profile exists, using the existing profile state/loaded data to choose between
create and edit text. Keep the form and submit behavior unchanged, only make the
heading reflect the current scenario.
In `@app/profile/index.tsx`:
- Line 39: The logout action in the profile screen only calls reset.mutate(), so
device-local data from the same feature-cohort is left behind and can leak into
the next profile. Update the onPress handler for the “Выйти” Button in the
profile view to also clear all local storage/state tied to the user, including
the my activities and my responses data, using the existing logout/reset flow or
the relevant cleanup helpers in this screen/module.
- Around line 24-37: The profile screen renders Stats and empty-state content as
soon as profileQuery resolves, even if useMyActivities and useMyResponses are
still loading. Update the logic in app/profile/index.tsx around ProfileHeader,
Stats, MyActivities, and MyResponses so the page waits for all local queries
before showing counts or empty placeholders, and keep the loading state until
those hooks finish. Use the existing symbols profileQuery, myActivities, and
myResponses to gate the render consistently.
In `@src/entities/activity/local.ts`:
- Around line 14-27: Add a symmetric device-local cleanup path for the
my-activities store: `readMyActivities` and `addMyActivity` currently persist
`venty.my-activities`, but there is no `removeMyActivities`, so logout/reset
only clears the profile and leaves this data behind. Implement
`removeMyActivities()` alongside `readMyActivities`/`addMyActivity` to delete
the stored device JSON, then wire that helper into the shared logout/reset flow
that already handles profile cleanup; apply the same pattern for responses so
all device-local caches are cleared together.
In `@src/entities/participant/local.ts`:
- Around line 4-12: `MyResponse` is too broad for the “Посещено” counter because
it includes non-visit statuses like `maybe` and `cant`. Narrow the contract in
`participant/local.ts` so only statuses that should count as visited are stored,
or update the profile logic in `app/profile/index.tsx` to derive `visited` by
filtering `myResponses.data` by `status` instead of using raw length. Use
`MyResponse` and the `visited` calculation in `app/profile/index.tsx` to locate
the affected code.
In `@src/entities/profile/schemas.ts`:
- Around line 3-4: The `emptyToNull` normalization in `schemas.ts` only treats
`""` and `undefined` as empty, so whitespace-only strings still pass through
`optionalText` as real values. Update `emptyToNull` to also convert strings
containing only spaces (trimmed empty input) to null, and keep `optionalText`
using that shared helper so `city`, `photoUrl`, and `socials.*` normalize
consistently.
In `@src/entities/profile/storage.ts`:
- Around line 14-29: `writeProfile` and `keepRegisteredAt` have a non-atomic
read-then-write race that can assign different `registeredAt` values on
concurrent first saves. Fix it by making the `registeredAt` assignment atomic
within `writeProfile`/`keepRegisteredAt` (for example via a module-level lock or
an atomic compare-and-set path in `writeDeviceJson` if available), or explicitly
document and enforce that callers must serialize `writeProfile` calls. Ensure
the first persisted profile wins and subsequent writes reuse that same
timestamp.
In `@src/entities/profile/utils.ts`:
- Around line 44-51: The formatRegisteredAt helper is parsing the registration
ISO string with local timezone semantics, which can shift the displayed day for
users in UTC+N. Update formatRegisteredAt in utils.ts to format the date in UTC
or via an explicit UTC-aware parser/formatter (for example using parseISO with a
UTC-safe format path), and keep the invalid-date fallback behavior unchanged.
In `@src/shared/lib/storage.ts`:
- Around line 16-20: `readDeviceJson` currently casts `JSON.parse` directly to
`T`, so incompatible but valid JSON can leak into callers like `readProfile` and
the activity storage flow. Change the helper to stop promising `T` without
validation: either return `unknown` from `readDeviceJson` or accept a
validator/schema and verify the parsed value before returning it. Then update
the downstream uses in `storage.ts` callers to handle rejected/invalid shapes
safely instead of trusting the parsed value as a typed entity.
In `@src/shared/ui/PhotoPicker.tsx`:
- Around line 36-42: The photo selection flow in PhotoPicker is swallowing real
failures, so only user-initiated cancel should stay quiet. Update the try/catch
around ImagePicker.launchImageLibraryAsync to treat result.canceled as a silent
no-op, but in the catch path surface the error through the component’s existing
inline/root error handling instead of ignoring it. Keep the fix localized to
PhotoPicker and preserve onChange only for successful selections.
---
Outside diff comments:
In `@src/features/create-activity/useCreateActivityForm.ts`:
- Around line 40-58: В submit() лишнее ожидание rememberCreated(activity) держит
форму в подвешенном состоянии после mutation.mutateAsync(), из-за чего возможна
повторная отправка и дубли активности; в useCreateActivityForm нужно запускать
rememberCreated() без await и сразу выполнять
router.replace(`/manage/${activity.edit_token}`), сохранив текущую обработку
ошибок через setFormServerError и не меняя поведение rememberCreated().
---
Nitpick comments:
In `@src/entities/profile/mappers.test.ts`:
- Around line 35-53: The filled-fields test for mapProfileToForm is only
asserting a couple of properties, so it can miss extra fields or incorrect
null-to-empty-string mapping. Update the test in mappers.test.ts to use a full
object comparison with toEqual, mirroring the first test, and explicitly verify
the socials object and omission of registeredAt through the expected object
shape rather than separate partial assertions.
In `@src/entities/profile/utils.test.ts`:
- Around line 20-23: The current `formatRegisteredAt` test only covers a normal
timestamp and invalid input, so it misses the timezone edge case near midnight
UTC. Add a boundary test in `utils.test.ts` around `formatRegisteredAt` using a
UTC timestamp close to midnight to lock in the expected formatted day and
prevent regressions when the implementation uses `getUTCDate()`/`getUTCMonth()`.
In `@src/entities/profile/utils.ts`:
- Around line 35-42: The getFilledSocials helper currently treats any non-null
social URL as filled, so an empty string can still be rendered as an active link
when data bypasses profileSchema. Update getFilledSocials in
src/entities/profile/utils.ts to reject empty strings as well as null/undefined,
ideally by reusing a shared isNonEmptyString guard if available or extracting
one to shared, and keep the filtering tied to the existing
socialOptions/ProfileSocials flow.
In `@src/features/respond-to-activity/useRespondToActivityForm.ts`:
- Around line 54-75: В `submit()` из `useRespondToActivityForm` не нужно ждать
`rememberResponse()` после успешной отправки отклика на сервер: локальный
трекинг «Посещено» некритичен и не должен добавлять задержку. Вызов
`rememberResponse(activity, values.status)` сделайте без `await`, сохранив
текущую обработку ошибок через `setFormServerError` для основного запроса и
оставив `track.mutateAsync` в `rememberResponse` с подавлением его ошибок.
In `@src/shared/ui/MultiSelect.tsx`:
- Around line 6-8: Shrink the MultiSelect option label type to match what is
actually rendered: `Option["label"]` is currently `ReactNode`, but `MultiSelect`
uses it in `Button` as a string title and in the template string path, which can
turn JSX into `[object Object]`. Update the `Option` type and the
`MultiSelect`/`Button` usage so the `label` is either constrained to `string` or
rendered as children instead of being interpolated, keeping the `Option` and
`MultiSelect` symbols aligned with the real UI behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 41d56d6d-b43a-435a-9731-85ae71380b6b
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (28)
TESTS.mdapp.jsonapp/_layout.tsxapp/profile/edit.tsxapp/profile/index.tsxdocs/about_the_project.mdpackage.jsonsrc/entities/activity/hooks.tssrc/entities/activity/local.tssrc/entities/participant/hooks.tssrc/entities/participant/local.tssrc/entities/profile/hooks.tssrc/entities/profile/mappers.test.tssrc/entities/profile/mappers.tssrc/entities/profile/schemas.test.tssrc/entities/profile/schemas.tssrc/entities/profile/storage.tssrc/entities/profile/types.tssrc/entities/profile/utils.test.tssrc/entities/profile/utils.tssrc/features/create-activity/useCreateActivityForm.tssrc/features/edit-profile/EditProfileForm.tsxsrc/features/edit-profile/useEditProfileForm.tssrc/features/respond-to-activity/useRespondToActivityForm.tssrc/shared/lib/storage.tssrc/shared/ui/Avatar.tsxsrc/shared/ui/MultiSelect.tsxsrc/shared/ui/PhotoPicker.tsx
| return ( | ||
| <Page> | ||
| <BackLink /> | ||
| <Text className="text-2xl font-bold">Редактировать профиль</Text> | ||
| <EditProfileForm form={form} isSaving={isSaving} onSubmit={submit} /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Заголовок не совпадает со сценарием создания профиля.
На этот экран ведёт ссылка «Создать профиль» из EmptyProfile, но при отсутствии профиля пользователь всё равно видит «Редактировать профиль». Лучше выбирать заголовок по наличию сохранённого профиля, иначе flow выглядит сломанным.
🤖 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/profile/edit.tsx` around lines 13 - 17, The screen header in the profile
form is hardcoded to the edit flow, so the “Создать профиль” path from
EmptyProfile still shows “Редактировать профиль.” Update the title in the
profile page component (the one rendering Page, BackLink, and EditProfileForm)
to depend on whether a saved profile exists, using the existing profile
state/loaded data to choose between create and edit text. Keep the form and
submit behavior unchanged, only make the heading reflect the current scenario.
| if (profileQuery.isLoading) return <LoadingPage />; | ||
| const profile = profileQuery.data; | ||
| if (!profile) return <EmptyProfile />; | ||
|
|
||
| return ( | ||
| <Page> | ||
| <BackLink /> | ||
| <ProfileHeader profile={profile} /> | ||
| <Stats | ||
| created={myActivities.data?.length ?? 0} | ||
| visited={myResponses.data?.length ?? 0} | ||
| /> | ||
| <Socials profile={profile} /> | ||
| <MyActivities items={myActivities.data ?? []} /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Не показывайте нули и пустое состояние до завершения локальных query.
Экран ждёт только profileQuery, поэтому при ещё загружающихся useMyActivities/useMyResponses уже рисуются 0 и «Пока ничего не создано». Для пользователя это выглядит как реальные данные, а не как loading-state.
🤖 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/profile/index.tsx` around lines 24 - 37, The profile screen renders Stats
and empty-state content as soon as profileQuery resolves, even if
useMyActivities and useMyResponses are still loading. Update the logic in
app/profile/index.tsx around ProfileHeader, Stats, MyActivities, and MyResponses
so the page waits for all local queries before showing counts or empty
placeholders, and keep the loading state until those hooks finish. Use the
existing symbols profileQuery, myActivities, and myResponses to gate the render
consistently.
| <Socials profile={profile} /> | ||
| <MyActivities items={myActivities.data ?? []} /> | ||
| <TextLink href="/profile/edit">[Редактировать профиль]</TextLink> | ||
| <Button title="Выйти" onPress={() => reset.mutate()} /> |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Очищайте все device-local следы при выходе.
Сейчас кнопка «Выйти» сбрасывает только профиль, а локальные my activities/my responses из того же feature-cohort остаются в хранилище. После создания нового профиля на этом устройстве экран подтянет статистику и ссылки предыдущего пользователя.
🤖 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/profile/index.tsx` at line 39, The logout action in the profile screen
only calls reset.mutate(), so device-local data from the same feature-cohort is
left behind and can leak into the next profile. Update the onPress handler for
the “Выйти” Button in the profile view to also clear all local storage/state
tied to the user, including the my activities and my responses data, using the
existing logout/reset flow or the relevant cleanup helpers in this
screen/module.
| const myActivitiesKey = "venty.my-activities"; | ||
|
|
||
| export async function readMyActivities(): Promise<MyActivity[]> { | ||
| const list = await readDeviceJson<MyActivity[]>(myActivitiesKey); | ||
| return list ?? []; | ||
| } | ||
|
|
||
| export async function addMyActivity(activity: Activity) { | ||
| const list = await readMyActivities(); | ||
| if (list.some((item) => item.slug === activity.slug)) return list; | ||
| const next = [toMyActivity(activity), ...list]; | ||
| await writeDeviceJson(myActivitiesKey, next); | ||
| return next; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Добавьте симметричный reset/remove для device-local стора.
У нового стора есть только read/add, а по стек-контексту logout/reset удаляет только профиль. В таком виде venty.my-activities переживёт "Выйти", и следующий профиль на этом устройстве увидит чужие "Мои ивенты". Нужен removeMyActivities() и подключение его к общему reset/logout-потоку; то же самое понадобится для responses.
🤖 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 `@src/entities/activity/local.ts` around lines 14 - 27, Add a symmetric
device-local cleanup path for the my-activities store: `readMyActivities` and
`addMyActivity` currently persist `venty.my-activities`, but there is no
`removeMyActivities`, so logout/reset only clears the profile and leaves this
data behind. Implement `removeMyActivities()` alongside
`readMyActivities`/`addMyActivity` to delete the stored device JSON, then wire
that helper into the shared logout/reset flow that already handles profile
cleanup; apply the same pattern for responses so all device-local caches are
cleared together.
| // Локальный список активностей, где устройство откликнулось — данные для счётчика | ||
| // «Посещено» в профиле. Серверного аккаунта нет, это device-local проекция отклика. | ||
| export type MyResponse = { | ||
| slug: string; | ||
| title: string; | ||
| city: string; | ||
| startsAt: string; | ||
| status: ParticipantStatus; | ||
| }; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Контракт MyResponse не совпадает со счётчиком "Посещено".
Здесь сохраняются любые статусы отклика, включая "maybe" и "cant", а downstream app/profile/index.tsx считает visited просто как myResponses.data?.length. В итоге даже отказ увеличит "Посещено". Либо храните здесь только статусы, которые реально считаются посещением, либо перестройте профильный счётчик на фильтрацию по status.
🤖 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 `@src/entities/participant/local.ts` around lines 4 - 12, `MyResponse` is too
broad for the “Посещено” counter because it includes non-visit statuses like
`maybe` and `cant`. Narrow the contract in `participant/local.ts` so only
statuses that should count as visited are stored, or update the profile logic in
`app/profile/index.tsx` to derive `visited` by filtering `myResponses.data` by
`status` instead of using raw length. Use `MyResponse` and the `visited`
calculation in `app/profile/index.tsx` to locate the affected code.
| const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v); | ||
| const optionalText = z.preprocess(emptyToNull, z.string().nullable()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Нормализуйте строки из пробелов так же, как пустые.
Сейчас emptyToNull обрабатывает только "" и undefined, поэтому значения вроде " " в city, photoUrl и socials.* сохраняются как непустые строки. Дальше такие поля будут считаться заполненными, хотя по смыслу это пустое значение.
Возможный фикс
-const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v);
+const emptyToNull = (v: unknown) =>
+ typeof v === "string" && v.trim() === ""
+ ? null
+ : v === undefined
+ ? null
+ : v;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v); | |
| const optionalText = z.preprocess(emptyToNull, z.string().nullable()); | |
| const emptyToNull = (v: unknown) => | |
| typeof v === "string" && v.trim() === "" | |
| ? null | |
| : v === undefined | |
| ? null | |
| : v; | |
| const optionalText = z.preprocess(emptyToNull, z.string().nullable()); |
🤖 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 `@src/entities/profile/schemas.ts` around lines 3 - 4, The `emptyToNull`
normalization in `schemas.ts` only treats `""` and `undefined` as empty, so
whitespace-only strings still pass through `optionalText` as real values. Update
`emptyToNull` to also convert strings containing only spaces (trimmed empty
input) to null, and keep `optionalText` using that shared helper so `city`,
`photoUrl`, and `socials.*` normalize consistently.
| export async function writeProfile(input: SaveProfileInput): Promise<Profile> { | ||
| const registeredAt = await keepRegisteredAt(); | ||
| const profile = { ...input, registeredAt }; | ||
| await writeDeviceJson(profileKey, profile); | ||
| return profile; | ||
| } | ||
|
|
||
| export function removeProfile() { | ||
| return removeDeviceValue(profileKey); | ||
| } | ||
|
|
||
| // Дата регистрации = первое сохранение на устройстве, при правках не меняется. | ||
| async function keepRegisteredAt() { | ||
| const existing = await readProfile(); | ||
| return existing?.registeredAt ?? new Date().toISOString(); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
Race condition: keepRegisteredAt неатомарное чтение-запись.
keepRegisteredAt читает, затем writeProfile записывает. При двух параллельных первых сохранениях оба вызова readProfile() вернут null, и оба создадут разный registeredAt — нарушение инварианта «дата регистрации = первое сохранение».
Если writeDeviceJson не поддерживает CAS/атомарную проверку, добавьте явную блокировку на уровне модуля или задокументируйте, что вызов writeProfile должен сериализоваться вызывающим кодом (React Query mutation с одним экземпляром достаточен, но это неочевидно).
+let writePromise: Promise<unknown> | null = null;
+
export async function writeProfile(input: SaveProfileInput): Promise<Profile> {
+ // Серилизуем записи, чтобы keepRegisteredAt было атомарным
+ if (writePromise) await writePromise;
+ writePromise = (async () => {
- const registeredAt = await keepRegisteredAt();
+ const registeredAt = await keepRegisteredAt();
- const profile = { ...input, registeredAt };
+ const profile = { ...input, registeredAt };
- await writeDeviceJson(profileKey, profile);
+ await writeDeviceJson(profileKey, profile);
- return profile;
+ return profile;
+ })();
+ return writePromise;
}Или, если writeDeviceJson возвращает предыдущее значение:
async function keepRegisteredAt() {
// атомарная проверка при записи, а не отдельное чтение
// ...
}🤖 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 `@src/entities/profile/storage.ts` around lines 14 - 29, `writeProfile` and
`keepRegisteredAt` have a non-atomic read-then-write race that can assign
different `registeredAt` values on concurrent first saves. Fix it by making the
`registeredAt` assignment atomic within `writeProfile`/`keepRegisteredAt` (for
example via a module-level lock or an atomic compare-and-set path in
`writeDeviceJson` if available), or explicitly document and enforce that callers
must serialize `writeProfile` calls. Ensure the first persisted profile wins and
subsequent writes reuse that same timestamp.
| export function formatRegisteredAt(value: string) { | ||
| const date = new Date(value); | ||
| if (Number.isNaN(date.getTime())) return value; | ||
|
|
||
| return `${padDatePart(date.getDate())}.${padDatePart( | ||
| date.getMonth() + 1, | ||
| )}.${date.getFullYear()}`; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Некорректное отображение даты регистрации в восточных часовых поясах.
formatRegisteredAt парсит ISO-строку через new Date(value) без указания timezone. Для пользователей в UTC+N дата регистрации может сдвигаться на +1 день (например, 2026-03-05T23:00:00Z в UTC+3 → 06.03.2026 вместо 05.03.2026).
Храните и форматируйте дату в UTC, или используйте date-fns/Intl.DateTimeFormat с явным UTC:
export function formatRegisteredAt(value: string) {
- const date = new Date(value);
+ const date = new Date(value); // уже ISO-строка от toISOString()
if (Number.isNaN(date.getTime())) return value;
- return `${padDatePart(date.getDate())}.${padDatePart(
- date.getMonth() + 1,
- )}.${date.getFullYear()}`;
+ return `${padDatePart(date.getUTCDate())}.${padDatePart(
+ date.getUTCMonth() + 1,
+ )}.${date.getUTCFullYear()}`;
}Или, если в проекте уже есть date-fns:
import { format, parseISO } from "date-fns";
return format(parseISO(value), "dd.MM.yyyy");🤖 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 `@src/entities/profile/utils.ts` around lines 44 - 51, The formatRegisteredAt
helper is parsing the registration ISO string with local timezone semantics,
which can shift the displayed day for users in UTC+N. Update formatRegisteredAt
in utils.ts to format the date in UTC or via an explicit UTC-aware
parser/formatter (for example using parseISO with a UTC-safe format path), and
keep the invalid-date fallback behavior unchanged.
| export async function readDeviceJson<T>(key: string): Promise<T | null> { | ||
| const raw = await AsyncStorage.getItem(key); | ||
| if (raw == null) return null; | ||
| try { | ||
| return JSON.parse(raw); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Не выдавайте JSON.parse за T без валидации.
Здесь граница хранения фактически возвращает unknown, но helper объявляет результат как T. Из-за этого любой валидный, но несовместимый JSON пройдет как Profile/MyActivity[]: например, src/entities/profile/storage.ts сразу отдает результат в readProfile(), а src/entities/activity/local.ts дальше работает с ним как с массивом. После обновления схемы или при поврежденных локальных данных это даст уже не null, а поломку в downstream-логике. Лучше либо возвращать unknown, либо принимать валидатор/схему и отбрасывать несовместимую форму прямо здесь.
🤖 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 `@src/shared/lib/storage.ts` around lines 16 - 20, `readDeviceJson` currently
casts `JSON.parse` directly to `T`, so incompatible but valid JSON can leak into
callers like `readProfile` and the activity storage flow. Change the helper to
stop promising `T` without validation: either return `unknown` from
`readDeviceJson` or accept a validator/schema and verify the parsed value before
returning it. Then update the downstream uses in `storage.ts` callers to handle
rejected/invalid shapes safely instead of trusting the parsed value as a typed
entity.
| try { | ||
| const result = await ImagePicker.launchImageLibraryAsync({ | ||
| mediaTypes: ["images"], | ||
| quality: 1, | ||
| }); | ||
| if (!result.canceled) onChange(result.assets[0].uri); | ||
| } catch {} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Не проглатывайте ошибки выбора фото.
Если доступ к фото запрещён или launchImageLibraryAsync падает, нажатие на кнопку просто ничего не делает. Для пользовательского действия это слишком тихий сбой — покажите хотя бы inline/root error и отдельно оставьте тихой только отмену выбора.
🤖 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 `@src/shared/ui/PhotoPicker.tsx` around lines 36 - 42, The photo selection flow
in PhotoPicker is swallowing real failures, so only user-initiated cancel should
stay quiet. Update the try/catch around ImagePicker.launchImageLibraryAsync to
treat result.canceled as a silent no-op, but in the catch path surface the error
through the component’s existing inline/root error handling instead of ignoring
it. Keep the fix localized to PhotoPicker and preserve onChange only for
successful selections.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation