Skip to content

Comments

Notification and Font Updates#51

Closed
fortune710 wants to merge 17 commits intomainfrom
staging
Closed

Notification and Font Updates#51
fortune710 wants to merge 17 commits intomainfrom
staging

Conversation

@fortune710
Copy link
Owner

@fortune710 fortune710 commented Feb 8, 2026

Note

Medium Risk
Touches push notification processing, analytics emission, and realtime data updates across backend/frontend; failures could affect notification delivery/metrics accuracy or cause duplicated/missed entry updates if edge cases aren’t handled.

Overview
Improves notification observability and UX by adding PostHog tracking for push notifications (captured on enqueue and on successful send, with recipient user/email resolved from push tokens) and by tweaking entry-share push payloads to deep-link to vault with a refresh hint.

On the client, adds a global ErrorBoundary that reports crashes to PostHog with device/app metadata, adds realtime entry ingestion for newly shared entries with an unseen entries indicator in Vault, and enables pull-to-refresh + refresh-on-deeplink for Friends.

Also includes assorted UX/polish changes: local notifications on entry save and friend-request acceptance, suggested-friends caching/sync via device storage, safer groupBy (local-date grouping + null guards), invite acceptance now looks up inviter via profiles.invite_code, plus minor toast/layout scaling and font-family updates across settings screens.

Written by Cursor Bugbot for commit c3f8982. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • Added error boundary for improved crash recovery with user-friendly recovery UI.
    • Local notifications now alert you when entries are shared.
    • Pull-to-refresh functionality added to friends list.
    • Real-time indicator displays when new entries are received.
    • Invite code system introduced for streamlined friend invitations.
  • Bug Fixes

    • Enhanced notification delivery edge case handling.
    • Improved error logging and recovery.
  • Style

    • Updated typography consistency across settings screens with standardized font families.

@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link

vercel bot commented Feb 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
keepsafe Ready Ready Preview, Comment Feb 8, 2026 6:32am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 8, 2026

Walkthrough

This pull request implements notification telemetry tracking, invite code infrastructure, unseen entry tracking with refresh mechanisms, and comprehensive typography standardization. Changes span backend notification services with PostHog integration, frontend social features (friends, invites), vault entry management with real-time updates, and database schema extensions for invite codes.

Changes

Cohort / File(s) Summary
Backend Notification Services
backend/services/friend_service.py, backend/services/notification_enqueue_service.py, backend/services/notification_service.py
Enhanced recipient filtering with edge-case logging, removed legacy friend-fetching path, added PostHog telemetry hooks for notification enqueue/send events with user metadata resolution, updated notification text to exclude owner and adjust formatting, changed vault URL to include refresh parameter.
Backend Testing
backend/test_enqueue_service.py
Added new asynchronous test script for NotificationEnqueueService with configurable entry data, sharing settings, and interactive data lookup from Supabase.
Frontend Layout & Error Handling
frontend/app/_layout.tsx, frontend/components/error-boundary.tsx
Introduced centralized ErrorBoundary wrapper with PostHog error reporting, device metadata collection, and fallback UI with retry functionality.
Frontend Vault & Entry Management
frontend/app/vault.tsx, frontend/app/capture/details.tsx, frontend/app/capture/index.tsx, frontend/components/new-entries-indicator.tsx
Added unseen entry tracking with viewability detection, scroll-to-top indicator, local notifications on entry save, and responsive button scaling. Implemented real-time synchronization with PostgreSQL channel listener and offline recovery.
Frontend Social Features
frontend/app/friends.tsx, frontend/app/invite/[id].tsx, frontend/hooks/use-friends.ts, frontend/hooks/use-invite-acceptance.ts
Added pull-to-refresh, friend request acceptance notifications, invite code-based lookups (renamed from inviteId), explicit inviter profile resolution, and refreshFriends method for concurrent query refetching.
Frontend Settings & Typography
frontend/app/settings/*, frontend/components/toast-message.tsx
Standardized typography across settings screens by replacing fontWeight with explicit fontFamily values (Outfit-SemiBold, Jost-Regular, Outfit-Bold, Outfit-Medium), restructured toast message layout with dedicated container styling.
Frontend Core Services & Utilities
frontend/services/friend-service.ts, frontend/services/device-storage.ts, frontend/services/local-notification-service.ts, frontend/services/search-service.ts, frontend/hooks/use-suggested-friends.ts, frontend/hooks/use-user-entries.ts, frontend/lib/utils.ts, frontend/package.json
Refactored friend-service to fetch fresh data from API with contact-based filtering, added deduplication in device storage, introduced badge sanitization in local notifications, enhanced groupBy utility with date-aware grouping, upgraded package version and added react-native-error-boundary dependency.
Database Migrations
frontend/supabase/migrations/20250825200450_misty_boat.sql, frontend/supabase/migrations/20260202060724_add_invite_code_to_profiles.sql
Added invite_code column to profiles table with UNIQUE constraint, implemented auto-generation via trigger using 8-character alphanumeric codes, backfilled existing profiles with generated codes and retry logic for uniqueness violations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Invites now hop with coded grace,
Entries seen light up the place,
Fonts aligned in perfect style,
PostHog tracks each user's smile.
Boundaries catch the bugs that flee,
Real-time friends—just wait and see!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.52% 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 PR title 'Notification and Font Updates' is too vague and generic. While the changeset includes notification enhancements and font styling changes, the title fails to convey the primary focus or most significant changes (e.g., PostHog telemetry integration, invite code workflow, real-time sync features) and uses overly broad phrasing that could apply to many different changesets. Consider a more specific title that highlights the main technical change, such as 'Add PostHog telemetry to notification service' or 'Implement real-time entry synchronization and invite codes' to better reflect the primary objectives.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch staging

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 and usage tips.

@fortune710 fortune710 closed this Feb 8, 2026
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

This PR is being reviewed by Cursor Bugbot

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

# Determine recipients based on sharing settings
recipient_user_ids: List[str] = []
# Remove owner from the list of recipients
recipient_user_ids: List[str] = [user_id for user_id in shared_with if user_id != owner_id] if isinstance(shared_with, list) else []
Copy link

Choose a reason for hiding this comment

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

Shared-with-everyone notifications no longer sent to friends

High Severity

The logic for handling shared_with_everyone entries was removed. Previously, when an entry had shared_with_everyone=True, the code fetched all friends via _get_user_friends() and sent notifications to them. Now the code only uses the shared_with list, completely ignoring the shared_with_everyone flag. Entries shared with "everyone" will not trigger notifications to any friends unless they're also explicitly in the shared_with array. The _get_user_friends helper method was also deleted.

Fix in Cursor Fix in Web

message: `${shareOptions.message}\n\n${shareOptions.url}`,
url: shareOptions.url,
});
const result = await Share.share(shareOptions);
Copy link

Choose a reason for hiding this comment

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

Share invite link missing URL on Android devices

Medium Severity

The Share.share() call was changed from explicitly concatenating the URL into the message to passing shareOptions directly. On Android, the url property in React Native's Share API is often ignored and not displayed. The original code used message: \${shareOptions.message}\n\n${shareOptions.url}`to ensure the invite link appeared in the shared content on all platforms. The existing implementation infriend-service.ts` still uses the correct pattern.

Fix in Cursor Fix in Web

.select(`*, profile:${TABLES.PROFILES}(*)`)
.contains('shared_with', [user.id])
.gte('created_at', tenMinutesAgo)
.order('created_at', { ascending: false })
Copy link

Choose a reason for hiding this comment

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

Offline reconnection misses shared-with-everyone entries

Medium Severity

The offline reconnection handler only queries entries where shared_with contains the user ID (.contains('shared_with', [user.id])), but doesn't account for entries with shared_with_everyone=true. This is inconsistent with the realtime handler at lines 127-131, which correctly checks both sharedWithEveryone and sharedWith.includes(user.id). Users reconnecting after being offline will miss entries that were shared with everyone during their offline period.

Fix in Cursor Fix in Web

inviterName: inviterName,
inviterEmail: inviterProfile?.email || '',
inviterAvatar: inviterProfile?.avatar_url || 'https://images.pexels.com/photos/1239291/pexels-photo-1239291.jpeg?auto=compress&cs=tinysrgb&w=200',
inviterAvatar: inviterProfile?.avatar_url || getDefaultAvatarUrl(inviterName),
Copy link

Choose a reason for hiding this comment

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

Missing null check causes crash with invalid invite code

High Severity

The query changed from using .single() (on the old INVITES table) to .maybeSingle() (on PROFILES table), but the null check wasn't updated. With .maybeSingle(), when no profile matches the invite code, the result is { data: null, error: null }. The code only checks if (error) at line 94, which passes when error is null. At line 111, accessing inviterProfile.id will throw a TypeError because inviterProfile is null. Users visiting invalid invite links will experience a crash.

Fix in Cursor Fix in Web

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (13)
frontend/hooks/use-friend-invitation.ts (2)

20-20: ⚠️ Potential issue | 🟠 Major

inviteLink is always truthy — the guard on lines 24/38 is ineffective.

inviteLink is built from a template literal, so even when profile?.invite_code is undefined, it evaluates to something like "…/invite/undefined" — a truthy string. The if (!inviteLink) guards never trigger, and the user could copy/share a malformed URL.

Guard against a missing invite code directly:

Proposed fix
- const inviteLink = `${baseUrl}/invite/${profile?.invite_code}`
+ const inviteCode = profile?.invite_code;
+ const inviteLink = inviteCode ? `${baseUrl}/invite/${inviteCode}` : '';

Also applies to: 24-26, 38-40


42-50: ⚠️ Potential issue | 🟠 Major

url is iOS-only in React Native's Share.share — Android users won't see the link.

On Android, Share.share only uses message; the url property is ignored. The current message doesn't contain the actual URL, so Android users will share just the text with no invite link.

Proposed fix — append URL to message
  const shareOptions: ShareOptions = {
    title: 'Join me on Keepsafe',
-   message: 'I\'d love to share moments with you on Keepsafe! Join me using this link:',
+   message: `I'd love to share moments with you on Keepsafe! Join me using this link: ${inviteLink}`,
    url: inviteLink,
    ...options,
  };
frontend/hooks/use-invite-acceptance.ts (3)

84-121: ⚠️ Potential issue | 🔴 Critical

Null-pointer crash when invite_code doesn't match any profile.

maybeSingle() returns { data: null, error: null } when no row matches. The code checks for error on line 94 but never checks whether invite itself is null. The cast on line 106 will make inviterProfile null, and line 111 accesses inviterProfile.id without optional chaining — causing a TypeError at runtime for any invalid/unknown invite code.

🐛 Proposed fix: add a null check for the query result
       if (error) {
         setError('This invitation link is invalid or has expired.');
         return null;
       }

+      if (!invite) {
+        setError('This invitation link is invalid or has expired.');
+        return null;
+      }
+
       //const isUsed = invite.current_uses >= invite.max_uses;

163-183: ⚠️ Potential issue | 🟠 Major

declineInvite is a no-op — always returns success without performing any action.

The try body is empty. The UI in [id].tsx will show "Invitation Declined" and navigate the user away, but no backend state is modified. If declining should have an effect (e.g., deleting a pending friendship, recording a rejection), this needs implementation. If it's intentionally deferred, consider adding a // TODO comment and/or not exposing the decline button until it's functional.


48-59: ⚠️ Potential issue | 🟡 Minor

Silently succeeding on duplicate friendship masks the "already connected" state from the user.

When an existing friendship is found, the mutation returns a result with friendshipId but the caller (line 147-151) always shows "Invitation accepted successfully!" — the user never sees the 'You are already connected with this user' message. Consider either throwing an error for duplicates or propagating the message to the caller so it can display appropriate feedback.

frontend/supabase/migrations/20250825200450_misty_boat.sql (1)

340-365: ⚠️ Potential issue | 🟡 Minor

Trigger does not handle unique constraint collisions on invite_code.

set_profile_invite_code() assigns a generated code but has no retry logic if the code collides with an existing one. With 62⁸ ≈ 218 trillion possibilities, collisions are extremely unlikely at small scale, but the INSERT will hard-fail with a unique violation if it ever happens. The backfill migration (the second file) correctly retries on unique_violation—consider applying similar retry logic here, or at minimum document the accepted risk.

frontend/services/search-service.ts (1)

80-97: ⚠️ Potential issue | 🟠 Major

onError is invoked twice for non-OK responses.

When the response is not OK:

  1. Line 87 calls onError?.(error)
  2. Line 97 throws the error
  3. The catch block at Line 171 calls onError(error) again

This results in duplicate error handling for the consumer.

Proposed fix: remove the early onError call since the throw will reach the catch block
       if (!response.ok) {
         const error = new Error(`Search request failed with status ${response.status}`);
         logger.error('SearchService: non-OK response', {
           status: response.status,
           statusText: response.statusText,
-          
         });
-        onError?.(error);
         try {
           const errorText = await response.text();
           logger.error('SearchService: non-OK response body', {
             status: response.status,
             body: errorText,
           });
         } catch {
           // ignore body-read errors here
         }
         throw error;
       }
frontend/app/settings/blocked-users.tsx (1)

79-97: ⚠️ Potential issue | 🟡 Minor

Avatar placeholder branch is now unreachable.

getDefaultAvatarUrl always returns a non-empty URL string, so avatarUrl on Line 79 is always truthy. The else branch (Lines 91-97) with the placeholder initial is dead code.

frontend/app/capture/details.tsx (1)

253-261: ⚠️ Potential issue | 🟡 Minor

Duplicate toast on every early return path.

Line 260 fires unconditionally within the outer if, so the user always sees two toasts when validation fails. If !hasSelectedSharing(), they get "Please select who to share..." and "Cannot save entry". This appears to be a pre-existing bug in the function you're modifying.

Proposed fix: remove the duplicate toast
     if (!capture || !user || !hasSelectedSharing()) {
       if (!hasSelectedSharing()) {
         toast('Please select who to share this entry with', 'error');
       } else {
         toast('Cannot save entry', 'error');
       }
-      toast('Cannot save entry', 'error');
       return;
     }
frontend/hooks/use-user-entries.ts (1)

80-80: ⚠️ Potential issue | 🟡 Minor

Misleading staleTime comment.

The comment says "5 minutes" but the value is 1000 * 60 * 10 which is 10 minutes.

✏️ Proposed fix
-    staleTime: 1000 * 60 * 10, // 5 minutes
+    staleTime: 1000 * 60 * 10, // 10 minutes
frontend/app/friends.tsx (1)

68-79: ⚠️ Potential issue | 🟡 Minor

Sending a local notification to yourself on accepting a friend request is unusual.

LocalNotificationService.sendNotification fires a device notification to the current user who just tapped "Accept." Typically the other party should be notified. If this is intentional as an in-app confirmation, a toast (like the other handlers use) would be less intrusive and more consistent. The await also means the UI blocks until the notification is dispatched (including the permission prompt on first use).

Consider replacing with a toast for consistency with the other handlers, or at least making the notification fire-and-forget (remove await).

frontend/services/friend-service.ts (2)

316-322: ⚠️ Potential issue | 🟠 Major

PostgREST filter injection via unsanitized email/phone values in .or() / .in.() clauses.

Emails and phone numbers are interpolated directly into the PostgREST filter string. The isValidEmail regex (/^[^\s@]+@[^\s@]+\.[^\s@]+$/) permits commas and parentheses in the local part (e.g., a]b,c)d@evil.com), which can break or manipulate the .in.(...) filter syntax when joined with commas.

For example, a contact email like x]y,z)@evil.com would produce:

email.in.(x]y,z)@evil.com,legit@example.com)

This corrupts the PostgREST filter and may produce unexpected results or errors.

Mitigations (pick one or combine):

  1. Tighten the email regex to reject commas, parentheses, and brackets in the local part.
  2. Escape or URL-encode filter values before interpolation.
  3. Use parameterized RPC calls instead of client-side filter string construction.
Option 1: Tighten email regex
  private static isValidEmail(email: string): boolean {
    if (!email || typeof email !== 'string') return false;
-   // RFC 5322 compliant email regex (simplified but practical)
-   const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+   // Practical email regex that excludes characters unsafe for PostgREST filters
+   const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/;
    return emailRegex.test(email.trim()) && email.length <= 254;
  }

156-177: ⚠️ Potential issue | 🟠 Major

Error and null checks happen after data is already consumed.

Line 156 uses data?.map(...) (safe due to optional chaining), but the error check at Line 175 and the null-data guard at Line 173 both come after the map. If Supabase returns { data: null, error: someError }, the code returns [] instead of throwing, silently swallowing the error.

Reorder to check error first, then null-data:

Proposed fix
-      const friends: FriendWithProfile[] = data?.map(friend => {
+      if (error) {
+        throw new Error(error.message);
+      }
+
+      if (!data) return [];
+
+      const friends: FriendWithProfile[] = data.map(friend => {
         const { 
           user_profile: user, 
           friend_profile: friend_, 
           ...friend_record 
         } = friend;
 
         const profile = friend.friend_id === userId ? 
         user : friend_;
 
         return {
           ...friend_record,
           friend_profile: profile
         }
-
       })
 
-      if (!data) return [];
-
-      if (error) {
-        throw new Error(error.message);
-      }
-
       // Cache the friends data
-      if (data) {
-        await deviceStorage.setFriends(userId, friends);
-      }
+      await deviceStorage.setFriends(userId, friends);
 
       return friends as FriendWithProfile[];
🤖 Fix all issues with AI agents
In `@backend/services/notification_enqueue_service.py`:
- Around line 142-144: The body generation currently builds entry_type_display
as f"a {entry_type.capitalize()}" with only "audio" special-cased, causing "a
Entry" for the default "entry"; update the logic in
backend/services/notification_enqueue_service.py where entry_type_display is set
to compute the correct indefinite article by checking entry_type.lower()[0] in
"aeiou" (use 'an' for vowels, 'a' otherwise), then build entry_type_display
using the article and entry_type.lower() (or entry_type if you need different
casing), and leave the rest of the notification body (owner_name and title)
unchanged so the message becomes e.g. "an entry" or "a video" correctly.

In `@backend/services/notification_service.py`:
- Around line 109-117: The telemetry call in enqueue_notification synchronously
invokes _capture_notification_enqueued_event which performs blocking DB work via
_get_user_info_from_tokens, blocking the caller; update enqueue_notification to
offload that call to a background thread (use asyncio.to_thread) or make
enqueue_notification async and await asyncio.to_thread for
_capture_notification_enqueued_event, mirroring how the sent path handles
telemetry, so the DB queries no longer block the enqueue caller.
- Around line 779-780: Do not send raw user email to PostHog: stop setting
properties["email"] from the local email variable; instead only send non-PII
identifiers (e.g., user_id) or a pseudonymous value. Update the logic around the
properties dict (the properties variable and the email variable) to either
remove the email assignment entirely or gate it behind an explicit
config/consent flag (e.g., POSTHOG_ALLOW_PII) and/or replace the raw email with
a one-way hash (sha256) before adding to properties to ensure PII isn’t
transmitted by default.
- Around line 708-712: In the recipients loop in send/enqueue logic, the local
variable email is retrieved from token_user_info but never passed into the
enqueued event payload; update the enqueued-event creation (the enqueued event
handler code that mirrors the sent handler) to include email alongside user_id
(use token_user_info.get(token, {}).get("email")) so the enqueued event contains
the same email field the sent handler expects, or if email is truly unnecessary
remove the unused assignment; locate the loop iterating "for token in
recipients" and the enqueued event construction to make the change.
- Around line 248-257: The telemetry call via await
asyncio.to_thread(self._capture_notification_sent_event, ...) can raise and
bubble into the outer except, incorrectly incrementing stats["failed"]; wrap the
telemetry invocation in its own try/except so any exceptions from
asyncio.to_thread or self._capture_notification_sent_event are caught and logged
(or swallowed) without affecting stats, e.g., call asyncio.to_thread(...) inside
a try block and on failure log the exception and continue; ensure this change
references the existing asyncio.to_thread call and the
_capture_notification_sent_event method and does not alter the surrounding
success path that deletes the sent notification.

In `@backend/test_enqueue_service.py`:
- Around line 96-101: The validation guard in test_enqueue_service.py uses
placeholder strings that no longer match the constants; update the check to
validate against the actual default placeholders or the environment-variable
defaults so it can fail when not configured: change the condition that
references ENTRY_ID and OWNER_USER_ID to compare them to the placeholder/default
values used when loading from env (or check for empty/UUID-zero patterns) so the
guard triggers when ENTRY_ID or OWNER_USER_ID still hold the unconfigured
defaults; reference the constants ENTRY_ID and OWNER_USER_ID and adjust the
conditional accordingly.
- Around line 29-37: Replace the hardcoded real UUIDs and environment-specific
values in the test file: change ENTRY_ID, OWNER_USER_ID, and entries inside
SHARED_WITH_USER_IDS to non-production placeholders or read them from test
fixtures/environment variables, and keep ENTRY_TYPE as a controlled test
constant; update any test setup that references these variables (e.g.,
test_enqueue_service.py symbols ENTRY_ID, OWNER_USER_ID, SHARED_WITH_USER_IDS,
ENTRY_TYPE) so tests use injectable/fixture-provided IDs or os.getenv values
instead of committed real UUIDs.

In `@frontend/app/friends.tsx`:
- Around line 109-119: The comment is incorrect: handleRefresh only calls
refreshFriends() while refetchSuggestedFriends (destructured earlier) is unused;
update handleRefresh to call and await refetchSuggestedFriends as well (e.g.,
await Promise.all or sequential awaits inside handleRefresh) and change the
inline comment to accurately state both friends and suggested friends are being
refreshed; reference the handleRefresh function and the refetchSuggestedFriends
identifier so you update the correct spot.

In `@frontend/app/vault.tsx`:
- Around line 110-126: onViewableItemsChanged is being recreated because its
dependency array includes entriesByDate and unseenEntryIds, which breaks
FlashList; make the callback stable by moving its function to a ref-backed
handler: create refs (e.g., entriesByDateRef, unseenEntryIdsRef) that are
updated whenever entriesByDate or unseenEntryIds change, then define
onViewableItemsChanged once (no deps) using useCallback or store it in a ref so
it never changes; inside that stable handler read from entriesByDateRef.current
and unseenEntryIdsRef.current, compute visibleEntryIds as before and call
markEntriesAsSeen(visibleEntryIds). Ensure markEntriesAsSeen reference is stable
or read from a ref if it can change.

In `@frontend/components/error-boundary.tsx`:
- Around line 153-159: The style objects title, errorTitle, and buttonText in
frontend/components/error-boundary.tsx currently include both fontWeight and
fontFamily (e.g., fontWeight: '600' alongside fontFamily: 'Outfit-SemiBold');
remove the fontWeight entries from those style objects so only fontFamily is
used (aligning with the rest of the PR and avoiding Android rendering issues),
leaving title, errorTitle, and buttonText to specify only fontFamily and other
properties.
- Around line 153-159: The title style in the error boundary currently forces a
custom fontFamily ('Outfit-SemiBold'), which may not be available when the
boundary renders; update the title style in
frontend/components/error-boundary.tsx (the title style object) to use a safe
font stack or remove the custom font reference so it falls back to system fonts
(e.g., use a stacked value like 'Outfit-SemiBold, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial' or simply omit fontFamily) ensuring
the fallback UI renders legibly even if custom fonts haven't loaded.

In `@frontend/hooks/use-user-entries.ts`:
- Around line 95-101: The forEach callback in markEntriesAsSeen implicitly
returns the boolean from Set.delete (violating the linter); update the callback
to use a block body or a for...of loop so it does not return a value—e.g.,
within the setUnseenEntryIds updater for the updated Set, replace
entryIds.forEach(id => updated.delete(id)) with a non-returning form
(entryIds.forEach(id => { updated.delete(id); }) or for (const id of entryIds) {
updated.delete(id); }) ensuring the function name markEntriesAsSeen and state
updater setUnseenEntryIds are unchanged.
- Around line 226-232: Replace the async forEach usage
(missedEntries.forEach(async (entry) => { await deviceStorage.addEntry(user.id,
entry) ... })) with a Promise.all over missedEntries.map(...) so all addEntry
promises are tracked and failures are aggregated; either make the enclosing
.then() callback async and await Promise.all(mappedPromises) or return
Promise.all(mappedPromises) from the current .then() chain, and consolidate
error handling to catch and log any rejection from Promise.all for
deviceStorage.addEntry calls.

In `@frontend/lib/utils.ts`:
- Around line 90-106: groupBy, hasEntries, and getEntryCount produce
inconsistent date keys because groupBy builds YYYY-MM-DD from local Date methods
while the others use UTC via toISOString; fix this by deriving all date keys
through the shared timezone-aware helper getTimefromTimezone (call
getTimefromTimezone with the original timestamp or year/month/day inputs, then
use .toISOString().split('T')[0]) instead of using getFullYear/getMonth/getDate
or new Date(...).toISOString() conversions directly; update the groupBy
implementation (where it checks keys 'updated_at'/'created_at') to use
getTimefromTimezone, and ensure hasEntries and getEntryCount use the same
getTimefromTimezone-based YYYY-MM-DD extraction so all utilities produce
identical keys.

In `@frontend/services/search-service.ts`:
- Around line 82-86: The logger.error call in SearchService includes a trailing
comma after statusText, indicating a missing property; update the object passed
to logger.error (in the SearchService where logger.error is called) to either
remove the stray comma or add the intended field — e.g., include response.url
(or responseText/responseBody if you capture the body) so the log object
contains status, statusText, and the missing identifier, and ensure no stray
trailing comma remains.

In `@frontend/supabase/migrations/20250825200450_misty_boat.sql`:
- Around line 78-90: The generate_invite_code() function can pick index 0 due to
ceil(random()*62) when random() == 0; update the selection expression inside the
substring call to use a 1-based index by replacing ceil(random()*62) with
floor(random()*62)::int + 1 so the range is always 1..62 (update the expression
within the SELECT in function generate_invite_code to use
floor(random()*62)::int + 1).
🧹 Nitpick comments (18)
frontend/hooks/use-friend-invitation.ts (1)

53-56: Inconsistent error handling: shareInviteLink re-throws while copyInviteLink swallows errors.

copyInviteLink catches and returns false, but shareInviteLink logs and re-throws. Since both return Promise<boolean>, callers likely expect uniform behavior. Consider returning false instead of re-throwing, or document that shareInviteLink can throw.

frontend/hooks/use-invite-acceptance.ts (2)

130-161: Confusing parameter naming: inviteeId is actually the inviter's ID.

In acceptInvite, the first parameter inviteeId receives inviteData.id from the caller (the inviter's profile ID), while userId is the current user. This makes the PostHog event on lines 141-144 accidentally correct (inviter_id: inviteeId) but the naming is misleading throughout the mutation as well (lines 39-46, 51, 66-67).

Consider renaming the parameter to inviterId to match its semantic meaning and avoid future bugs.


99-104: Remove commented-out dead code.

These commented-out lines for isUsed checking add noise. If this feature is planned for later, track it as a TODO or issue rather than leaving dead code in the file.

backend/test_enqueue_service.py (1)

1-226: Consider whether this manual test script belongs in the repository.

There are already proper integration tests at backend/tests/test_notification_enqueue_service_integration.py with mocked dependencies. This script hits a real database without mocks, has no assertions, and is essentially a developer utility. If it's needed, consider moving it to a scripts/ or tools/ directory and adding it to .gitignore or documenting its purpose clearly.

backend/services/notification_service.py (2)

636-688: Two DB round-trips per telemetry call — consider caching or batching.

_get_user_info_from_tokens issues two separate DB queries (push_tokens + profiles) every time telemetry fires. Since both enqueued and sent events call this, a single notification lifecycle triggers four queries purely for analytics. Consider caching the token→user mapping (it rarely changes) or passing user info through the message metadata to avoid these lookups entirely.


690-800: Significant duplication between _capture_notification_enqueued_event and _capture_notification_sent_event.

These two methods are nearly identical — only the event name and the email inclusion differ. Extract common logic into a shared helper.

Sketch
+    def _capture_notification_event(
+        self,
+        event_name: str,
+        metadata: Optional[Dict[str, Any]],
+        recipients: List[str],
+        title: str,
+        body: str,
+        priority: str,
+        include_email: bool = False,
+    ) -> None:
+        if not self.posthog_client:
+            return
+        try:
+            notification_type = metadata.get("notification_type", "unknown") if metadata else "unknown"
+            token_user_info = self._get_user_info_from_tokens(recipients)
+            for token in recipients:
+                user_info = token_user_info.get(token, {})
+                user_id = user_info.get("user_id")
+                email = user_info.get("email")
+                properties: Dict[str, Any] = {
+                    "notification_type": notification_type,
+                    "title": title,
+                    "body": body,
+                    "priority": priority,
+                    "recipient_count": len(recipients),
+                }
+                if user_id:
+                    properties["user_id"] = user_id
+                if include_email and email:
+                    properties["email"] = email
+                if metadata:
+                    for key, value in metadata.items():
+                        if key not in properties and key != "notification_type":
+                            properties[f"metadata_{key}"] = value
+                distinct_id = user_id if user_id else f"token_{token[:8]}"
+                self.posthog_client.capture(distinct_id=distinct_id, event=event_name, properties=properties)
+            self.posthog_client.flush()
+        except Exception as e:
+            logger.error(f"Error capturing {event_name} event to PostHog: {str(e)}")
frontend/app/settings/index.tsx (2)

273-278: Avatar fallback is inconsistent with profile.tsx.

This file now uses getDefaultAvatarUrl() for the avatar fallback (good), but frontend/app/settings/profile.tsx at Line 110 still uses a hardcoded Pexels URL as the fallback. Consider applying the same getDefaultAvatarUrl pattern there for consistency.


420-424: Unused profileEmail style.

Per the summary, the profile email line was removed from the JSX, but the profileEmail style definition remains. Consider removing it to avoid dead code.

frontend/app/settings/profile.tsx (1)

108-112: Avatar fallback still uses a hardcoded URL.

This screen still uses a hardcoded Pexels URL as avatar fallback, while settings/index.tsx was updated to use getDefaultAvatarUrl(). Consider updating this for consistency.

Proposed fix
+import { getDefaultAvatarUrl } from '@/lib/utils';
+
 // ...
             <Image 
               source={{ 
-                uri: profile?.avatar_url || 'https://images.pexels.com/photos/1239291/pexels-photo-1239291.jpeg?auto=compress&cs=tinysrgb&w=200' 
+                uri: profile?.avatar_url || getDefaultAvatarUrl(profile?.full_name || 'Unknown User')
               }}
               style={styles.profilePhoto}
             />
frontend/services/local-notification-service.ts (1)

25-33: Good defensive sanitization for badge values.

The guard against non-integer/non-finite values is solid. One edge case: negative integers (e.g., -1) will pass validation but are invalid badge counts on iOS. Consider adding badge < 0 to the rejection condition.

Optional: reject negative badge values
-    if (typeof badge !== 'number' || !Number.isFinite(badge) || !Number.isInteger(badge)) {
+    if (typeof badge !== 'number' || !Number.isFinite(badge) || !Number.isInteger(badge) || badge < 0) {
       return undefined;
     }
frontend/components/error-boundary.tsx (2)

15-49: deviceInfo missing from useEffect dependency array.

deviceInfo is referenced inside the effect but not listed in [error]. Since getDeviceInfo() produces a new object each render, adding it would cause infinite re-runs. The current approach works because the component only re-renders when error changes, but a linter may flag this. Consider moving the getDeviceInfo() call inside the effect to be explicit and avoid the lint warning.

Move getDeviceInfo inside the effect
 function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
-  // Collect device information
-  const deviceInfo = getDeviceInfo();
-
   // Capture error to PostHog
   React.useEffect(() => {
+    const deviceInfo = getDeviceInfo();
     try {
       const errorProperties: Record<string, string> = {

Then keep a separate getDeviceInfo() call for the local logger call, or move the logger call inside the effect as well (since it's also a side effect).


122-133: Align fallback prop type with RNErrorBoundary expectations.

The fallback prop is typed as (props: ErrorFallbackProps) => ReactNode, but RNErrorBoundary's FallbackComponent expects React.ComponentType<ErrorFallbackProps>. While function components work at runtime, the type mismatch could cause issues with stricter type checking. Align the typing to React.ComponentType<ErrorFallbackProps> for precision.

Suggested change
 interface ErrorBoundaryProps {
   children: ReactNode;
-  fallback?: (props: ErrorFallbackProps) => ReactNode;
+  fallback?: React.ComponentType<ErrorFallbackProps>;
 }
frontend/app/_layout.tsx (1)

45-74: Good placement of ErrorBoundary at the root level.

Wrapping the entire provider/layout tree ensures any uncaught render error is caught centrally.

One nit: Line 57 (<Stack.Screen name="vault" />) has inconsistent indentation compared to its siblings (lines 53–56, 58–65 all use extra leading whitespace).

frontend/app/vault.tsx (1)

247-253: Redundant visibility guard.

The JSX condition on line 247 (unseenEntryIds.size > 0) already prevents rendering, making visible={unseenEntryIds.size > 0} on line 251 always true. You can simplify by always passing visible={true} or removing the outer condition and letting the component's internal guard (line 12 in new-entries-indicator.tsx) handle it alone.

frontend/hooks/use-suggested-friends.ts (1)

66-79: Side effect inside setQueryData updater callback.

deviceStorage.setSuggestedFriends(updated) at line 73 is a side effect fired from within the updater function passed to setQueryData. React Query's updater should be a pure transformation. While it works today, the updater can be called multiple times or batched in certain scenarios, leading to redundant storage writes. Move the storage sync outside:

♻️ Proposed refactor
  const removeContactFromList = useCallback((friendId: string) => {
-   queryClient.setQueryData<SuggestedFriend[]>(['suggested-friends'], (oldData) => {
-     if (!oldData) return [];
-     const updated = oldData.filter(contact => contact.id !== friendId);
-     
-     // Sync device storage optimistically
-     deviceStorage.setSuggestedFriends(updated).catch((error) => {
-       logger.warn('Failed to update device storage optimistically:', error);
-     });
-     
-     return updated;
-   });
+   const oldData = queryClient.getQueryData<SuggestedFriend[]>(['suggested-friends']) ?? [];
+   const updated = oldData.filter(contact => contact.id !== friendId);
+   queryClient.setQueryData<SuggestedFriend[]>(['suggested-friends'], updated);
+   
+   // Sync device storage optimistically
+   deviceStorage.setSuggestedFriends(updated).catch((error) => {
+     logger.warn('Failed to update device storage optimistically:', error);
+   });
  }, [queryClient]);
frontend/hooks/use-friends.ts (1)

236-253: Duplicated query logic between prefetchSuggestedFriends and useSuggestedFriends.

The queryFn here (fetch → filter by profile.id → sync to device storage) is a near-duplicate of the queryFn in use-suggested-friends.ts (lines 44–57). If one changes without the other, they'll silently diverge. Consider extracting a shared fetcher function, or having prefetchSuggestedFriends simply call queryClient.prefetchQuery with the same query key and let useSuggestedFriends define the sole queryFn via a queryOptions factory.

frontend/app/friends.tsx (1)

130-142: convertToFriendFormat uses any[] — loses type safety.

Consider typing the parameter as FriendWithProfile[] (or the appropriate type from your types) to get compile-time checks on the field accesses (friend_profile, status, etc.).

frontend/services/friend-service.ts (1)

62-64: Inconsistent logging: console.log / console.error used alongside logger.

Lines 64, 72, 84, 103–106, 108, 224, 348, etc. still use raw console.* calls while the rest of the file uses the structured logger. This scatters log output across two systems and may leak info in production (the logger suppresses debug in non-dev builds).

Consider replacing all console.* calls with their logger equivalents for consistency.

Comment on lines +142 to +144
entry_type_display = f"a {entry_type.capitalize()}" if entry_type != "audio" else "an audio recording"
title = "New Entry Shared"
body = f"{owner_name} shared a {entry_type_display} with you"
body = f"{owner_name} shared {entry_type_display} with you"
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Grammatical bug: "a Entry" when entry_type is the default value "entry".

When entry_type defaults to "entry" (line 93), this produces "a Entry" — should be "an entry". The vowel check only handles "audio" but misses this case.

Proposed fix
-            entry_type_display = f"a {entry_type.capitalize()}" if entry_type != "audio" else "an audio recording"
+            if entry_type == "audio":
+                entry_type_display = "an audio recording"
+            elif entry_type[0].lower() in "aeiou":
+                entry_type_display = f"an {entry_type}"
+            else:
+                entry_type_display = f"a {entry_type}"
📝 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
entry_type_display = f"a {entry_type.capitalize()}" if entry_type != "audio" else "an audio recording"
title = "New Entry Shared"
body = f"{owner_name} shared a {entry_type_display} with you"
body = f"{owner_name} shared {entry_type_display} with you"
if entry_type == "audio":
entry_type_display = "an audio recording"
elif entry_type[0].lower() in "aeiou":
entry_type_display = f"an {entry_type}"
else:
entry_type_display = f"a {entry_type}"
title = "New Entry Shared"
body = f"{owner_name} shared {entry_type_display} with you"
🤖 Prompt for AI Agents
In `@backend/services/notification_enqueue_service.py` around lines 142 - 144, The
body generation currently builds entry_type_display as f"a
{entry_type.capitalize()}" with only "audio" special-cased, causing "a Entry"
for the default "entry"; update the logic in
backend/services/notification_enqueue_service.py where entry_type_display is set
to compute the correct indefinite article by checking entry_type.lower()[0] in
"aeiou" (use 'an' for vowels, 'a' otherwise), then build entry_type_display
using the article and entry_type.lower() (or entry_type if you need different
casing), and leave the rest of the notification body (owner_name and title)
unchanged so the message becomes e.g. "an entry" or "a video" correctly.

Comment on lines +109 to +117

# Capture PostHog event for notification enqueued
self._capture_notification_enqueued_event(
metadata=metadata,
recipients=recipients,
title=title,
body=body,
priority=priority
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Synchronous telemetry with DB queries blocks the enqueue caller.

_capture_notification_enqueued_event makes two synchronous DB queries via _get_user_info_from_tokens (lines 706, 651, 666). Unlike the sent path (line 250), this isn't offloaded to a thread, so it blocks the caller of enqueue_notification. Consider using the same asyncio.to_thread pattern, or making enqueue_notification async like the sent path does.

Note: If enqueue_notification is always called from an async context, you could make it async and use await asyncio.to_thread(...) here too. Otherwise, fire-and-forget via a thread would work.

🤖 Prompt for AI Agents
In `@backend/services/notification_service.py` around lines 109 - 117, The
telemetry call in enqueue_notification synchronously invokes
_capture_notification_enqueued_event which performs blocking DB work via
_get_user_info_from_tokens, blocking the caller; update enqueue_notification to
offload that call to a background thread (use asyncio.to_thread) or make
enqueue_notification async and await asyncio.to_thread for
_capture_notification_enqueued_event, mirroring how the sent path handles
telemetry, so the DB queries no longer block the enqueue caller.

Comment on lines +248 to +257

# Capture PostHog event for notification sent (offload blocking I/O to thread)
await asyncio.to_thread(
self._capture_notification_sent_event,
metadata=metadata,
recipients=recipients,
title=title,
body=body,
priority=priority
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Telemetry failure in to_thread will propagate and increment stats["failed"].

If _capture_notification_sent_event raises (e.g., DB timeout), the await asyncio.to_thread(...) on line 250 will raise into the outer except block (line 267), causing stats["failed"] to be incremented even though the notification was successfully sent and deleted. The telemetry call should be wrapped in its own try/except to avoid corrupting stats.

Proposed fix
                 logger.info(f"Successfully processed notification: msg_id={msg_id}")
                 
-                # Capture PostHog event for notification sent (offload blocking I/O to thread)
-                await asyncio.to_thread(
-                    self._capture_notification_sent_event,
-                    metadata=metadata,
-                    recipients=recipients,
-                    title=title,
-                    body=body,
-                    priority=priority
-                )
+                # Capture PostHog event for notification sent (offload blocking I/O to thread)
+                try:
+                    await asyncio.to_thread(
+                        self._capture_notification_sent_event,
+                        metadata=metadata,
+                        recipients=recipients,
+                        title=title,
+                        body=body,
+                        priority=priority
+                    )
+                except Exception:
+                    logger.warning(f"Failed to capture telemetry for msg_id={msg_id}", exc_info=True)
📝 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
# Capture PostHog event for notification sent (offload blocking I/O to thread)
await asyncio.to_thread(
self._capture_notification_sent_event,
metadata=metadata,
recipients=recipients,
title=title,
body=body,
priority=priority
)
# Capture PostHog event for notification sent (offload blocking I/O to thread)
try:
await asyncio.to_thread(
self._capture_notification_sent_event,
metadata=metadata,
recipients=recipients,
title=title,
body=body,
priority=priority
)
except Exception:
logger.warning(f"Failed to capture telemetry for msg_id={msg_id}", exc_info=True)
🤖 Prompt for AI Agents
In `@backend/services/notification_service.py` around lines 248 - 257, The
telemetry call via await
asyncio.to_thread(self._capture_notification_sent_event, ...) can raise and
bubble into the outer except, incorrectly incrementing stats["failed"]; wrap the
telemetry invocation in its own try/except so any exceptions from
asyncio.to_thread or self._capture_notification_sent_event are caught and logged
(or swallowed) without affecting stats, e.g., call asyncio.to_thread(...) inside
a try block and on failure log the exception and continue; ensure this change
references the existing asyncio.to_thread call and the
_capture_notification_sent_event method and does not alter the surrounding
success path that deletes the sent notification.

Comment on lines +708 to +712
# Capture event for each recipient
for token in recipients:
user_info = token_user_info.get(token, {})
user_id = user_info.get("user_id")
email = user_info.get("email")
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

email is fetched but never used in the enqueued event handler.

Static analysis confirms email on line 712 is assigned but never referenced. The sent event handler (line 779-780) does use it. Either this is an oversight and email should be included here too, or the fetch is unnecessary overhead.

If email should be included (matching the sent handler)
                if user_id:
                    properties["user_id"] = user_id
+                if email:
+                    properties["email"] = email
🧰 Tools
🪛 Ruff (0.14.14)

[error] 712-712: Local variable email is assigned to but never used

Remove assignment to unused variable email

(F841)

🤖 Prompt for AI Agents
In `@backend/services/notification_service.py` around lines 708 - 712, In the
recipients loop in send/enqueue logic, the local variable email is retrieved
from token_user_info but never passed into the enqueued event payload; update
the enqueued-event creation (the enqueued event handler code that mirrors the
sent handler) to include email alongside user_id (use token_user_info.get(token,
{}).get("email")) so the enqueued event contains the same email field the sent
handler expects, or if email is truly unnecessary remove the unused assignment;
locate the loop iterating "for token in recipients" and the enqueued event
construction to make the change.

Comment on lines +779 to +780
if email:
properties["email"] = email
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Capturing user email in PostHog events is a PII/compliance risk.

Sending email addresses to a third-party analytics service (PostHog) may conflict with GDPR/CCPA requirements. Consider whether user_id alone is sufficient for your analytics needs, or ensure your PostHog data retention and processing agreements cover PII.

🤖 Prompt for AI Agents
In `@backend/services/notification_service.py` around lines 779 - 780, Do not send
raw user email to PostHog: stop setting properties["email"] from the local email
variable; instead only send non-PII identifiers (e.g., user_id) or a
pseudonymous value. Update the logic around the properties dict (the properties
variable and the email variable) to either remove the email assignment entirely
or gate it behind an explicit config/consent flag (e.g., POSTHOG_ALLOW_PII)
and/or replace the raw email with a one-way hash (sha256) before adding to
properties to ensure PII isn’t transmitted by default.

Comment on lines +95 to +101
const markEntriesAsSeen = useCallback((entryIds: string[]) => {
setUnseenEntryIds(prev => {
const updated = new Set(prev);
entryIds.forEach(id => updated.delete(id));
return updated;
});
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Static analysis: forEach callback should not return a value.

Set.delete() returns a boolean, so the arrow function implicitly returns it. Use a block body to satisfy the linter.

✏️ Proposed fix
   const markEntriesAsSeen = useCallback((entryIds: string[]) => {
     setUnseenEntryIds(prev => {
       const updated = new Set(prev);
-      entryIds.forEach(id => updated.delete(id));
+      entryIds.forEach(id => { updated.delete(id); });
       return updated;
     });
   }, []);
📝 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 markEntriesAsSeen = useCallback((entryIds: string[]) => {
setUnseenEntryIds(prev => {
const updated = new Set(prev);
entryIds.forEach(id => updated.delete(id));
return updated;
});
}, []);
const markEntriesAsSeen = useCallback((entryIds: string[]) => {
setUnseenEntryIds(prev => {
const updated = new Set(prev);
entryIds.forEach(id => { updated.delete(id); });
return updated;
});
}, []);
🧰 Tools
🪛 Biome (2.3.13)

[error] 98-98: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@frontend/hooks/use-user-entries.ts` around lines 95 - 101, The forEach
callback in markEntriesAsSeen implicitly returns the boolean from Set.delete
(violating the linter); update the callback to use a block body or a for...of
loop so it does not return a value—e.g., within the setUnseenEntryIds updater
for the updated Set, replace entryIds.forEach(id => updated.delete(id)) with a
non-returning form (entryIds.forEach(id => { updated.delete(id); }) or for
(const id of entryIds) { updated.delete(id); }) ensuring the function name
markEntriesAsSeen and state updater setUnseenEntryIds are unchanged.

Comment on lines +226 to +232
missedEntries.forEach(async (entry: EntryWithProfile) => {
try {
await deviceStorage.addEntry(user.id, entry);
} catch (storageError) {
console.error('Error saving missed entry to storage:', storageError);
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and read the relevant file
fd 'use-user-entries.ts' --type f

Repository: fortune710/keepsafe

Length of output: 97


🏁 Script executed:

# Read the relevant file, focusing on lines 190-240 to get full context
cat -n frontend/hooks/use-user-entries.ts | sed -n '190,240p'

Repository: fortune710/keepsafe

Length of output: 2741


🏁 Script executed:

# Check for other instances of forEach(async pattern in the codebase
rg -n 'forEach\s*\(\s*async' --type=ts --type=tsx -g '!node_modules/**'

Repository: fortune710/keepsafe

Length of output: 90


🏁 Script executed:

# Check for other instances of forEach(async pattern in the codebase (adjusted for available types)
rg -n 'forEach\s*\(\s*async' --type=ts -g '!node_modules/**'

Repository: fortune710/keepsafe

Length of output: 178


🏁 Script executed:

# Let's check if there are any other async/await issues or patterns related to Supabase queries in this file
rg -n '\.then\s*\(' frontend/hooks/use-user-entries.ts -A 5 | head -50

Repository: fortune710/keepsafe

Length of output: 332


forEach with async callbacks — unhandled promises and no operation completion tracking.

Each iteration spawns an independent, untracked promise. If the loop contains multiple entries, you have concurrent device storage writes with no aggregated error handling and no way for the caller to know when the operation completes. Use Promise.all with map to properly track completion and surface any failures across all entries.

Implementation note: The parent .then() at line 203 is not async. To use await Promise.all(...), either convert that callback to async or chain the Promise.all to an additional .then() block.

🔧 Proposed fix
-                  missedEntries.forEach(async (entry: EntryWithProfile) => {
-                    try {
-                      await deviceStorage.addEntry(user.id, entry);
-                    } catch (storageError) {
-                      console.error('Error saving missed entry to storage:', storageError);
-                    }
-                  });
+                  Promise.all(
+                    missedEntries.map(async (entry: EntryWithProfile) => {
+                      try {
+                        await deviceStorage.addEntry(user.id, entry);
+                      } catch (storageError) {
+                        console.error('Error saving missed entry to storage:', storageError);
+                      }
+                    })
+                  );
🤖 Prompt for AI Agents
In `@frontend/hooks/use-user-entries.ts` around lines 226 - 232, Replace the async
forEach usage (missedEntries.forEach(async (entry) => { await
deviceStorage.addEntry(user.id, entry) ... })) with a Promise.all over
missedEntries.map(...) so all addEntry promises are tracked and failures are
aggregated; either make the enclosing .then() callback async and await
Promise.all(mappedPromises) or return Promise.all(mappedPromises) from the
current .then() chain, and consolidate error handling to catch and log any
rejection from Promise.all for deviceStorage.addEntry calls.

Comment on lines 90 to 106
if ((key === 'updated_at' || key === 'created_at') && typeof item[key] === 'string') {
// Group by day (YYYY-MM-DD) if key is 'updated_at'
// Group by day (YYYY-MM-DD) in local timezone if key is 'updated_at' or 'created_at'
const date = new Date(item[key] as string);
groupKey = date.toISOString().slice(0, 10);
if (!isNaN(date.getTime())) {
// Use local date components to match DateContainer display
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
groupKey = `${year}-${month}-${day}`;
} else {
// Invalid date - group under 'invalid-date' or skip
groupKey = 'invalid-date';
}
} else {
groupKey = String(item[key]);
const value = item[key];
groupKey = value != null ? String(value) : 'null';
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n frontend/lib/utils.ts | head -120

Repository: fortune710/keepsafe

Length of output: 4657


🏁 Script executed:

wc -l frontend/lib/utils.ts

Repository: fortune710/keepsafe

Length of output: 88


🏁 Script executed:

# Find the file first
find . -name "utils.ts" -path "*/frontend/lib/*"

Repository: fortune710/keepsafe

Length of output: 86


🏁 Script executed:

sed -n '180,220p' frontend/lib/utils.ts

Repository: fortune710/keepsafe

Length of output: 1574


🏁 Script executed:

# Search for getTimefromTimezone, hasEntries, getEntryCount
rg -n 'getTimfromTimezone|hasEntries|getEntryCount' frontend/lib/utils.ts -A5 -B2

Repository: fortune710/keepsafe

Length of output: 691


🏁 Script executed:

# Also search for toISOString usage in the file
rg -n 'toISOString' frontend/lib/utils.ts -B2 -A2

Repository: fortune710/keepsafe

Length of output: 892


🏁 Script executed:

# Search for getTimefromTimezone implementation
rg -n 'getTimfromTimezone' frontend/ -A10 -B2

Repository: fortune710/keepsafe

Length of output: 45


🏁 Script executed:

# Also check how the groupBy output is actually used - search for calls to groupBy and entriesData construction
rg -n 'groupBy.*created_at|groupBy.*updated_at' frontend/ -B2 -A5

Repository: fortune710/keepsafe

Length of output: 590


🏁 Script executed:

# Search for where entriesData is populated/created
rg -n 'entriesData|Record<string, number>' frontend/ -B3 -A3

Repository: fortune710/keepsafe

Length of output: 4262


🏁 Script executed:

# See the actual entriesData construction in calendar/index.tsx
sed -n '22,35p' frontend/app/calendar/index.tsx

Repository: fortune710/keepsafe

Length of output: 424


🏁 Script executed:

# Search for getTimfromTimezone definition (note: might be spelled differently)
rg -n 'export.*getTime.*[Tt]imezone' frontend/

Repository: fortune710/keepsafe

Length of output: 142


🏁 Script executed:

sed -n '242,260p' frontend/lib/utils.ts

Repository: fortune710/keepsafe

Length of output: 628


🏁 Script executed:

# Also check the actual flow - where does entriesByDate from groupBy get used?
rg -n 'entriesByDate' frontend/ -B2 -A2

Repository: fortune710/keepsafe

Length of output: 2929


🏁 Script executed:

# Verify the actual timezone impact - what does TZDate.toISOString() return?
# Also check if groupBy is actually used in calendar context
rg -n 'entriesByDate' frontend/app/calendar/ -B3 -A3

Repository: fortune710/keepsafe

Length of output: 45


🏁 Script executed:

# Check if there's any direct comparison between groupBy keys and hasEntries
rg -n 'hasEntries.*entriesByDate|entriesByDate.*hasEntries' frontend/

Repository: fortune710/keepsafe

Length of output: 45


🏁 Script executed:

# Let's verify the actual behavior of the two approaches with a concrete example
python3 << 'EOF'
from datetime import datetime, timezone, timedelta

# Simulate what the code does for a date in positive UTC offset timezone
# Example: Feb 8, 2026 in JST (UTC+9)

# When parsed from ISO string "2026-02-08T10:00:00Z" (10 AM UTC)
# Local in JST: Feb 8, 2026 7 PM

# Approach 1: groupBy uses local date methods
# If someone is in JST and entry was created at 2026-02-08T10:00:00Z
# new Date parses UTC, but getDate() returns LOCAL date
iso_string = "2026-02-08T10:00:00Z"
print("ISO String from server:", iso_string)

# In JavaScript, new Date("2026-02-08T10:00:00Z") creates UTC date
# But getDate() returns the LOCAL date component
# For someone in JST (UTC+9): 10:00 UTC + 9 hours = 19:00 JST on Feb 8
# So getDate() would return 8
print("\nApproach 1 (groupBy - uses local getDate()):")
print("  Entry ISO: 2026-02-08T10:00:00Z")
print("  In JST (UTC+9): 2026-02-08 19:00:00")
print("  getDate() returns: 8")
print("  groupKey: 2026-02-08")

# Approach 2: hasEntries uses new Date(year, month, day).toISOString()
# new Date(2026, 1, 8) creates a LOCAL date (Feb 8 00:00:00 in local timezone)
# For JST user: 2026-02-08 00:00:00 JST = 2026-02-07 15:00:00 UTC
# toISOString() returns: "2026-02-07T15:00:00Z"
print("\nApproach 2 (hasEntries - creates local date, converts to UTC ISO):")
print("  new Date(2026, 1, 8) in JST: 2026-02-08 00:00:00 JST")
print("  In UTC: 2026-02-07 15:00:00 UTC")
print("  toISOString(): 2026-02-07T15:00:00Z")
print("  dateKey: 2026-02-07")

print("\n⚠️  MISMATCH: groupBy produces '2026-02-08' but hasEntries looks for '2026-02-07'")
EOF

Repository: fortune710/keepsafe

Length of output: 571


🏁 Script executed:

# Now check what getTimefromTimezone + toISOString does
# This is used in calendar/index.tsx
python3 << 'EOF'
# Approach 3: calendar/index.tsx uses getTimefromTimezone + toISOString
# getTimefromTimezone creates a TZDate (timezone-aware)
# TZDate.toISOString() should return the correct UTC date regardless of local timezone
print("Approach 3 (calendar/index.tsx - uses getTimefromTimezone + TZDate):")
print("  Entry ISO: 2026-02-08T10:00:00Z (10:00 UTC)")
print("  getTimefromTimezone wraps in TZDate with device timezone")
print("  TZDate.toISOString() for JST should return: 2026-02-08T10:00:00Z")
print("  dateKey: 2026-02-08 (UTC date, which is correct)")
print("\n✓ This approach is timezone-aware and correct")
EOF

Repository: fortune710/keepsafe

Length of output: 401


🏁 Script executed:

# Double-check: does the current code mix these approaches anywhere?
# Check if anyone calls groupBy output with hasEntries
rg -n 'groupBy' frontend/hooks/use-user-entries.ts -A10 -B5

Repository: fortune710/keepsafe

Length of output: 1163


🏁 Script executed:

# Let's verify one more thing - does getTimefromTimezone exist and is it exported?
rg -n 'getTimefromTimezone|getDeviceTimezone' frontend/lib/utils.ts -B2 -A8

Repository: fortune710/keepsafe

Length of output: 1289


🏁 Script executed:

# Check how the timezone situation impacts real usage - are there any timezone-aware date utilities?
rg -n 'getDeviceTimezone|TZDate' frontend/ -l

Repository: fortune710/keepsafe

Length of output: 123


🏁 Script executed:

# Final check - verify if the LOCAL date keys from groupBy actually cause issues
# by checking if vault.tsx ever intersects with calendar date logic
rg -n 'getEntriesForDate|hasEntries|getEntryCount' frontend/app/vault.tsx

Repository: fortune710/keepsafe

Length of output: 45


🏁 Script executed:

# Check date-container.tsx to see if it uses any of these functions
cat frontend/components/date-container.tsx

Repository: fortune710/keepsafe

Length of output: 1381


Timezone inconsistency in date-key derivation across utility functions.

groupBy (lines 90–106) derives date keys using local date methods (getDate(), getMonth(), getFullYear()), producing keys like "2026-02-08". However, hasEntries and getEntryCount (lines 192–200) derive keys via new Date(year, month, day).toISOString().split('T')[0], which converts a locally-constructed date to UTC, producing different keys for the same entry in non-UTC timezones. For example, in JST (UTC+9), an entry created on Feb 8 would produce "2026-02-08" from groupBy but "2026-02-07" from hasEntries.

Currently, the code paths are isolated: groupBy is used only in vault.tsx via entriesByDate, while hasEntries/getEntryCount are used only in calendar/index.tsx. However, calendar/index.tsx itself correctly builds entriesData using getTimefromTimezone(...).toISOString().split('T')[0], which is timezone-aware. To prevent latent bugs if these utilities are ever combined, unify date-key derivation by using getTimefromTimezone consistently across all grouping, filtering, and counting operations.

🤖 Prompt for AI Agents
In `@frontend/lib/utils.ts` around lines 90 - 106, groupBy, hasEntries, and
getEntryCount produce inconsistent date keys because groupBy builds YYYY-MM-DD
from local Date methods while the others use UTC via toISOString; fix this by
deriving all date keys through the shared timezone-aware helper
getTimefromTimezone (call getTimefromTimezone with the original timestamp or
year/month/day inputs, then use .toISOString().split('T')[0]) instead of using
getFullYear/getMonth/getDate or new Date(...).toISOString() conversions
directly; update the groupBy implementation (where it checks keys
'updated_at'/'created_at') to use getTimefromTimezone, and ensure hasEntries and
getEntryCount use the same getTimefromTimezone-based YYYY-MM-DD extraction so
all utilities produce identical keys.

Comment on lines +82 to +86
logger.error('SearchService: non-OK response', {
status: response.status,
statusText: response.statusText,

});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Incomplete log object — looks like a missing property.

Line 85 has a trailing comma after statusText with nothing following it, suggesting a property was accidentally deleted or never added.

🤖 Prompt for AI Agents
In `@frontend/services/search-service.ts` around lines 82 - 86, The logger.error
call in SearchService includes a trailing comma after statusText, indicating a
missing property; update the object passed to logger.error (in the SearchService
where logger.error is called) to either remove the stray comma or add the
intended field — e.g., include response.url (or responseText/responseBody if you
capture the body) so the log object contains status, statusText, and the missing
identifier, and ensure no stray trailing comma remains.

Comment on lines +78 to +90
-- Create function to generate invite code
CREATE OR REPLACE FUNCTION generate_invite_code()
RETURNS text AS $$
BEGIN
RETURN array_to_string(
ARRAY(
SELECT substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
FROM (ceil(random()*62))::int FOR 1)
FROM generate_series(1, 8)
), ''
);
END;
$$ LANGUAGE plpgsql VOLATILE;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ceil(random()*62) can produce 0, yielding an empty-string character.

random() returns [0.0, 1.0). When it returns 0.0, ceil(0.0*62) = ceil(0) = 0. substring(... FROM 0 FOR 1) in PostgreSQL returns '', so the resulting code will be fewer than 8 characters. Use floor(random()*62)::int + 1 instead to guarantee the range [1, 62].

🔧 Proposed fix
 CREATE OR REPLACE FUNCTION generate_invite_code()
 RETURNS text AS $$
 BEGIN
   RETURN array_to_string(
     ARRAY(
       SELECT substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 
-      FROM (ceil(random()*62))::int FOR 1)
+      FROM (floor(random()*62)::int + 1) FOR 1)
       FROM generate_series(1, 8)
     ), ''
   );
 END;
 $$ LANGUAGE plpgsql VOLATILE;
📝 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
-- Create function to generate invite code
CREATE OR REPLACE FUNCTION generate_invite_code()
RETURNS text AS $$
BEGIN
RETURN array_to_string(
ARRAY(
SELECT substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
FROM (ceil(random()*62))::int FOR 1)
FROM generate_series(1, 8)
), ''
);
END;
$$ LANGUAGE plpgsql VOLATILE;
-- Create function to generate invite code
CREATE OR REPLACE FUNCTION generate_invite_code()
RETURNS text AS $$
BEGIN
RETURN array_to_string(
ARRAY(
SELECT substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
FROM (floor(random()*62)::int + 1) FOR 1)
FROM generate_series(1, 8)
), ''
);
END;
$$ LANGUAGE plpgsql VOLATILE;
🤖 Prompt for AI Agents
In `@frontend/supabase/migrations/20250825200450_misty_boat.sql` around lines 78 -
90, The generate_invite_code() function can pick index 0 due to
ceil(random()*62) when random() == 0; update the selection expression inside the
substring call to use a 1-based index by replacing ceil(random()*62) with
floor(random()*62)::int + 1 so the range is always 1..62 (update the expression
within the SELECT in function generate_invite_code to use
floor(random()*62)::int + 1).

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