Skip to content

Feat/profile - #9

Open
anu-mdl wants to merge 10 commits into
mainfrom
feat/profile
Open

Feat/profile#9
anu-mdl wants to merge 10 commits into
mainfrom
feat/profile

Conversation

@anu-mdl

@anu-mdl anu-mdl commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added a new profile section with viewing, editing, and logout actions.
    • Users can now add a profile photo from the device gallery.
    • Added support for saving “my activities” and “my responses” for quick access.
    • Expanded profile forms with social links, category selection, and better date display.
  • Bug Fixes

    • Improved handling of empty profile fields and missing saved data.
    • Added safer behavior for opening links and loading stored JSON data.
  • Documentation

    • Updated project notes and test coverage descriptions for profile behavior.

anu-mdl and others added 10 commits June 28, 2026 18:05
Выбор фото профиля через системную галерею (локальный 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>
@anu-mdl
anu-mdl requested a review from lukivan8 June 28, 2026 19:37
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a complete profile feature: types, Zod schema, device JSON storage, React Query hooks, form mappers/utils, EditProfileForm component, useEditProfileForm hook, and ProfilePage/EditProfilePage screens. Extends shared storage with JSON helpers, adds Avatar, MultiSelect, and PhotoPicker UI components, and wires silent device-local tracking into activity creation and response submission flows.

Changes

Profile feature and local tracking

Layer / File(s) Summary
Shared device JSON storage primitives
src/shared/lib/storage.ts
Adds readDeviceJson, writeDeviceJson, and removeDeviceValue for arbitrary on-device JSON persistence.
Profile types, schema, and mappers
src/entities/profile/types.ts, src/entities/profile/schemas.ts, src/entities/profile/mappers.ts, src/entities/profile/mappers.test.ts, src/entities/profile/schemas.test.ts
Defines Profile, SaveProfileInput, SocialNetwork, ProfileSocials; Zod profileSchema with empty-string→null normalization; mapProfileToForm mapper; corresponding tests.
Profile device storage and React Query hooks
src/entities/profile/storage.ts, src/entities/profile/hooks.ts
Implements readProfile, writeProfile (with stable registeredAt), removeProfile; wires useProfile, useSaveProfile, useResetProfile with query invalidation.
Profile UI utilities
src/entities/profile/utils.ts, src/entities/profile/utils.test.ts
Exports categoryOptions, socialOptions, getFilledSocials (ordered non-null socials), formatRegisteredAt (DD.MM.YYYY with fallback); tests included.
Shared UI: Avatar, MultiSelect, PhotoPicker
src/shared/ui/Avatar.tsx, src/shared/ui/MultiSelect.tsx, src/shared/ui/PhotoPicker.tsx, package.json, app.json
Adds Avatar (photo/initial fallback), MultiSelect (toggle-button), PhotoPicker (expo-image-picker); adds dependency and plugin config.
Edit profile form feature
src/features/edit-profile/useEditProfileForm.ts, src/features/edit-profile/EditProfileForm.tsx
useEditProfileForm wires react-hook-form + zodResolver, prefills from stored profile, submits via useSaveProfile, navigates on success. EditProfileForm renders all fields including dynamic socials and saving state.
Device-local activity and response tracking
src/entities/activity/local.ts, src/entities/activity/hooks.ts, src/entities/participant/local.ts, src/entities/participant/hooks.ts, src/features/create-activity/useCreateActivityForm.ts, src/features/respond-to-activity/useRespondToActivityForm.ts
MyActivity/MyResponse types with deduplicated device persistence; useMyActivities, useTrackMyActivity, useMyResponses, useTrackMyResponse hooks; silent rememberCreated/rememberResponse calls wired into form submission.
Profile view and edit screens
app/profile/index.tsx, app/profile/edit.tsx, app/_layout.tsx, TESTS.md
ProfilePage with EmptyProfile, ProfileHeader, Stats, Socials, MyActivities; EditProfilePage; /profile nav link; test docs added.

Project documentation update

Layer / File(s) Summary
about_the_project.md updates
docs/about_the_project.md
Target audience restructured; open-creation tradeoff section added; moderation policy rewritten with phased approach; MVP scope items updated.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • linkerkz/project-e#6: This PR extends src/features/respond-to-activity/useRespondToActivityForm.ts and src/shared/lib/storage.ts that were introduced or modified in PR #6.

Poem

🐇 A profile springs up from the ground,
With avatar, socials, and stats all around!
My activities tracked without a fuss,
Null becomes empty—no drama, no muss.
The rabbit hops proud through each new screen,
The cleanest profile page ever seen! 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too vague and doesn't describe the actual changes beyond a profile-related feature. Use a specific title that names the main change, such as adding profile screens, storage, and edit form support.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/profile

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 в "" для instagram/whatsapp/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

📥 Commits

Reviewing files that changed from the base of the PR and between 5d4b49a and 7579054.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • TESTS.md
  • app.json
  • app/_layout.tsx
  • app/profile/edit.tsx
  • app/profile/index.tsx
  • docs/about_the_project.md
  • package.json
  • src/entities/activity/hooks.ts
  • src/entities/activity/local.ts
  • src/entities/participant/hooks.ts
  • src/entities/participant/local.ts
  • src/entities/profile/hooks.ts
  • src/entities/profile/mappers.test.ts
  • src/entities/profile/mappers.ts
  • src/entities/profile/schemas.test.ts
  • src/entities/profile/schemas.ts
  • src/entities/profile/storage.ts
  • src/entities/profile/types.ts
  • src/entities/profile/utils.test.ts
  • src/entities/profile/utils.ts
  • src/features/create-activity/useCreateActivityForm.ts
  • src/features/edit-profile/EditProfileForm.tsx
  • src/features/edit-profile/useEditProfileForm.ts
  • src/features/respond-to-activity/useRespondToActivityForm.ts
  • src/shared/lib/storage.ts
  • src/shared/ui/Avatar.tsx
  • src/shared/ui/MultiSelect.tsx
  • src/shared/ui/PhotoPicker.tsx

Comment thread app/profile/edit.tsx
Comment on lines +13 to +17
return (
<Page>
<BackLink />
<Text className="text-2xl font-bold">Редактировать профиль</Text>
<EditProfileForm form={form} isSaving={isSaving} onSubmit={submit} />

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

Заголовок не совпадает со сценарием создания профиля.

На этот экран ведёт ссылка «Создать профиль» из 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.

Comment thread app/profile/index.tsx
Comment on lines +24 to +37
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 ?? []} />

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

Не показывайте нули и пустое состояние до завершения локальных 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.

Comment thread app/profile/index.tsx
<Socials profile={profile} />
<MyActivities items={myActivities.data ?? []} />
<TextLink href="/profile/edit">[Редактировать профиль]</TextLink>
<Button title="Выйти" onPress={() => reset.mutate()} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 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.

Comment on lines +14 to +27
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 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.

Comment on lines +4 to +12
// Локальный список активностей, где устройство откликнулось — данные для счётчика
// «Посещено» в профиле. Серверного аккаунта нет, это device-local проекция отклика.
export type MyResponse = {
slug: string;
title: string;
city: string;
startsAt: string;
status: ParticipantStatus;
};

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 | 🟠 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.

Comment on lines +3 to +4
const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v);
const optionalText = z.preprocess(emptyToNull, z.string().nullable());

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

Нормализуйте строки из пробелов так же, как пустые.

Сейчас 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.

Suggested change
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.

Comment on lines +14 to +29
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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +44 to +51
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()}`;
}

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 | 🟠 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.

Comment thread src/shared/lib/storage.ts
Comment on lines +16 to +20
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +36 to +42
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 1,
});
if (!result.canceled) onChange(result.assets[0].uri);
} catch {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant