From 6d93769a0309d42eb46405ac816b857222562087 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Thu, 23 Apr 2026 21:47:51 -0400 Subject: [PATCH 01/45] removed supabase files --- supabase/.temp/cli-latest | 1 - supabase/.temp/gotrue-version | 1 - supabase/.temp/pooler-url | 1 - supabase/.temp/postgres-version | 1 - supabase/.temp/project-ref | 1 - supabase/.temp/rest-version | 1 - supabase/.temp/storage-version | 1 - 7 files changed, 7 deletions(-) delete mode 100644 supabase/.temp/cli-latest delete mode 100644 supabase/.temp/gotrue-version delete mode 100644 supabase/.temp/pooler-url delete mode 100644 supabase/.temp/postgres-version delete mode 100644 supabase/.temp/project-ref delete mode 100644 supabase/.temp/rest-version delete mode 100644 supabase/.temp/storage-version diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest deleted file mode 100644 index 47c148f..0000000 --- a/supabase/.temp/cli-latest +++ /dev/null @@ -1 +0,0 @@ -v2.84.2 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version deleted file mode 100644 index 5bbfd4d..0000000 --- a/supabase/.temp/gotrue-version +++ /dev/null @@ -1 +0,0 @@ -v2.188.1 \ No newline at end of file diff --git a/supabase/.temp/pooler-url b/supabase/.temp/pooler-url deleted file mode 100644 index 11c2882..0000000 --- a/supabase/.temp/pooler-url +++ /dev/null @@ -1 +0,0 @@ -postgresql://postgres.kjnuwzuhngfvdfzzaitj:[YOUR-PASSWORD]@aws-1-us-east-2.pooler.supabase.com:6543/postgres \ No newline at end of file diff --git a/supabase/.temp/postgres-version b/supabase/.temp/postgres-version deleted file mode 100644 index 99aae29..0000000 --- a/supabase/.temp/postgres-version +++ /dev/null @@ -1 +0,0 @@ -17.4.1.074 \ No newline at end of file diff --git a/supabase/.temp/project-ref b/supabase/.temp/project-ref deleted file mode 100644 index 4b153b4..0000000 --- a/supabase/.temp/project-ref +++ /dev/null @@ -1 +0,0 @@ -kjnuwzuhngfvdfzzaitj \ No newline at end of file diff --git a/supabase/.temp/rest-version b/supabase/.temp/rest-version deleted file mode 100644 index c518e9a..0000000 --- a/supabase/.temp/rest-version +++ /dev/null @@ -1 +0,0 @@ -v13.0.4 \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version deleted file mode 100644 index b781586..0000000 --- a/supabase/.temp/storage-version +++ /dev/null @@ -1 +0,0 @@ -fix-optimized-search-function \ No newline at end of file From f46154e28060fe6153b3a38b13f23c4efa719029 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 00:39:47 -0400 Subject: [PATCH 02/45] send push notifications on successful dump creation --- .../services/notification_enqueue_service.py | 63 +++++++++++ .../queues/monthly_dump_queue_service.py | 25 +++-- .../test_monthly_dump_queue_notifications.py | 104 ++++++++++++++++++ 3 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_monthly_dump_queue_notifications.py diff --git a/backend/services/notification_enqueue_service.py b/backend/services/notification_enqueue_service.py index 9be6170..bf90b8f 100644 --- a/backend/services/notification_enqueue_service.py +++ b/backend/services/notification_enqueue_service.py @@ -262,3 +262,66 @@ def _get_push_tokens_for_users(self, user_ids: List[str]) -> List[str]: except Exception as e: logger.error(f"Error fetching push tokens for users: {str(e)}") return [] + + async def enqueue_monthly_dump_notifications( + self, + user_ids: List[str] + ) -> bool: + """ + Enqueue push notifications for a batch of users when their monthly dump is ready. + + Parameters: + user_ids (List[str]): User IDs of users whose monthly dumps are generated. + + Returns: + bool: True if fully processed. + """ + if not user_ids: + return True + + try: + # We filter by 'push_notifications' or we can just send it to all tokens + # Let's filter by push_notifications setting (default to True if not found) + filtered_recipients = self._filter_recipients_by_notification_settings( + user_ids, + notification_type="push_notifications" + ) + + if not filtered_recipients: + logger.info("No recipients with push_notifications enabled for monthly dump batch") + return True + + push_tokens = self._get_push_tokens_for_users(filtered_recipients) + + if not push_tokens: + logger.info("No push tokens found for monthly dump batch") + return True + + title = "Your Monthly Dump is Ready! πŸŽ‰" + body = "Relive your best moments from last month." + + success = self.notification_service.enqueue_notification( + title=title, + body=body, + recipients=push_tokens, + priority="normal", + metadata={ + "notification_type": "monthly_dump_ready" + }, + data={ + "page_url": "/vault", + } + ) + + if success: + logger.info( + f"Monthly dump batch notification enqueued: recipients={len(push_tokens)}" + ) + else: + logger.error("Failed to enqueue monthly dump batch notification") + + return success + + except Exception as e: + logger.error(f"Error enqueueing monthly dump batch notification: {str(e)}", exc_info=True) + return False diff --git a/backend/services/queues/monthly_dump_queue_service.py b/backend/services/queues/monthly_dump_queue_service.py index 0d40e49..86c5eba 100644 --- a/backend/services/queues/monthly_dump_queue_service.py +++ b/backend/services/queues/monthly_dump_queue_service.py @@ -9,6 +9,7 @@ from services.queue_service import QueueService from services.supabase_client import get_supabase_client from services.monthly_dump_service import MonthlyDumpService, MonthlyDumpInputs +from services.notification_enqueue_service import NotificationEnqueueService from controllers.monthly_dump_controller import MonthlyDumpController from database.tables import DatabaseTables from queue_constants import ( @@ -94,8 +95,16 @@ async def process_queue(self) -> Dict[str, int]: logger.info("No monthly dump messages available", extra={"queue": self.queue_name}) return stats + successful_user_ids = [] + for message in messages: - await self._process_message(message, stats) + user_id = await self._process_message(message, stats) + if user_id: + successful_user_ids.append(user_id) + + if successful_user_ids: + notification_service = NotificationEnqueueService() + await notification_service.enqueue_monthly_dump_notifications(successful_user_ids) logger.info( "Monthly dump queue processing complete", @@ -103,7 +112,7 @@ async def process_queue(self) -> Dict[str, int]: ) return stats - async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) -> None: + async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) -> Optional[str]: stats["processed"] += 1 msg_id = message.get("msg_id") msg_str = message.get("message", "{}") @@ -119,7 +128,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) if msg_id is not None: self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) stats["failed"] += 1 - return + return None monthly_dump_id = msg_data.get("monthly_dump_id") user_id = msg_data.get("user_id") @@ -135,7 +144,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) ) self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) stats["failed"] += 1 - return + return None try: logger.info( @@ -171,7 +180,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) ) self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) stats["succeeded"] += 1 - return + return None persisted_seed = existing_dump.get("random_seed") if existing_dump else None if persisted_seed is not None: @@ -198,8 +207,8 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) ) self.dump_controller.delete({"id": monthly_dump_id}) self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) - stats["succeeded"] += 1 - return + stats["failed"] += 1 + return None # Ensure msg_data has the seed in case it goes to DLQ msg_data["random_seed"] = seed @@ -240,6 +249,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) stats["succeeded"] += 1 + return user_id except Exception as exc: # noqa: BLE001 msg_data["last_error"] = str(exc) logger.exception( @@ -252,6 +262,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) }, ) self._handle_failure(msg_id, msg_data, failure_count, stats) + return None def _handle_failure( self, diff --git a/backend/tests/test_monthly_dump_queue_notifications.py b/backend/tests/test_monthly_dump_queue_notifications.py new file mode 100644 index 0000000..b791fe9 --- /dev/null +++ b/backend/tests/test_monthly_dump_queue_notifications.py @@ -0,0 +1,104 @@ +import pytest +import json +from unittest.mock import AsyncMock, patch, MagicMock + +from services.queues.monthly_dump_queue_service import MonthlyDumpQueueService +from services.monthly_dump_service import MonthlyDumpResult + + +@pytest.fixture +def mock_supabase(): + return MagicMock() + + +@pytest.mark.asyncio +async def test_monthly_dump_queue_processes_missing_entries_as_failed(): + service = MonthlyDumpQueueService() + + # Mock queue messages + queue_messages = [ + { + "msg_id": 1, + "message": json.dumps({ + "monthly_dump_id": "dump-1", + "user_id": "user-A", + "month": "2026-04", + "random_seed": 123 + }) + } + ] + service.queue_service = MagicMock() + service.queue_service.read_messages.return_value = queue_messages + + # Mock no entries found correctly short-circuiting + service.dump_controller = MagicMock() + service.dump_controller.get.return_value.data = {"status": "pending"} + + service.dump_service = MagicMock() + service.dump_service.get_month_bounds.return_value = (None, None) + service.dump_service.fetch_entries.return_value = [] # No entries! + + with patch("services.queues.monthly_dump_queue_service.NotificationEnqueueService") as MockNotifService: + mock_notif_instance = AsyncMock() + MockNotifService.return_value = mock_notif_instance + + stats = await service.process_queue() + + # Ensure it failed based on missing entries + assert stats["failed"] == 1 + assert stats["succeeded"] == 0 + + # Ensure notification enqueue was NOT explicitly called + mock_notif_instance.enqueue_monthly_dump_notifications.assert_not_called() + + +@pytest.mark.asyncio +async def test_monthly_dump_queue_enqueues_notification_on_success(): + service = MonthlyDumpQueueService() + + # Mock queue messages + queue_messages = [ + { + "msg_id": 2, + "message": json.dumps({ + "monthly_dump_id": "dump-2", + "user_id": "user-B", + "month": "2026-04", + "random_seed": 123 + }) + }, + { + "msg_id": 3, + "message": json.dumps({ + "monthly_dump_id": "dump-3", + "user_id": "user-C", + "month": "2026-04", + "random_seed": 123 + }) + } + ] + service.queue_service = MagicMock() + service.queue_service.read_messages.return_value = queue_messages + + # Realistically mock finding entries + service.dump_controller = MagicMock() + service.dump_controller.get.return_value.data = {"status": "pending"} + + service.dump_service = AsyncMock() + service.dump_service.get_month_bounds.return_value = (None, None) + service.dump_service.fetch_entries.return_value = [{"id": "entry-1"}] + service.dump_service.build_monthly_dump.return_value = MonthlyDumpResult( + slides=[], photo_count=1, video_count=0, audio_count=0, grid_count=0 + ) + + with patch("services.queues.monthly_dump_queue_service.NotificationEnqueueService") as MockNotifService: + mock_notif_instance = AsyncMock() + MockNotifService.return_value = mock_notif_instance + + stats = await service.process_queue() + + assert stats["succeeded"] == 2 + assert stats["failed"] == 0 + + # Check that it called the enqueue notifications logic ONLY once, for all successful users + mock_notif_instance.enqueue_monthly_dump_notifications.assert_awaited_once_with(["user-B", "user-C"]) From 01f08210472c2023945bd58f6448f236f5f11724 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 00:40:11 -0400 Subject: [PATCH 03/45] filtered endpoint to return only completed dumps --- backend/controllers/monthly_dump_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/controllers/monthly_dump_controller.py b/backend/controllers/monthly_dump_controller.py index 5543a16..d8a2ac2 100644 --- a/backend/controllers/monthly_dump_controller.py +++ b/backend/controllers/monthly_dump_controller.py @@ -11,7 +11,8 @@ def get_dump(self, user_id: str, month_date: str, timezone: str): filters = { "user_id": user_id, "month": month_date, - "timezone": timezone + "timezone": timezone, + "status": "completed" } return self.get(filters=filters, maybe_single=True) From a20a3bc79744c1fe9a1fbcb3994816aa7722f747 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 00:45:04 -0400 Subject: [PATCH 04/45] fixed failing unit tests --- backend/tests/test_monthly_dump_queue_notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_monthly_dump_queue_notifications.py b/backend/tests/test_monthly_dump_queue_notifications.py index b791fe9..4a7c027 100644 --- a/backend/tests/test_monthly_dump_queue_notifications.py +++ b/backend/tests/test_monthly_dump_queue_notifications.py @@ -84,7 +84,7 @@ async def test_monthly_dump_queue_enqueues_notification_on_success(): service.dump_controller = MagicMock() service.dump_controller.get.return_value.data = {"status": "pending"} - service.dump_service = AsyncMock() + service.dump_service = MagicMock() service.dump_service.get_month_bounds.return_value = (None, None) service.dump_service.fetch_entries.return_value = [{"id": "entry-1"}] service.dump_service.build_monthly_dump.return_value = MonthlyDumpResult( From 5c51f91c650431fc12bcc3cf55e3a459db8205b8 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 00:53:32 -0400 Subject: [PATCH 05/45] fixed unimported typing --- .../queues/monthly_dump_queue_service.py | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/services/queues/monthly_dump_queue_service.py b/backend/services/queues/monthly_dump_queue_service.py index 86c5eba..eeff030 100644 --- a/backend/services/queues/monthly_dump_queue_service.py +++ b/backend/services/queues/monthly_dump_queue_service.py @@ -1,6 +1,6 @@ -from __future__ import annotations -from typing import Any, Dict, Optional + +from typing import Any, Dict, Optional, List import json import logging import random @@ -95,16 +95,28 @@ async def process_queue(self) -> Dict[str, int]: logger.info("No monthly dump messages available", extra={"queue": self.queue_name}) return stats - successful_user_ids = [] + # Group successful users by their dump month to send batch notifications + month_to_user_ids: Dict[str, List[str]] = {} for message in messages: - user_id = await self._process_message(message, stats) - if user_id: - successful_user_ids.append(user_id) + # We need the month from the message data to group notifications + msg_str = message.get("message", "{}") + try: + msg_data = json.loads(msg_str) if isinstance(msg_str, str) else msg_str + msg_month = msg_data.get("month") + except Exception: + msg_month = None - if successful_user_ids: - notification_service = NotificationEnqueueService() - await notification_service.enqueue_monthly_dump_notifications(successful_user_ids) + user_id = await self._process_message(message, stats) + if user_id and msg_month: + if msg_month not in month_to_user_ids: + month_to_user_ids[msg_month] = [] + month_to_user_ids[msg_month].append(user_id) + + if month_to_user_ids: + notification_enqueue_service = NotificationEnqueueService() + for msg_month, user_ids in month_to_user_ids.items(): + await notification_enqueue_service.enqueue_monthly_dump_notifications(user_ids, msg_month) logger.info( "Monthly dump queue processing complete", From dcc1da3516adfb043bf544e17718c47994fd7b55 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 00:53:55 -0400 Subject: [PATCH 06/45] added month into notifiaction --- .../services/notification_enqueue_service.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/backend/services/notification_enqueue_service.py b/backend/services/notification_enqueue_service.py index bf90b8f..d62dee5 100644 --- a/backend/services/notification_enqueue_service.py +++ b/backend/services/notification_enqueue_service.py @@ -265,13 +265,15 @@ def _get_push_tokens_for_users(self, user_ids: List[str]) -> List[str]: async def enqueue_monthly_dump_notifications( self, - user_ids: List[str] + user_ids: List[str], + month: str ) -> bool: """ Enqueue push notifications for a batch of users when their monthly dump is ready. Parameters: user_ids (List[str]): User IDs of users whose monthly dumps are generated. + month (str): The month string in YYYY-MM format. Returns: bool: True if fully processed. @@ -280,25 +282,30 @@ async def enqueue_monthly_dump_notifications( return True try: - # We filter by 'push_notifications' or we can just send it to all tokens - # Let's filter by push_notifications setting (default to True if not found) + # Extract month name and year + from datetime import datetime + dt = datetime.strptime(month, "%Y-%m") + month_name = dt.strftime("%B") + year = dt.year + + # Filter by push_notifications setting filtered_recipients = self._filter_recipients_by_notification_settings( user_ids, notification_type="push_notifications" ) if not filtered_recipients: - logger.info("No recipients with push_notifications enabled for monthly dump batch") + logger.info(f"No recipients with push_notifications enabled for {month_name} dump batch") return True push_tokens = self._get_push_tokens_for_users(filtered_recipients) if not push_tokens: - logger.info("No push tokens found for monthly dump batch") + logger.info(f"No push tokens found for {month_name} dump batch") return True - title = "Your Monthly Dump is Ready! πŸŽ‰" - body = "Relive your best moments from last month." + title = f"Your {month_name} Dump is Ready! πŸŽ‰" + body = f"Relive your best moments from {month_name} {year}." success = self.notification_service.enqueue_notification( title=title, @@ -306,22 +313,23 @@ async def enqueue_monthly_dump_notifications( recipients=push_tokens, priority="normal", metadata={ - "notification_type": "monthly_dump_ready" + "notification_type": "monthly_dump_ready", + "month": month }, data={ - "page_url": "/vault", + "page_url": f"/monthly-dumps/{month}", } ) if success: logger.info( - f"Monthly dump batch notification enqueued: recipients={len(push_tokens)}" + f"Monthly dump batch notification enqueued for {month}: recipients={len(push_tokens)}" ) else: - logger.error("Failed to enqueue monthly dump batch notification") + logger.error(f"Failed to enqueue monthly dump batch notification for {month}") return success except Exception as e: - logger.error(f"Error enqueueing monthly dump batch notification: {str(e)}", exc_info=True) + logger.error(f"Error enqueueing monthly dump batch notification for {month}: {str(e)}", exc_info=True) return False From 3730999373963e7d772b845c305f6e31b20e3cdb Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 16:42:55 -0400 Subject: [PATCH 07/45] fixed failing unit test --- backend/tests/test_monthly_dump_queue_notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_monthly_dump_queue_notifications.py b/backend/tests/test_monthly_dump_queue_notifications.py index 4a7c027..9a3ae3f 100644 --- a/backend/tests/test_monthly_dump_queue_notifications.py +++ b/backend/tests/test_monthly_dump_queue_notifications.py @@ -101,4 +101,4 @@ async def test_monthly_dump_queue_enqueues_notification_on_success(): assert stats["failed"] == 0 # Check that it called the enqueue notifications logic ONLY once, for all successful users - mock_notif_instance.enqueue_monthly_dump_notifications.assert_awaited_once_with(["user-B", "user-C"]) + mock_notif_instance.enqueue_monthly_dump_notifications.assert_awaited_once_with(["user-B", "user-C"], "2026-04") From a365ebfe15c3e9b71aa9221c418c2c0016e0498d Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 24 Apr 2026 16:43:13 -0400 Subject: [PATCH 08/45] added dynamic fields to structured logs --- .../services/notification_enqueue_service.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/services/notification_enqueue_service.py b/backend/services/notification_enqueue_service.py index d62dee5..9cd93e4 100644 --- a/backend/services/notification_enqueue_service.py +++ b/backend/services/notification_enqueue_service.py @@ -323,13 +323,20 @@ async def enqueue_monthly_dump_notifications( if success: logger.info( - f"Monthly dump batch notification enqueued for {month}: recipients={len(push_tokens)}" + "Monthly dump batch notification enqueued", + extra={"month": month, "recipients": len(push_tokens)}, ) else: - logger.error(f"Failed to enqueue monthly dump batch notification for {month}") - + logger.error( + "Failed to enqueue monthly dump batch notification", + extra={"month": month}, + ) + return success - - except Exception as e: - logger.error(f"Error enqueueing monthly dump batch notification for {month}: {str(e)}", exc_info=True) + + except Exception: + logger.exception( + "Error enqueueing monthly dump batch notification", + extra={"month": month}, + ) return False From 39bdcaa86d83181216414357667714f913e69b0a Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:16:40 -0400 Subject: [PATCH 09/45] added onthly dump to database files --- frontend/constants/supabase.ts | 5 ++- frontend/types/database.ts | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/frontend/constants/supabase.ts b/frontend/constants/supabase.ts index 719e1af..535d054 100644 --- a/frontend/constants/supabase.ts +++ b/frontend/constants/supabase.ts @@ -15,12 +15,15 @@ export const TABLES = { USER_STREAKS: 'user_streaks', PHONE_NUMBER_UPDATES: 'phone_number_updates', ENTRY_REPORTS: 'entry_reports', + MONTHLY_DUMPS: 'monthly_dumps', } as const; // Storage Bucket Names export const STORAGE_BUCKETS = { MEDIA: 'media', AVATARS: 'avatars', + MONTHLY_DUMPS: 'monthly_dumps', + STICKERS: 'stickers', } as const; // Entry Types @@ -70,7 +73,7 @@ export const SCHEMA = { metadata: 'jsonb', created_at: 'timestamptz DEFAULT now()', updated_at: 'timestamptz DEFAULT now()', - + }, FRIENDSHIPS: { id: 'uuid PRIMARY KEY DEFAULT gen_random_uuid()', diff --git a/frontend/types/database.ts b/frontend/types/database.ts index 961c9d1..415ed4c 100644 --- a/frontend/types/database.ts +++ b/frontend/types/database.ts @@ -372,6 +372,85 @@ export interface Database { created_at?: string } } + monthly_dumps: { + Row: { + id: string + user_id: string + month: string + timezone: string + status: 'pending' | 'processing' | 'completed' | 'failed' + slides: Json[] | null + photo_count: number + video_count: number + audio_count: number + grid_count: number + error: string | null + created_at: string + updated_at: string + completed_at: string | null + } + Insert: { + id?: string + user_id: string + month: string + timezone?: string + status?: 'pending' | 'processing' | 'completed' | 'failed' + slides?: Json[] | null + photo_count?: number + video_count?: number + audio_count?: number + grid_count?: number + error?: string | null + created_at?: string + updated_at?: string + completed_at?: string | null + } + Update: { + id?: string + user_id?: string + month?: string + timezone?: string + status?: 'pending' | 'processing' | 'completed' | 'failed' + slides?: Json[] | null + photo_count?: number + video_count?: number + audio_count?: number + grid_count?: number + error?: string | null + created_at?: string + updated_at?: string + completed_at?: string | null + } + } + entry_reports: { + Row: { + id: string + entry_id: string + reporter_id: string + reason: string + details: string | null + status: 'pending' | 'reviewed' | 'dismissed' + created_at: string + } + Insert: { + id?: string + entry_id: string + reporter_id: string + reason: string + details?: string | null + status?: 'pending' | 'reviewed' | 'dismissed' + created_at?: string + } + Update: { + id?: string + entry_id?: string + reporter_id?: string + reason?: string + details?: string | null + status?: 'pending' | 'reviewed' | 'dismissed' + created_at?: string + } + } } Views: { [_ in never]: never From 0460e7e8312b53db6f2da744f34a48c015e60a4b Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:17:43 -0400 Subject: [PATCH 10/45] created hook and service layer to read and update monthly dumps in DB --- .../hooks/__tests__/use-monthly-dump.test.tsx | 98 ++++ frontend/hooks/use-monthly-dump.ts | 133 ++++++ frontend/services/monthly-dump-service.ts | 421 ++++++++++++++++++ 3 files changed, 652 insertions(+) create mode 100644 frontend/hooks/__tests__/use-monthly-dump.test.tsx create mode 100644 frontend/hooks/use-monthly-dump.ts create mode 100644 frontend/services/monthly-dump-service.ts diff --git a/frontend/hooks/__tests__/use-monthly-dump.test.tsx b/frontend/hooks/__tests__/use-monthly-dump.test.tsx new file mode 100644 index 0000000..c70fb68 --- /dev/null +++ b/frontend/hooks/__tests__/use-monthly-dump.test.tsx @@ -0,0 +1,98 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { useMonthlyDump } from '../use-monthly-dump'; +import { MonthlyDumpService } from '@/services/monthly-dump-service'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Mock MonthlyDumpService +jest.mock('@/services/monthly-dump-service'); + +// Mock useAuth +jest.mock('../use-auth', () => ({ + useAuth: () => ({ user: { id: 'test-user' } }), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + return Wrapper; +}; + +describe('useMonthlyDump Hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('is enabled during the last 3 days of the month (e.g., April 29)', async () => { + jest.setSystemTime(new Date('2026-04-29T12:00:00Z')); + + const { result } = renderHook(() => useMonthlyDump(), { wrapper: createWrapper() }); + + expect(result.current.isEnabled).toBe(true); + expect(result.current.month).toBe('2026-04'); + }); + + it('is enabled during the first 4 days of the month (e.g., May 2)', async () => { + jest.setSystemTime(new Date('2026-05-02T12:00:00Z')); + + const { result } = renderHook(() => useMonthlyDump(), { wrapper: createWrapper() }); + + expect(result.current.isEnabled).toBe(true); + // Should be checking for April's dump because it's early May + expect(result.current.month).toBe('2026-04'); + }); + + it('is disabled in the middle of the month (e.g., April 15)', async () => { + jest.setSystemTime(new Date('2026-04-15T12:00:00Z')); + + const { result } = renderHook(() => useMonthlyDump(), { wrapper: createWrapper() }); + + expect(result.current.isEnabled).toBe(false); + }); + + it('handles 404 (not found) when fetching dump', async () => { + jest.setSystemTime(new Date('2026-04-29T12:00:00Z')); + (MonthlyDumpService.getMonthlyDump as jest.Mock).mockResolvedValue(null); + + const { result } = renderHook(() => useMonthlyDump(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.hasDump).toBe(false); + expect(result.current.slides).toEqual([]); + }); + + it('successfully fetches and transforms slides', async () => { + jest.setSystemTime(new Date('2026-04-29T12:00:00Z')); + const mockResponse = { + status: 'completed', + slides: [ + { type: 'image', storage_path: 'test/path.jpg', duration_seconds: 5 } + ] + }; + (MonthlyDumpService.getMonthlyDump as jest.Mock).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useMonthlyDump(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.hasDump).toBe(true); + expect(result.current.slides[0].url).toBeDefined(); + expect(result.current.slides[0].type).toBe('image'); + }); +}); diff --git a/frontend/hooks/use-monthly-dump.ts b/frontend/hooks/use-monthly-dump.ts new file mode 100644 index 0000000..18198ae --- /dev/null +++ b/frontend/hooks/use-monthly-dump.ts @@ -0,0 +1,133 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuth } from './use-auth'; +import { CachedMonthlyDump, MonthlyDumpService, MonthlyDumpSlide } from '@/services/monthly-dump-service'; +import { getDate, getDaysInMonth, subMonths, format } from 'date-fns'; +import { useMemo } from 'react'; +import { STORAGE_BUCKETS } from '@/constants/supabase'; + +export interface UseMonthlyDumpResult { + hasDump: boolean; + slides: MonthlyDumpSlide[]; + isLoading: boolean; + status?: string; + month?: string; + isEnabled: boolean; +} + +const SUPABASE_STORAGE_PUBLIC_SEGMENT = '/storage/v1/object/public/'; + +function toSlideType(type: string): MonthlyDumpSlide['type'] { + if (type === 'photo') return 'image'; + if (type === 'video') return 'video'; + if (type === 'audio') return 'audio'; + return 'image'; +} + +function buildSupabasePublicUrl(storagePath?: string): string { + if (!storagePath) return ''; + const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; + if (!baseUrl) return ''; + + const sanitizedPath = storagePath.replace(/^\/+/, ''); + if (sanitizedPath.startsWith('http://') || sanitizedPath.startsWith('https://')) { + return sanitizedPath; + } + + const [possibleBucket, ...remaining] = sanitizedPath.split('/'); + const hasBucketPrefix = Object.values(STORAGE_BUCKETS).includes(possibleBucket as (typeof STORAGE_BUCKETS)[keyof typeof STORAGE_BUCKETS]) && remaining.length > 0; + const bucket = hasBucketPrefix ? possibleBucket : STORAGE_BUCKETS.MEDIA; + const path = hasBucketPrefix ? remaining.join('/') : sanitizedPath; + + return `${baseUrl}${SUPABASE_STORAGE_PUBLIC_SEGMENT}${bucket}/${path}`; +} + +function mergePendingLocalSlides(remoteSlides: MonthlyDumpSlide[], cached?: CachedMonthlyDump | null): MonthlyDumpSlide[] { + if (!cached?.slides?.length) return remoteSlides; + + const remoteEntryIds = new Set(remoteSlides.map((slide) => slide.entry_id).filter(Boolean)); + const pendingLocalSlides = cached.slides.filter((slide) => { + if (!slide.url.startsWith('file://')) return false; + if (!slide.entry_id) return false; + return !remoteEntryIds.has(slide.entry_id); + }); + + if (!pendingLocalSlides.length) return remoteSlides; + return [...remoteSlides, ...pendingLocalSlides]; +} + +export function useMonthlyDump(requestedMonth?: string): UseMonthlyDumpResult { + const { user } = useAuth(); + + const { isEnabled, dumpMonth } = useMemo(() => { + if (requestedMonth) { + return { isEnabled: true, dumpMonth: requestedMonth }; + } + + const today = new Date(); + const date = getDate(today); + const daysInMonth = getDaysInMonth(today); + + const isFirst4Days = date <= 4; + const isLast3Days = date > daysInMonth - 3; + + const enabled = isFirst4Days || isLast3Days; + + let month: string; + if (isFirst4Days) { + // If we are in the first 4 days of May, we want April's dump + month = format(subMonths(today, 1), 'yyyy-MM'); + } else { + // If we are in the last 3 days of April, we want April's dump + month = format(today, 'yyyy-MM'); + } + + return { isEnabled: enabled, dumpMonth: month }; + }, [requestedMonth]); + + const { data, isLoading } = useQuery({ + queryKey: ['monthlyDump', user?.id, dumpMonth], + queryFn: async () => { + if (!user?.id || !dumpMonth) return null; + + const cachedDump = await MonthlyDumpService.getCachedMonthlyDump(user.id, dumpMonth); + + try { + const response = await MonthlyDumpService.getMonthlyDump(user.id, dumpMonth); + if (!response) { + if (cachedDump) return cachedDump; + return { hasDump: false, slides: [] }; + } + + const transformedSlides = (response.slides || []).map((slide) => ({ + ...slide, + type: toSlideType(slide.type as string), + url: slide.url || buildSupabasePublicUrl(slide.storage_path), + })); + + const mergedSlides = mergePendingLocalSlides(transformedSlides, cachedDump); + const payload = { + hasDump: response.status === 'completed', + slides: mergedSlides, + status: response.status, + }; + await MonthlyDumpService.setCachedMonthlyDump(user.id, dumpMonth, payload); + + return payload; + } catch (error) { + if (cachedDump) return cachedDump; + return { hasDump: false, slides: [] }; + } + }, + //enabled: isEnabled && !!user?.id, + staleTime: 1000 * 60 * 10, // 10 minutes + }); + + return { + hasDump: data?.hasDump ?? false, + slides: data?.slides ?? [], + isLoading, + status: data?.status, + month: dumpMonth, + isEnabled, + }; +} diff --git a/frontend/services/monthly-dump-service.ts b/frontend/services/monthly-dump-service.ts new file mode 100644 index 0000000..eec6406 --- /dev/null +++ b/frontend/services/monthly-dump-service.ts @@ -0,0 +1,421 @@ +import { z } from 'zod'; +import { logger } from '@/lib/logger'; +import { supabase } from '@/lib/supabase'; +import { TABLES, STORAGE_BUCKETS } from '@/constants/supabase'; +import { convertToArrayBuffer } from '@/lib/utils'; +import { deviceStorage } from './device-storage'; + +const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL ?? 'http://localhost:8000'; +const MONTHLY_DUMP_CACHE_TTL_MINUTES = 31 * 24 * 60; +const MONTHLY_DUMP_GRID_QUEUE_KEY = 'monthly_dump_grid_queue'; + +const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); +const userIdSchema = z.string().min(1); + +const monthlyDumpGridPhotoSchema = z.object({ + id: z.string().min(1), + content_url: z.string().url(), +}); + +const monthlyDumpSlideSchema = z.object({ + type: z.enum(['image', 'video', 'audio']), + url: z.string().min(1), + duration_seconds: z.number().min(1), + entry_id: z.string().optional(), + storage_path: z.string().optional(), +}); + +export type MonthlyDumpSlide = z.infer; + +export interface MonthlyDumpResponse { + status: 'completed' | 'pending' | 'processing' | 'failed'; + slides: MonthlyDumpSlide[]; +} + +export interface MonthlyDumpGridPhoto { + id: string; + content_url: string; +} + +export interface CachedMonthlyDump { + hasDump: boolean; + slides: MonthlyDumpSlide[]; + status?: MonthlyDumpResponse['status']; +} + +interface MonthlyDumpGridQueueItem { + idempotencyKey: string; + userId: string; + month: string; + localGridUri: string; + optimisticSlide: MonthlyDumpSlide; +} + +export class MonthlyDumpService { + private static queueInMemory: MonthlyDumpGridQueueItem[] | null = null; + private static isProcessingQueue = false; + + private static cacheKey(userId: string, month: string): string { + return `monthly_dump_${userId}_${month}`; + } + + private static resolveStorageTarget(storagePath: string): { bucket: string; path: string } { + const sanitizedPath = storagePath.replace(/^\/+/, ''); + const [possibleBucket, ...remainingPath] = sanitizedPath.split('/'); + const hasBucketPrefix = + Object.values(STORAGE_BUCKETS).includes( + possibleBucket as (typeof STORAGE_BUCKETS)[keyof typeof STORAGE_BUCKETS] + ) && remainingPath.length > 0; + + if (hasBucketPrefix) { + return { bucket: possibleBucket, path: remainingPath.join('/') }; + } + + return { bucket: STORAGE_BUCKETS.MONTHLY_DUMPS, path: sanitizedPath }; + } + + private static async loadGridQueue(): Promise { + if (this.queueInMemory) return this.queueInMemory; + const existing = await deviceStorage.getItem(MONTHLY_DUMP_GRID_QUEUE_KEY); + this.queueInMemory = Array.isArray(existing) ? existing : []; + return this.queueInMemory; + } + + private static async saveGridQueue(queue: MonthlyDumpGridQueueItem[]): Promise { + this.queueInMemory = queue; + await deviceStorage.setItem(MONTHLY_DUMP_GRID_QUEUE_KEY, queue); + } + + private static async dequeueGridQueueItem(): Promise { + const queue = await this.loadGridQueue(); + const next = queue.shift(); + await this.saveGridQueue(queue); + return next; + } + + private static async processGridQueueItem(item: MonthlyDumpGridQueueItem): Promise { + const uploaded = await this.saveCreatedGridImageToStorage({ + userId: item.userId, + month: item.month, + gridImageUri: item.localGridUri, + }); + + const monthDate = `${item.month}-01`; + const { data: dump, error: dumpError } = await supabase + .from(TABLES.MONTHLY_DUMPS) + .select('id, slides, grid_count') + .eq('user_id', item.userId) + .eq('month', monthDate) + .maybeSingle(); + + if (dumpError) { + throw new Error(dumpError.message); + } + + if (!dump) { + throw new Error('Monthly dump does not exist for this month.'); + } + + const existingSlides = ((dump as any).slides ?? []) as any[]; + const existingIndex = existingSlides.findIndex( + (slide) => slide?.entry_id && slide.entry_id === item.optimisticSlide.entry_id + ); + + const finalSlide = { + ...item.optimisticSlide, + url: uploaded.url, + storage_path: undefined, + }; + + const nextSlides = [...existingSlides]; + if (existingIndex >= 0) { + nextSlides[existingIndex] = finalSlide; + } else { + nextSlides.push(finalSlide); + } + + const { error: updateError } = await supabase + .from(TABLES.MONTHLY_DUMPS) + .update({ + slides: nextSlides as any, + grid_count: existingIndex >= 0 ? (dump as any).grid_count : (((dump as any).grid_count ?? 0) + 1), + updated_at: new Date().toISOString(), + } as never) + .eq('id', (dump as any).id); + + if (updateError) { + throw new Error(updateError.message); + } + + await this.replaceCachedGridSlideUrl({ + userId: item.userId, + month: item.month, + entryId: item.optimisticSlide.entry_id ?? '', + nextUrl: uploaded.url, + }); + + deviceStorage.emit('monthlyDumpUpdated', { + userId: item.userId, + month: item.month, + entryId: item.optimisticSlide.entry_id, + url: uploaded.url, + }); + } + + private static async startGridQueueProcessor(): Promise { + if (this.isProcessingQueue) return; + this.isProcessingQueue = true; + + try { + let next: MonthlyDumpGridQueueItem | undefined; + // eslint-disable-next-line no-constant-condition + while ((next = await this.dequeueGridQueueItem())) { + try { + await this.processGridQueueItem(next); + } catch (error) { + logger.error('Monthly dump custom grid queue item failed', { error, next }); + } + } + } finally { + this.isProcessingQueue = false; + } + } + + /** + * Fetches a completed monthly dump directly from Supabase. + */ + static async getMonthlyDump(userId: string, month: string): Promise { + userIdSchema.parse(userId); + monthSchema.parse(month); + + try { + const monthDate = `${month}-01`; + const { data: dump, error: dumpError } = await supabase + .from(TABLES.MONTHLY_DUMPS) + .select('*') + .eq('user_id', userId) + .eq('month', monthDate) + .eq('status', 'completed') + .maybeSingle(); + + if (dumpError) { + logger.error('Error fetching monthly dump from Supabase', { dumpError, userId, month }); + throw new Error('Failed to retrieve monthly dump. Please try again later.'); + } + + if (!dump) { + logger.info('No completed monthly dump found for user', { userId, month }); + return null; + } + + const dumpSlides = ((dump as any)?.slides ?? []) as any[]; + const hydratedSlides = await Promise.all( + dumpSlides.map(async (slide) => { + if (!slide?.storage_path) return slide; + if (slide?.url) return slide; + + const { storage_path, ...rest } = slide; + const { bucket, path } = this.resolveStorageTarget(storage_path); + const { + data: { publicUrl }, + } = supabase.storage.from(bucket).getPublicUrl(path); + + return { ...rest, storage_path, url: publicUrl }; + }) + ); + + return { + status: 'completed', + slides: hydratedSlides as MonthlyDumpSlide[], + }; + } catch (error) { + logger.error('MonthlyDumpService.getMonthlyDump error', { error }); + throw error; + } + } + + static async getEntries( + userId: string, + month: string, + type: 'photo' | 'video' | 'audio' = 'photo', + page = 1 + ) { + userIdSchema.parse(userId); + monthSchema.parse(month); + z.number().int().min(1).parse(page); + + try { + const { apiFetch } = await import('@/lib/api-client'); + const url = `${BACKEND_URL}/user/${userId}/entries/${month}?type=${type}&page=${page}`; + const response = await apiFetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch entries: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + logger.error('MonthlyDumpService.getEntries error', { error }); + throw error; + } + } + + static async getCachedMonthlyDump(userId: string, month: string): Promise { + userIdSchema.parse(userId); + monthSchema.parse(month); + return deviceStorage.getItem(this.cacheKey(userId, month)); + } + + static async setCachedMonthlyDump(userId: string, month: string, payload: CachedMonthlyDump): Promise { + userIdSchema.parse(userId); + monthSchema.parse(month); + + const validatedSlides = z.array(monthlyDumpSlideSchema).parse(payload.slides); + + await deviceStorage.setItem( + this.cacheKey(userId, month), + { + hasDump: payload.hasDump, + status: payload.status, + slides: validatedSlides, + }, + MONTHLY_DUMP_CACHE_TTL_MINUTES + ); + } + + static async create3x2GridImage( + photos: MonthlyDumpGridPhoto[], + captureGridImage: (photos: MonthlyDumpGridPhoto[]) => Promise + ): Promise { + const validatedPhotos = z.array(monthlyDumpGridPhotoSchema).min(1).max(6).parse(photos); + if (typeof captureGridImage !== 'function') { + throw new Error('captureGridImage must be a function.'); + } + + const localUri = await captureGridImage(validatedPhotos); + z.string().min(1).parse(localUri); + return localUri.startsWith('file://') ? localUri : `file://${localUri}`; + } + + static async saveCreatedGridImageToStorage(params: { + userId: string; + month: string; + gridImageUri: string; + }): Promise<{ url: string; storagePath: string }> { + const schema = z.object({ + userId: userIdSchema, + month: monthSchema, + gridImageUri: z.string().min(1), + }); + + const { userId, month, gridImageUri } = schema.parse(params); + const uploadData = await convertToArrayBuffer(gridImageUri); + const fileName = `${userId}/monthly-dumps/${month}/grid_${Date.now()}.png`; + + const { data, error } = await supabase.storage + .from(STORAGE_BUCKETS.MEDIA) + .upload(fileName, uploadData, { + cacheControl: '3600', + contentType: 'image/png', + upsert: false, + }); + + if (error) { + throw new Error(error.message); + } + + const { + data: { publicUrl }, + } = supabase.storage.from(STORAGE_BUCKETS.MEDIA).getPublicUrl(data.path); + + return { + url: publicUrl, + storagePath: data.path, + }; + } + + static async enqueueCustomGridCreation(params: { + userId: string; + month: string; + photos: MonthlyDumpGridPhoto[]; + captureGridImage: (photos: MonthlyDumpGridPhoto[]) => Promise; + }): Promise { + const schema = z.object({ + userId: userIdSchema, + month: monthSchema, + photos: z.array(monthlyDumpGridPhotoSchema).min(1).max(6), + captureGridImage: z.any(), + }); + + const { userId, month, photos, captureGridImage } = schema.parse(params); + if (typeof captureGridImage !== 'function') { + throw new Error('captureGridImage must be a function.'); + } + const localGridUri = await this.create3x2GridImage(photos, captureGridImage); + const entryId = `custom-grid-${Date.now()}`; + + const optimisticSlide: MonthlyDumpSlide = { + type: 'image', + url: localGridUri, + duration_seconds: 6, + entry_id: entryId, + }; + + const cached = await this.getCachedMonthlyDump(userId, month); + const nextSlides = [...(cached?.slides ?? []), optimisticSlide]; + await this.setCachedMonthlyDump(userId, month, { + hasDump: true, + status: cached?.status ?? 'completed', + slides: nextSlides, + }); + + const queue = await this.loadGridQueue(); + const idempotencyKey = `${userId}-${month}-${entryId}`; + const alreadyQueued = queue.some((item) => item.idempotencyKey === idempotencyKey); + + if (!alreadyQueued) { + queue.push({ + idempotencyKey, + userId, + month, + localGridUri, + optimisticSlide, + }); + await this.saveGridQueue(queue); + void this.startGridQueueProcessor(); + } + + return optimisticSlide; + } + + static async replaceCachedGridSlideUrl(params: { + userId: string; + month: string; + entryId: string; + nextUrl: string; + }): Promise { + const schema = z.object({ + userId: userIdSchema, + month: monthSchema, + entryId: z.string().min(1), + nextUrl: z.string().min(1), + }); + const { userId, month, entryId, nextUrl } = schema.parse(params); + + const cached = await this.getCachedMonthlyDump(userId, month); + if (!cached) return; + + const updatedSlides = cached.slides.map((slide) => { + if (slide.entry_id !== entryId) return slide; + return { + ...slide, + url: nextUrl, + }; + }); + + await this.setCachedMonthlyDump(userId, month, { + ...cached, + slides: updatedSlides, + }); + } +} From 84b377d5ebdcfd1e6da738345f04592410965058 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:19:37 -0400 Subject: [PATCH 11/45] added monthly dump banner to capture page --- .../__tests__/banner-visibility.test.tsx | 179 +++++++++++++++++ frontend/app/capture/index.tsx | 126 +++++++++++- .../components/capture/capture-header.tsx | 22 ++- frontend/components/date-container.tsx | 62 +++++- .../monthly-dumps/monthly-dump-banner.tsx | 187 ++++++++++++++++++ 5 files changed, 559 insertions(+), 17 deletions(-) create mode 100644 frontend/app/capture/__tests__/banner-visibility.test.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-banner.tsx diff --git a/frontend/app/capture/__tests__/banner-visibility.test.tsx b/frontend/app/capture/__tests__/banner-visibility.test.tsx new file mode 100644 index 0000000..bcb5d90 --- /dev/null +++ b/frontend/app/capture/__tests__/banner-visibility.test.tsx @@ -0,0 +1,179 @@ + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import CaptureScreen from '../index'; +import { useMonthlyDump } from '@/hooks/use-monthly-dump'; +import { useAuthContext } from '@/providers/auth-provider'; +import { useCameraPermissions } from 'expo-camera'; + +// Mock dependencies +jest.mock('@/hooks/use-monthly-dump'); +jest.mock('@/providers/auth-provider'); +jest.mock('@/providers/save-lock-provider', () => ({ + SaveLockProvider: ({ children }: any) => children, + useSaveLock: () => ({ + unlockSave: jest.fn(), + isSaveLocked: false + }), +})); +jest.mock('expo-camera', () => ({ + useCameraPermissions: jest.fn(), + CameraView: () => null, +})); +jest.mock('@/hooks/use-responsive', () => ({ + useResponsive: () => ({ + minTouchTarget: 44, + contentPadding: 20, + maxContentWidth: 600, + }), +})); +jest.mock('@/hooks/use-timezone', () => ({ + useTimezone: () => ({ + convertToLocalTimezone: (d: any) => d, + }), +})); +jest.mock('@/hooks/use-media-capture', () => ({ + useMediaCapture: () => ({ + isCapturing: false, + recordingDuration: 0, + clearCapture: jest.fn(), + }), +})); +jest.mock('@/hooks/use-vault-preloader', () => ({ + useVaultPreloader: jest.fn(), +})); +jest.mock('@/hooks/phone-number/use-manage-phone-sheet', () => ({ + useManagePhoneSheet: () => ({ + showPhoneSheet: false, + setShowPhoneSheet: jest.fn(), + }), +})); +jest.mock('@/hooks/capture/use-camera-control', () => ({ + useCameraControl: () => ({ + facing: 'back', + toggleCameraFacing: jest.fn(), + cameraRef: { current: null }, + }), +})); +jest.mock('@/hooks/capture/use-video-capture', () => ({ + useVideoCapture: () => ({ + isVideoRecording: false, + videoDuration: 0, + onCameraReady: jest.fn(), + }), +})); +jest.mock('@/hooks/capture/use-photo-capture', () => ({ + usePhotoCapture: () => ({ + takePicture: jest.fn(), + }), +})); +jest.mock('@/hooks/capture/use-audio-capture', () => ({ + useAudioCapture: () => ({ + toggleRecording: jest.fn(), + }), +})); +jest.mock('@/hooks/capture/use-media-upload', () => ({ + useMediaUpload: () => ({ + handleUpload: jest.fn(), + }), +})); +jest.mock('expo-router', () => ({ + useFocusEffect: (cb: any) => cb(), + useRouter: () => ({ push: jest.fn() }), +})); + +// Mock sub-components to avoid rendering issues +jest.mock('@/components/capture/mode-selector', () => ({ + __esModule: true, + ModeSelector: () => null, +})); +jest.mock('@/components/capture/media-display', () => ({ + __esModule: true, + MediaDisplay: () => null, +})); +jest.mock('@/components/capture/capture-actions', () => ({ + __esModule: true, + CaptureActions: () => null, +})); +jest.mock('@/components/capture/vault-button', () => ({ + __esModule: true, + VaultButton: () => null, +})); +jest.mock('@/components/phone-number-bottom-sheet', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('@/components/monthly-dumps/monthly-dump-banner', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + __esModule: true, + default: () => , + }; +}); +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({ children }: any) => children, +})); + +describe('CaptureScreen - Monthly Dump Banner Visibility', () => { + beforeEach(() => { + (useCameraPermissions as jest.Mock).mockReturnValue([{ granted: true }, jest.fn()]); + (useAuthContext as jest.Mock).mockReturnValue({ profile: { full_name: 'Test User' } }); + }); + + it('does not show the recap chip when no dump is ready and it is not recap season', () => { + (useMonthlyDump as jest.Mock).mockReturnValue({ + month: null, + hasDump: false, + isEnabled: false, + isLoading: false, + }); + + const { queryByText } = render(); + + // Header should not contain recap text + expect(queryByText(/Recap/)).toBeNull(); + }); + + it('shows the recap chip when a dump is ready', () => { + (useMonthlyDump as jest.Mock).mockReturnValue({ + month: '2026-04', + hasDump: true, + isEnabled: true, + isLoading: false, + }); + + const { getByText } = render(); + + // Check if "April RecapπŸŽ‰" chip is visible + expect(getByText('April RecapπŸŽ‰')).toBeTruthy(); + }); + + it('toggles the banner when the date/chip is pressed', async () => { + (useMonthlyDump as jest.Mock).mockReturnValue({ + month: '2026-04', + hasDump: true, + isEnabled: true, + isLoading: false, + }); + + const { getByTestId, queryByTestId } = render(); + + // The banner container itself should have pointerEvents="none" initially + // and opacity 0 (though opacity is harder to check in plain RNTL without getting styles). + // We can check if the Banner component is rendered. + expect(getByTestId('monthly-dump-banner')).toBeTruthy(); + + // The trigger button (DateContainer) + const trigger = getByTestId('banner-trigger-button'); + + // Toggle ON + await act(async () => { + fireEvent.press(trigger); + }); + + // In a real test we'd check if specific state changed or if pointerEvents is now 'auto' + // but the visibility is controlled by reanimated and useState. + // We've verified it responds to press and doesn't crash. + }); +}); diff --git a/frontend/app/capture/index.tsx b/frontend/app/capture/index.tsx index 3e8dc56..b654411 100644 --- a/frontend/app/capture/index.tsx +++ b/frontend/app/capture/index.tsx @@ -1,9 +1,16 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; import { useFocusEffect } from 'expo-router'; import { useCameraPermissions } from 'expo-camera'; import Animated, { - SlideInUp + Easing, + Extrapolation, + SlideInUp, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, } from 'react-native-reanimated'; import { useMediaCapture } from '@/hooks/use-media-capture'; import { useAuthContext } from '@/providers/auth-provider'; @@ -28,11 +35,21 @@ import { ModeSelector } from '@/components/capture/mode-selector'; import { MediaDisplay } from '@/components/capture/media-display'; import { CaptureActions } from '@/components/capture/capture-actions'; import { VaultButton } from '@/components/capture/vault-button'; +import MonthlyDumpBanner from '@/components/monthly-dumps/monthly-dump-banner'; +import { useMonthlyDump } from '@/hooks/use-monthly-dump'; export default function CaptureScreen() { + const RECAP_CLOSE_DURATION_MS = 620; + const RECAP_CHIP_REVEAL_EARLY_MS = 140; + const responsive = useResponsive(); const { convertToLocalTimezone } = useTimezone(); const [selectedMode, setSelectedMode] = useState<'camera' | 'microphone'>('camera'); + const [isRecapExpanded, setIsRecapExpanded] = useState(false); + const [isRecapChipReady, setIsRecapChipReady] = useState(true); + const recapBannerProgress = useSharedValue(0); + const recapChipRevealTimerRef = useRef | null>(null); + const { month, hasDump, isEnabled } = useMonthlyDump(); const [permission, requestPermission] = useCameraPermissions(); @@ -111,6 +128,66 @@ export default function CaptureScreen() { }; const defaultAvatarUrl = getDefaultAvatarUrl(profile?.full_name || ''); + const canShowRecap = !!month && (hasDump || isEnabled); + + const formatRecapChipMonth = (value?: string) => { + if (!value) return ''; + try { + const [year, monthValue] = value.split('-'); + const date = new Date(parseInt(year, 10), parseInt(monthValue, 10) - 1); + return date.toLocaleString('default', { month: 'long' }); + } catch { + return value; + } + }; + + const toggleRecapBanner = () => { + if (!canShowRecap) return; + + if (recapChipRevealTimerRef.current) { + clearTimeout(recapChipRevealTimerRef.current); + recapChipRevealTimerRef.current = null; + } + + const nextExpanded = !isRecapExpanded; + if (nextExpanded) { + setIsRecapChipReady(false); + } else { + const revealAfterMs = Math.max(0, RECAP_CLOSE_DURATION_MS - RECAP_CHIP_REVEAL_EARLY_MS); + recapChipRevealTimerRef.current = setTimeout(() => { + setIsRecapChipReady(true); + recapChipRevealTimerRef.current = null; + }, revealAfterMs); + } + + setIsRecapExpanded(nextExpanded); + recapBannerProgress.value = withTiming(nextExpanded ? 1 : 0, { + duration: RECAP_CLOSE_DURATION_MS, + easing: Easing.inOut(Easing.cubic), + }, (finished) => { + if (!finished) return; + if (!nextExpanded) { + runOnJS(setIsRecapChipReady)(true); + } + }); + }; + + useEffect(() => { + return () => { + if (!recapChipRevealTimerRef.current) return; + clearTimeout(recapChipRevealTimerRef.current); + }; + }, []); + + const recapBannerAnimatedStyle = useAnimatedStyle(() => { + const opacity = interpolate(recapBannerProgress.value, [0, 0.2, 1], [0, 1, 1], Extrapolation.CLAMP); + const translateY = interpolate(recapBannerProgress.value, [0, 0.55, 1], [-24, 0, 0], Extrapolation.CLAMP); + + return { + opacity, + transform: [{ translateY }], + }; + }); if (!permission) { return ; @@ -136,11 +213,26 @@ export default function CaptureScreen() { > - + + + + + + + + + Date; + onDatePress?: () => void; + showRecapChip?: boolean; + recapChipText?: string; + highlightDateBorder?: boolean; } -export const CaptureHeader = ({ profile, defaultAvatarUrl, convertToLocalTimezone }: CaptureHeaderProps) => { +export const CaptureHeader = ({ + profile, + defaultAvatarUrl, + convertToLocalTimezone, + onDatePress, + showRecapChip = false, + recapChipText, + highlightDateBorder = false, +}: CaptureHeaderProps) => { return ( - + void; + showRecapChip?: boolean; + recapChipText?: string; + highlightBorder?: boolean; } const getCurrentDate = (date: Date, timeZone?: string) => { @@ -19,16 +24,34 @@ const getCurrentDate = (date: Date, timeZone?: string) => { return date.toLocaleDateString('en-US', options); }; -export function DateContainer({ date, timezone }: DateContainerProps) { +export function DateContainer({ + date, + timezone, + onPress, + showRecapChip = false, + recapChipText = '', + highlightBorder = false, +}: DateContainerProps) { + const containerStyle = [ + styles.dateContainer, + highlightBorder ? styles.dateContainerHighlighted : null, + ]; + return ( - + {getCurrentDate(date, timezone)} - + {showRecapChip ? ( + + {recapChipText} + + ) : null} + ) } const styles = StyleSheet.create({ dateContainer: { + position: 'relative', backgroundColor: 'white', paddingHorizontal: 16, paddingVertical: 8, @@ -37,7 +60,12 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 4, - elevation: 2, + elevation: 3, + zIndex: 999 + }, + dateContainerHighlighted: { + borderWidth: 1.5, + borderColor: Colors.primary, }, dateText: { fontSize: scale(12), @@ -45,4 +73,22 @@ const styles = StyleSheet.create({ fontWeight: '500', fontFamily: 'Outfit-SemiBold', }, -}) \ No newline at end of file + recapChip: { + position: 'absolute', + right: -10, + bottom: verticalScale(-12), + backgroundColor: Colors.primary, + borderRadius: 12, + paddingHorizontal: 8, + paddingVertical: 3, + transform: [ + { rotate: '-2deg' } + ] + }, + recapChipText: { + fontSize: scale(10), + color: 'white', + fontFamily: 'Outfit-Bold', + fontWeight: '700', + }, +}) diff --git a/frontend/components/monthly-dumps/monthly-dump-banner.tsx b/frontend/components/monthly-dumps/monthly-dump-banner.tsx new file mode 100644 index 0000000..c524d7e --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-banner.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { Sparkles, Play } from 'lucide-react-native'; +import { useRouter } from 'expo-router'; +import { Colors } from '@/lib/constants'; +import { scale } from 'react-native-size-matters'; +import Animated, { + Extrapolation, + SharedValue, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; + +const circleWidths = 24; +const iconSize = circleWidths / 2 + 2; + +interface MonthlyDumpBannerProps { + month?: string; + animationProgress?: SharedValue; +} + +export default function MonthlyDumpBanner({ month, animationProgress }: MonthlyDumpBannerProps) { + const router = useRouter(); + + const formatMonth = (monthStr: string) => { + if (!monthStr) return ''; + try { + const [year, monthValue] = monthStr.split('-'); + const date = new Date(parseInt(year, 10), parseInt(monthValue, 10) - 1); + return date.toLocaleString('default', { month: 'long', year: 'numeric' }); + } catch { + return monthStr; + } + }; + + const textAnimatedStyle = useAnimatedStyle(() => { + if (!animationProgress) return { opacity: 1, maxWidth: 9999 }; + + const opacity = interpolate( + animationProgress.value, + [0, 0.9, 1], + [0, 0, 1], + Extrapolation.CLAMP + ); + + const maxWidth = interpolate( + animationProgress.value, + [0, 0.88, 1], + [0, 0, 9999], + Extrapolation.CLAMP + ); + + return { opacity, maxWidth }; + }); + + const sideButtonsAnimatedStyle = useAnimatedStyle(() => { + if (!animationProgress) return { opacity: 1 }; + + const opacity = interpolate( + animationProgress.value, + [0, 0.82, 1], + [0, 0, 1], + Extrapolation.CLAMP + ); + + return { opacity }; + }); + + const touchableAnimatedStyle = useAnimatedStyle(() => { + if (!animationProgress) return { width: '80%' }; + + const widthPercent = interpolate( + animationProgress.value, + [0, 0.55, 1], + [13, 13, 80], + Extrapolation.CLAMP + ); + + return { width: `${widthPercent}%` as any }; + }); + + const bannerShapeAnimatedStyle = useAnimatedStyle(() => { + if (!animationProgress) { + return { borderRadius: 24 }; + } + + const borderRadius = interpolate( + animationProgress.value, + [0, 0.55, 1], + [38, 38, 24], + Extrapolation.CLAMP + ); + + return { borderRadius }; + }); + + return ( + + + router.push(`/monthly-dumps/${month}`)} + style={styles.touchable} + > + + + + + + + + Your {formatMonth(month || '')} dump is ready! + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + marginTop: 12, + marginBottom: 8, + width: '100%', + }, + touchable: { + width: '100%', + }, + bannerShell: { + borderRadius: 24, + overflow: 'hidden', + backgroundColor: Colors.primary, + }, + blur: { + padding: scale(8), + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: scale(circleWidths), + height: scale(circleWidths), + borderRadius: scale(circleWidths / 2), + backgroundColor: 'rgba(192, 132, 252, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + title: { + color: '#F8FAFC', + fontSize: scale(12), + fontFamily: 'Outfit-Bold', + fontWeight: '700', + marginBottom: 2, + }, + playButton: { + width: scale(circleWidths), + height: scale(circleWidths), + borderRadius: scale(circleWidths / 2), + backgroundColor: '#8B5CF6', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, +}); From d64ed9b13db3647c153cd0fd612842dcce144e8e Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:20:49 -0400 Subject: [PATCH 12/45] added page to view dump slides --- frontend/app/monthly-dumps/[month].tsx | 283 ++++++++++++++++ .../monthly-dump-audio-slide.tsx | 31 ++ .../monthly-dump-grid-prompt-slide.tsx | 76 +++++ .../monthly-dump-progress-bar-item.tsx | 42 +++ .../monthly-dump-video-slide.tsx | 89 +++++ .../monthly-dumps/photo-grid-picker.tsx | 306 ++++++++++++++++++ 6 files changed, 827 insertions(+) create mode 100644 frontend/app/monthly-dumps/[month].tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-audio-slide.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-video-slide.tsx create mode 100644 frontend/components/monthly-dumps/photo-grid-picker.tsx diff --git a/frontend/app/monthly-dumps/[month].tsx b/frontend/app/monthly-dumps/[month].tsx new file mode 100644 index 0000000..020daee --- /dev/null +++ b/frontend/app/monthly-dumps/[month].tsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { View, Text, StyleSheet, Dimensions, TouchableOpacity, StatusBar, ActivityIndicator } from 'react-native'; +import { Image } from 'expo-image'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMonthlyDump } from '@/hooks/use-monthly-dump'; +import { useAuth } from '@/hooks/use-auth'; +import { X } from 'lucide-react-native'; +import Animated, { useSharedValue, withTiming, Easing, runOnJS } from 'react-native-reanimated'; +import PhotoGridPicker, { PhotoGridPickerCompletePayload } from '@/components/monthly-dumps/photo-grid-picker'; +import MonthlyDumpVideoSlide from '@/components/monthly-dumps/monthly-dump-video-slide'; +import MonthlyDumpProgressBarItem from '@/components/monthly-dumps/monthly-dump-progress-bar-item'; +import MonthlyDumpAudioSlide from '@/components/monthly-dumps/monthly-dump-audio-slide'; +import MonthlyDumpGridPromptSlide from '@/components/monthly-dumps/monthly-dump-grid-prompt-slide'; +import { logger } from '@/lib/logger'; +import { MonthlyDumpService, MonthlyDumpSlide } from '@/services/monthly-dump-service'; + +const { width, height } = Dimensions.get('window'); + +type Slide = MonthlyDumpSlide | { type: 'grid_prompt' }; + +export default function MonthlyDumpPage() { + const { month } = useLocalSearchParams<{ month: string }>(); + const { user } = useAuth(); + const { slides, isLoading } = useMonthlyDump(month); + const [currentIndex, setCurrentIndex] = useState(0); + const [showGridPicker, setShowGridPicker] = useState(false); + const queryClient = useQueryClient(); + const router = useRouter(); + + const progress = useSharedValue(0); + + const allSlides = useMemo(() => { + const baseSlides = slides || []; + return [...baseSlides, { type: 'grid_prompt' }]; + }, [slides]); + + const monthTitle = useMemo(() => { + if (!month) return ''; + try { + const [year, monthValue] = month.split('-'); + const parsed = new Date(parseInt(year, 10), parseInt(monthValue, 10) - 1, 1); + return parsed.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + } catch { + return month; + } + }, [month]); + + useEffect(() => { + const imageUrls = Array.from( + new Set( + allSlides + .filter((slide): slide is MonthlyDumpSlide => slide.type === 'image' && !!slide.url) + .map((slide) => slide.url) + ) + ) as string[]; + + if (imageUrls.length > 0) { + // Use expo-image's prefetch which is more reliable for caching + Image.prefetch(imageUrls); + } + }, [allSlides]); + + const nextSlide = () => { + if (currentIndex < allSlides.length - 1) { + setCurrentIndex(prev => prev + 1); + progress.value = 0; + } else { + router.back(); + } + }; + + const prevSlide = () => { + if (currentIndex > 0) { + setCurrentIndex(prev => prev - 1); + progress.value = 0; + } + }; + + useEffect(() => { + if (showGridPicker) return; + + const currentSlide = allSlides[currentIndex]; + logger.info("current slide", { currentSlide }) + if (!currentSlide || currentSlide.type === 'grid_prompt') { + progress.value = 1; // Full progress for the last slide + return; + } + + const duration = (currentSlide.duration_seconds || 5) * 1000; + progress.value = 0; + progress.value = withTiming(1, { + duration, + easing: Easing.linear, + }, (finished) => { + if (finished) { + runOnJS(nextSlide)(); + } + }); + + return () => { + progress.value = 0; + }; + }, [currentIndex, allSlides, showGridPicker]); + + const handleTap = (evt: any) => { + const x = evt.nativeEvent.locationX; + if (x < width * 0.33) { + prevSlide(); + } else { + nextSlide(); + } + }; + + const customGridMutation = useMutation({ + mutationFn: async ({ selectedPhotos, createGridImage }: PhotoGridPickerCompletePayload) => { + if (!user?.id) throw new Error('User is required'); + if (!month) throw new Error('Month is required'); + + const optimisticSlide = await MonthlyDumpService.enqueueCustomGridCreation({ + userId: user.id, + month, + photos: selectedPhotos.map((photo) => ({ + id: String(photo.id), + content_url: String(photo.content_url), + })), + captureGridImage: createGridImage, + }); + + return optimisticSlide; + }, + onSuccess: (optimisticSlide) => { + if (!user?.id || !month) return; + + let targetIndex = slides.length; + queryClient.setQueryData(['monthlyDump', user.id, month], (previous: any) => { + const previousSlides = Array.isArray(previous?.slides) ? previous.slides : slides; + const alreadyExists = previousSlides.some( + (slide: MonthlyDumpSlide) => slide.entry_id && slide.entry_id === optimisticSlide.entry_id + ); + const nextSlides = alreadyExists ? previousSlides : [...previousSlides, optimisticSlide]; + targetIndex = Math.max(0, nextSlides.length - 1); + + return { + hasDump: true, + status: previous?.status ?? 'completed', + slides: nextSlides, + }; + }); + + setShowGridPicker(false); + setCurrentIndex(targetIndex); + }, + onError: (error) => { + logger.error('Failed to create custom monthly dump grid', { error }); + }, + }); + + if (isLoading) { + return ( + + + + ); + } + + if (showGridPicker) { + return ( + setShowGridPicker(false)} + onComplete={async (payload) => { + await customGridMutation.mutateAsync(payload); + }} + /> + ); + } + + const currentSlide = allSlides[currentIndex]; + + return ( + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'black', + }, + centered: { + justifyContent: 'center', + alignItems: 'center', + }, + contentContainer: { + flex: 1, + }, + media: { + width: width, + height: height, + }, + topControls: { + position: 'absolute', + top: 50, + left: 0, + right: 0, + paddingHorizontal: 10, + }, + progressBars: { + flexDirection: 'row', + height: 3, + marginBottom: 15, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + userInfo: { + flexDirection: 'row', + alignItems: 'center', + }, + monthText: { + color: 'white', + fontSize: 16, + fontWeight: '700', + textShadowColor: 'rgba(0,0,0,0.5)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + closeButton: { + padding: 4, + }, +}); diff --git a/frontend/components/monthly-dumps/monthly-dump-audio-slide.tsx b/frontend/components/monthly-dumps/monthly-dump-audio-slide.tsx new file mode 100644 index 0000000..345d367 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-audio-slide.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Sparkles } from 'lucide-react-native'; + +interface MonthlyDumpAudioSlideProps { + month: string; +} + +export default function MonthlyDumpAudioSlide({ month }: MonthlyDumpAudioSlideProps) { + return ( + + + Sound of {month} + + ); +} + +const styles = StyleSheet.create({ + audioContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#1E1B4B', + }, + audioText: { + color: 'white', + fontSize: 24, + fontWeight: '700', + marginTop: 20, + }, +}); diff --git a/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx b/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx new file mode 100644 index 0000000..5c6363e --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { Sparkles } from 'lucide-react-native'; + +interface MonthlyDumpGridPromptSlideProps { + onCreateGrid: () => void; +} + +const { width } = Dimensions.get('window'); + +export default function MonthlyDumpGridPromptSlide({ onCreateGrid }: MonthlyDumpGridPromptSlideProps) { + return ( + + + + Relive your month + Create a custom 3x2 photo grid of your favorite moments. + + Make your grid + + + + ); +} + +const styles = StyleSheet.create({ + gridPromptContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#111827', + }, + gridPromptBlur: { + width: width * 0.85, + padding: 40, + borderRadius: 30, + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + overflow: 'hidden', + }, + icon: { + marginBottom: 20, + }, + gridPromptTitle: { + color: 'white', + fontSize: 28, + fontWeight: '800', + textAlign: 'center', + marginBottom: 12, + }, + gridPromptSubtitle: { + color: '#9CA3AF', + fontSize: 16, + textAlign: 'center', + lineHeight: 22, + marginBottom: 30, + }, + gridButton: { + backgroundColor: '#8B5CF6', + paddingHorizontal: 32, + paddingVertical: 16, + borderRadius: 100, + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 12, + elevation: 8, + }, + gridButtonText: { + color: 'white', + fontSize: 18, + fontWeight: '700', + }, +}); diff --git a/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx b/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx new file mode 100644 index 0000000..2a2a100 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { useAnimatedStyle, SharedValue } from 'react-native-reanimated'; + +interface MonthlyDumpProgressBarItemProps { + index: number; + currentIndex: number; + progress: SharedValue; +} + +export default function MonthlyDumpProgressBarItem({ + index, + currentIndex, + progress, +}: MonthlyDumpProgressBarItemProps) { + const barProgressStyle = useAnimatedStyle(() => { + if (index < currentIndex) return { width: '100%' }; + if (index > currentIndex) return { width: '0%' }; + return { width: `${progress.value * 100}%` }; + }); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + progressBarBackground: { + flex: 1, + height: '100%', + backgroundColor: 'rgba(255, 255, 255, 0.3)', + marginHorizontal: 2, + borderRadius: 2, + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + backgroundColor: 'white', + }, +}); diff --git a/frontend/components/monthly-dumps/monthly-dump-video-slide.tsx b/frontend/components/monthly-dumps/monthly-dump-video-slide.tsx new file mode 100644 index 0000000..89e7046 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-video-slide.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { useEvent } from 'expo'; +import { useVideoPlayer, VideoView } from 'expo-video'; +import { BlurView } from 'expo-blur'; +import { Colors } from '@/lib/constants'; + +interface MonthlyDumpVideoSlideProps { + url: string; +} + +export default function MonthlyDumpVideoSlide({ url }: MonthlyDumpVideoSlideProps) { + const player = useVideoPlayer(url, (instance) => { + instance.loop = false; + }); + const hasStartedPlaybackRef = useRef(false); + + const statusPayload = useEvent(player, 'statusChange', { status: player.status }); + const playbackStatus = statusPayload?.status; + const isLoading = playbackStatus !== 'readyToPlay' && playbackStatus !== 'error'; + const hasPlaybackError = statusPayload?.status === 'error'; + + useEffect(() => { + hasStartedPlaybackRef.current = false; + }, [url]); + + useEffect(() => { + if (playbackStatus !== 'readyToPlay') return; + if (hasStartedPlaybackRef.current) return; + + hasStartedPlaybackRef.current = true; + player.play(); + }, [playbackStatus, player]); + + if (!hasPlaybackError) { + return ( + + + {isLoading ? ( + + + + ) : null} + + ); + } + + return ( + + Video cannot be played + This clip is unavailable on this device. + + ); +} + +const styles = StyleSheet.create({ + videoContainer: { + flex: 1, + }, + media: { + width: '100%', + height: '100%', + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + }, + fallbackContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#0F172A', + paddingHorizontal: 24, + }, + fallbackTitle: { + color: Colors.white, + fontFamily: 'Outfit-Bold', + fontSize: 22, + textAlign: 'center', + }, + fallbackSubtitle: { + color: '#CBD5E1', + fontFamily: 'Outfit-Regular', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, +}); diff --git a/frontend/components/monthly-dumps/photo-grid-picker.tsx b/frontend/components/monthly-dumps/photo-grid-picker.tsx new file mode 100644 index 0000000..2b741a4 --- /dev/null +++ b/frontend/components/monthly-dumps/photo-grid-picker.tsx @@ -0,0 +1,306 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, FlatList, Image, ActivityIndicator, Dimensions } from 'react-native'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { MonthlyDumpService } from '@/services/monthly-dump-service'; +import { X } from 'lucide-react-native'; +import { useAuth } from '@/hooks/use-auth'; +import ViewShot from 'react-native-view-shot'; + +const { width } = Dimensions.get('window'); +const COLUMN_WIDTH = width / 3; +const GRID_CAPTURE_WIDTH = 1080; +const GRID_CAPTURE_HEIGHT = 1620; +const GRID_COLUMNS = 2; +const GRID_ROWS = 3; +const GRID_CELL_WIDTH = GRID_CAPTURE_WIDTH / GRID_COLUMNS; +const GRID_CELL_HEIGHT = GRID_CAPTURE_HEIGHT / GRID_ROWS; + +export interface PhotoGridPickerCompletePayload { + selectedPhotos: any[]; + createGridImage: () => Promise; +} + +interface PhotoGridPickerProps { + month: string; + onComplete: (payload: PhotoGridPickerCompletePayload) => Promise; + onCancel: () => void; +} + +export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGridPickerProps) { + const { user } = useAuth(); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedPhotos, setSelectedPhotos] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const gridShotRef = useRef(null); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteQuery({ + queryKey: ['monthPhotos', user?.id, month], + queryFn: async ({ pageParam = 1 }) => { + if (!user?.id) throw new Error('User not logged in'); + return MonthlyDumpService.getEntries(user.id, month, 'photo', pageParam as number); + }, + getNextPageParam: (lastPage) => lastPage.data.pagination.has_more ? lastPage.data.pagination.page + 1 : undefined, + enabled: !!user?.id, + initialPageParam: 1, + }); + + const photos = data?.pages.flatMap(page => page.data.entries) || []; + const gridCells = useMemo(() => { + if (!selectedPhotos.length) return []; + + const next = [...selectedPhotos]; + while (next.length < 6) { + next.push(selectedPhotos[next.length % selectedPhotos.length]); + } + return next.slice(0, 6); + }, [selectedPhotos]); + + const togglePhoto = (photo: any) => { + if (isSubmitting) return; + + if (selectedIds.includes(photo.id)) { + setSelectedIds(prev => prev.filter(id => id !== photo.id)); + setSelectedPhotos(prev => prev.filter(p => p.id !== photo.id)); + } else { + if (selectedIds.length >= 6) return; + setSelectedIds(prev => [...prev, photo.id]); + setSelectedPhotos(prev => [...prev, photo]); + } + }; + + const createGridImage = async (): Promise => { + if (!gridShotRef.current?.capture) { + throw new Error('Grid capture is not ready.'); + } + + const capturedUri = await gridShotRef.current.capture(); + if (!capturedUri) { + throw new Error('Failed to generate grid image.'); + } + + return capturedUri.startsWith('file://') ? capturedUri : `file://${capturedUri}`; + }; + + const handleDone = async () => { + if (!selectedIds.length || isSubmitting) return; + + setIsSubmitting(true); + try { + await onComplete({ + selectedPhotos, + createGridImage, + }); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + + + Create your 3x2 Grid + + {isSubmitting ? ( + + ) : ( + Done + )} + + + + {selectedIds.length} / 6 selected + + + item.id} + renderItem={({ item }) => { + const isSelected = selectedIds.includes(item.id); + const selectionIndex = selectedIds.indexOf(item.id); + return ( + togglePhoto(item)} + > + + {isSelected && ( + + + {selectionIndex + 1} + + + )} + + ); + }} + onEndReached={() => hasNextPage && fetchNextPage()} + onEndReachedThreshold={0.5} + ListFooterComponent={() => isFetchingNextPage ? : null} + ListEmptyComponent={() => ( + + No photos found for {month}. + + )} + /> + + + + {gridCells.map((photo, index) => ( + + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F8FAFC', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 60, + paddingBottom: 16, + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#E2E8F0', + }, + headerButton: { + padding: 4, + minWidth: 50, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#1E293B', + }, + doneText: { + fontSize: 16, + fontWeight: '600', + color: '#8B5CF6', + textAlign: 'right', + }, + disabledText: { + color: '#CBD5E1', + }, + selectionInfo: { + padding: 12, + backgroundColor: '#F1F5F9', + alignItems: 'center', + }, + selectionText: { + fontSize: 14, + color: '#64748B', + fontWeight: '500', + }, + photoContainer: { + width: COLUMN_WIDTH, + height: COLUMN_WIDTH, + padding: 1, + position: 'relative', + }, + photo: { + width: '100%', + height: '100%', + backgroundColor: '#E2E8F0', + }, + photoSelected: { + opacity: 0.7, + }, + selectionOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(139, 92, 246, 0.2)', + }, + selectionCircle: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#8B5CF6', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'white', + }, + selectionNumber: { + color: 'white', + fontSize: 14, + fontWeight: '700', + }, + emptyContainer: { + flex: 1, + paddingTop: 100, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#94A3B8', + }, + captureCanvasContainer: { + position: 'absolute', + left: -9999, + top: -9999, + width: GRID_CAPTURE_WIDTH, + height: GRID_CAPTURE_HEIGHT, + }, + captureCanvas: { + width: GRID_CAPTURE_WIDTH, + height: GRID_CAPTURE_HEIGHT, + flexDirection: 'row', + flexWrap: 'wrap', + backgroundColor: '#000', + }, + captureCell: { + width: GRID_CELL_WIDTH, + height: GRID_CELL_HEIGHT, + overflow: 'hidden', + }, + captureImage: { + width: '100%', + height: '100%', + }, +}); From f969da24aef89bcfb9c804f8c2790696aa488bc3 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:21:09 -0400 Subject: [PATCH 13/45] added mocks for unit tests --- frontend/jest.setup.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index f1d47ea..c9c65e0 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -27,4 +27,28 @@ jest.mock('@react-native-async-storage/async-storage', () => process.env.EXPO_PUBLIC_SUPABASE_URL="https://supabase.test.co" process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY="test-anon-key" -process.env.EXPO_PUBLIC_NODE_ENV="development" \ No newline at end of file +process.env.EXPO_PUBLIC_NODE_ENV="development" + +// Reanimated mock +require('react-native-reanimated').setUpTests(); + +// Expo Blur mock +jest.mock('expo-blur', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + BlurView: ({ children, style }) => {children}, + }; +}); + +// Lucide mock (often needed as it uses ES modules) +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Sparkles: () => , + Play: () => , + UserPlus: () => , + X: () => , + }; +}); \ No newline at end of file From a19c7f3745274c9af824652e1a02ccba8ae6556f Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 14:21:25 -0400 Subject: [PATCH 14/45] added new rule for coding agents --- frontend/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7f353ea..6d80879 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -10,6 +10,8 @@ 3. DO NOT call supabase tables inside of useEffect, ALWAYS use useQuery for fetching data from supabase, UNLESS you are using Supabase Realtime. +4. Before making any calls to supabase, perform input validation with zod to ensure data is valid. + ### Creating Custom Hooks 1. Data already made available from another custom hook should NOT be passed as a parameter INSTEAD it should be called inside the new custom hook itself @@ -21,6 +23,8 @@ 2. When Calling backend APIs use the `apiFetch` helper (or `apiFetchStream` if streaming endpoint) +3. Before making any calls to backend APIs, perform input validation with zod to ensure data is valid. + ### Code Style 1. Always evaulate negative conditions first eg. ```js From 8be033e3bd912af493322ec0dd55678e8e1268b2 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Sun, 3 May 2026 17:45:48 -0400 Subject: [PATCH 15/45] implemented code fixes as per coderabbit --- frontend/app/monthly-dumps/[month].tsx | 31 +++++++---- .../monthly-dumps/monthly-dump-banner.tsx | 8 ++- .../monthly-dumps/photo-grid-picker.tsx | 8 +-- .../phone-number/use-manage-phone-sheet.ts | 51 +++++++++++++++++++ frontend/hooks/use-monthly-dump.ts | 2 +- frontend/services/monthly-dump-service.ts | 23 ++++++--- ...60420043600_init_monthly_dump_next_run.sql | 5 +- frontend/types/database.ts | 3 ++ 8 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 frontend/hooks/phone-number/use-manage-phone-sheet.ts diff --git a/frontend/app/monthly-dumps/[month].tsx b/frontend/app/monthly-dumps/[month].tsx index 020daee..fc899d2 100644 --- a/frontend/app/monthly-dumps/[month].tsx +++ b/frontend/app/monthly-dumps/[month].tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity, StatusBar, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -6,14 +6,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMonthlyDump } from '@/hooks/use-monthly-dump'; import { useAuth } from '@/hooks/use-auth'; import { X } from 'lucide-react-native'; -import Animated, { useSharedValue, withTiming, Easing, runOnJS } from 'react-native-reanimated'; +import { useSharedValue, withTiming, Easing, runOnJS, cancelAnimation } from 'react-native-reanimated'; import PhotoGridPicker, { PhotoGridPickerCompletePayload } from '@/components/monthly-dumps/photo-grid-picker'; import MonthlyDumpVideoSlide from '@/components/monthly-dumps/monthly-dump-video-slide'; import MonthlyDumpProgressBarItem from '@/components/monthly-dumps/monthly-dump-progress-bar-item'; import MonthlyDumpAudioSlide from '@/components/monthly-dumps/monthly-dump-audio-slide'; import MonthlyDumpGridPromptSlide from '@/components/monthly-dumps/monthly-dump-grid-prompt-slide'; import { logger } from '@/lib/logger'; -import { MonthlyDumpService, MonthlyDumpSlide } from '@/services/monthly-dump-service'; +import { MonthlyDumpService, MonthlyDumpSlide, CachedMonthlyDump } from '@/services/monthly-dump-service'; const { width, height } = Dimensions.get('window'); @@ -61,21 +61,21 @@ export default function MonthlyDumpPage() { } }, [allSlides]); - const nextSlide = () => { + const nextSlide = useCallback(() => { if (currentIndex < allSlides.length - 1) { setCurrentIndex(prev => prev + 1); progress.value = 0; } else { router.back(); } - }; + }, [currentIndex, allSlides.length, router, progress]); - const prevSlide = () => { + const prevSlide = useCallback(() => { if (currentIndex > 0) { setCurrentIndex(prev => prev - 1); progress.value = 0; } - }; + }, [currentIndex, progress]); useEffect(() => { if (showGridPicker) return; @@ -99,11 +99,12 @@ export default function MonthlyDumpPage() { }); return () => { + cancelAnimation(progress); progress.value = 0; }; - }, [currentIndex, allSlides, showGridPicker]); + }, [currentIndex, allSlides, showGridPicker, nextSlide, progress]); - const handleTap = (evt: any) => { + const handleTap = (evt: { nativeEvent: { locationX: number } }) => { const x = evt.nativeEvent.locationX; if (x < width * 0.33) { prevSlide(); @@ -133,7 +134,7 @@ export default function MonthlyDumpPage() { if (!user?.id || !month) return; let targetIndex = slides.length; - queryClient.setQueryData(['monthlyDump', user.id, month], (previous: any) => { + queryClient.setQueryData(['monthlyDump', user.id, month], (previous: CachedMonthlyDump | undefined) => { const previousSlides = Array.isArray(previous?.slides) ? previous.slides : slides; const alreadyExists = previousSlides.some( (slide: MonthlyDumpSlide) => slide.entry_id && slide.entry_id === optimisticSlide.entry_id @@ -164,10 +165,18 @@ export default function MonthlyDumpPage() { ); } + if (!month) { + return ( + + Invalid month parameter + + ); + } + if (showGridPicker) { return ( setShowGridPicker(false)} onComplete={async (payload) => { await customGridMutation.mutateAsync(payload); diff --git a/frontend/components/monthly-dumps/monthly-dump-banner.tsx b/frontend/components/monthly-dumps/monthly-dump-banner.tsx index c524d7e..0f478cf 100644 --- a/frontend/components/monthly-dumps/monthly-dump-banner.tsx +++ b/frontend/components/monthly-dumps/monthly-dump-banner.tsx @@ -95,13 +95,19 @@ export default function MonthlyDumpBanner({ month, animationProgress }: MonthlyD return { borderRadius }; }); + const handlePress = () => { + if (!month) return; + router.push(`/monthly-dumps/${month}`); + } + return ( router.push(`/monthly-dumps/${month}`)} + onPress={handlePress} style={styles.touchable} + disabled={!month} > diff --git a/frontend/components/monthly-dumps/photo-grid-picker.tsx b/frontend/components/monthly-dumps/photo-grid-picker.tsx index 2b741a4..2faacb9 100644 --- a/frontend/components/monthly-dumps/photo-grid-picker.tsx +++ b/frontend/components/monthly-dumps/photo-grid-picker.tsx @@ -15,8 +15,10 @@ const GRID_ROWS = 3; const GRID_CELL_WIDTH = GRID_CAPTURE_WIDTH / GRID_COLUMNS; const GRID_CELL_HEIGHT = GRID_CAPTURE_HEIGHT / GRID_ROWS; +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; + export interface PhotoGridPickerCompletePayload { - selectedPhotos: any[]; + selectedPhotos: MonthlyDumpGridPhoto[]; createGridImage: () => Promise; } @@ -29,7 +31,7 @@ interface PhotoGridPickerProps { export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGridPickerProps) { const { user } = useAuth(); const [selectedIds, setSelectedIds] = useState([]); - const [selectedPhotos, setSelectedPhotos] = useState([]); + const [selectedPhotos, setSelectedPhotos] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const gridShotRef = useRef(null); @@ -61,7 +63,7 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr return next.slice(0, 6); }, [selectedPhotos]); - const togglePhoto = (photo: any) => { + const togglePhoto = (photo: MonthlyDumpGridPhoto) => { if (isSubmitting) return; if (selectedIds.includes(photo.id)) { diff --git a/frontend/hooks/phone-number/use-manage-phone-sheet.ts b/frontend/hooks/phone-number/use-manage-phone-sheet.ts new file mode 100644 index 0000000..b95aff6 --- /dev/null +++ b/frontend/hooks/phone-number/use-manage-phone-sheet.ts @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback } from 'react'; +import { supabase } from '@/lib/supabase'; +import { useAuthContext } from '@/providers/auth-provider'; +import { getPhonePromptState } from '@/services/phone-number-prompt-service'; +import { TABLES } from '@/constants/supabase'; + +export function useManagePhoneSheet() { + const { user } = useAuthContext(); + const [showPhoneSheet, setShowPhoneSheet] = useState(false); + const { profile } = useAuthContext(); + + useEffect(() => { + let cancelled = false; + + const checkShouldShowPhonePrompt = async () => { + if (!user?.id) return; + if (profile?.phone_number) { + if (!cancelled) setShowPhoneSheet(false); + return; + } + + // If the user already has a pending OTP record, always show the sheet. + const { data: pendingRecord } = await supabase + .from(TABLES.PHONE_NUMBER_UPDATES) + .select('id') + .eq('user_id', user.id) + .maybeSingle() as { data: { id: string } | null }; + + if (pendingRecord?.id) { + if (!cancelled) setShowPhoneSheet(true); + return; + } + + const state = await getPhonePromptState(user.id); + const now = Date.now(); + const shouldShow = !state.dontAskAgain && (!state.nextPromptAtMs || now >= state.nextPromptAtMs); + + if (!cancelled) setShowPhoneSheet(shouldShow); + }; + + checkShouldShowPhonePrompt().catch(() => { }); + return () => { + cancelled = true; + }; + }, [profile?.phone_number, user?.id]); + + return { + showPhoneSheet, + setShowPhoneSheet + } +} \ No newline at end of file diff --git a/frontend/hooks/use-monthly-dump.ts b/frontend/hooks/use-monthly-dump.ts index 18198ae..0f125a6 100644 --- a/frontend/hooks/use-monthly-dump.ts +++ b/frontend/hooks/use-monthly-dump.ts @@ -118,7 +118,7 @@ export function useMonthlyDump(requestedMonth?: string): UseMonthlyDumpResult { return { hasDump: false, slides: [] }; } }, - //enabled: isEnabled && !!user?.id, + enabled: isEnabled && !!user?.id, staleTime: 1000 * 60 * 10, // 10 minutes }); diff --git a/frontend/services/monthly-dump-service.ts b/frontend/services/monthly-dump-service.ts index eec6406..89964f9 100644 --- a/frontend/services/monthly-dump-service.ts +++ b/frontend/services/monthly-dump-service.ts @@ -86,11 +86,15 @@ export class MonthlyDumpService { await deviceStorage.setItem(MONTHLY_DUMP_GRID_QUEUE_KEY, queue); } - private static async dequeueGridQueueItem(): Promise { + private static async peekGridQueueItem(): Promise { const queue = await this.loadGridQueue(); - const next = queue.shift(); - await this.saveGridQueue(queue); - return next; + return queue[0]; + } + + private static async removeGridQueueItem(idempotencyKey: string): Promise { + const queue = await this.loadGridQueue(); + const filtered = queue.filter((item) => item.idempotencyKey !== idempotencyKey); + await this.saveGridQueue(filtered); } private static async processGridQueueItem(item: MonthlyDumpGridQueueItem): Promise { @@ -169,11 +173,17 @@ export class MonthlyDumpService { try { let next: MonthlyDumpGridQueueItem | undefined; // eslint-disable-next-line no-constant-condition - while ((next = await this.dequeueGridQueueItem())) { + while ((next = await this.peekGridQueueItem())) { try { await this.processGridQueueItem(next); + // Only remove after successful processing + await this.removeGridQueueItem(next.idempotencyKey); } catch (error) { logger.error('Monthly dump custom grid queue item failed', { error, next }); + // Optionally: on failure, we could implement a retry counter or backoff. + // For now, we'll keep it in the queue for a retry on next app launch/processor start, + // but we MUST break the loop to avoid an infinite failing loop. + break; } } } finally { @@ -242,6 +252,7 @@ export class MonthlyDumpService { ) { userIdSchema.parse(userId); monthSchema.parse(month); + z.enum(['photo', 'video', 'audio']).parse(type); z.number().int().min(1).parse(page); try { @@ -344,7 +355,7 @@ export class MonthlyDumpService { userId: userIdSchema, month: monthSchema, photos: z.array(monthlyDumpGridPhotoSchema).min(1).max(6), - captureGridImage: z.any(), + captureGridImage: z.custom<(photos: MonthlyDumpGridPhoto[]) => Promise>((val) => typeof val === 'function'), }); const { userId, month, photos, captureGridImage } = schema.parse(params); diff --git a/frontend/supabase/migrations/20260420043600_init_monthly_dump_next_run.sql b/frontend/supabase/migrations/20260420043600_init_monthly_dump_next_run.sql index aa5f711..244f690 100644 --- a/frontend/supabase/migrations/20260420043600_init_monthly_dump_next_run.sql +++ b/frontend/supabase/migrations/20260420043600_init_monthly_dump_next_run.sql @@ -1,8 +1,9 @@ /* # Initialize Monthly Dump Next Run - Sets the default `monthly_dump_next_run` for all existing profiles - to the 3rd to last day of the current month. + Sets the default monthly_dump_next_run for all existing profiles: + - To the 3rd-to-last day of the current month if that date hasn't passed yet + - Otherwise, to the 3rd-to-last day of the next month */ UPDATE public.profiles diff --git a/frontend/types/database.ts b/frontend/types/database.ts index 415ed4c..c7bb029 100644 --- a/frontend/types/database.ts +++ b/frontend/types/database.ts @@ -27,6 +27,7 @@ export interface Database { max_uses: number current_uses: number is_active: boolean + monthly_dump_next_run: string | null } Insert: { id: string @@ -40,6 +41,7 @@ export interface Database { invite_code?: string | null phone_number?: string | null birthday?: string | null + monthly_dump_next_run?: string | null } Update: { email?: string, @@ -50,6 +52,7 @@ export interface Database { bio?: string | null, updated_at?: string, phone_number?: string | null, + monthly_dump_next_run?: string | null } } entries: { From b1f7cb20bf9054df5fa9951516139ecbe5095b66 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Thu, 28 May 2026 22:56:00 -0400 Subject: [PATCH 16/45] added new agent skills --- .agents/skills/frontend-design/SKILL.md | 42 ++++++ .agents/skills/stop-slop/CHANGELOG.md | 24 ++++ .agents/skills/stop-slop/LICENSE | 21 +++ .agents/skills/stop-slop/README.md | 62 ++++++++ .agents/skills/stop-slop/SKILL.md | 68 +++++++++ .../skills/stop-slop/references/examples.md | 59 ++++++++ .../skills/stop-slop/references/phrases.md | 128 +++++++++++++++++ .../skills/stop-slop/references/structures.md | 134 ++++++++++++++++++ skills-lock.json | 12 ++ 9 files changed, 550 insertions(+) create mode 100644 .agents/skills/frontend-design/SKILL.md create mode 100644 .agents/skills/stop-slop/CHANGELOG.md create mode 100644 .agents/skills/stop-slop/LICENSE create mode 100644 .agents/skills/stop-slop/README.md create mode 100644 .agents/skills/stop-slop/SKILL.md create mode 100644 .agents/skills/stop-slop/references/examples.md create mode 100644 .agents/skills/stop-slop/references/phrases.md create mode 100644 .agents/skills/stop-slop/references/structures.md diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..600b6db --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,42 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. \ No newline at end of file diff --git a/.agents/skills/stop-slop/CHANGELOG.md b/.agents/skills/stop-slop/CHANGELOG.md new file mode 100644 index 0000000..64d9f54 --- /dev/null +++ b/.agents/skills/stop-slop/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## 2026-01-13 + +### Added + +**Phrases (references/phrases.md)** +- Throat-clearing: "Here's what I find interesting", "Here's the problem though" +- Performative emphasis: "creeps in", "I promise", "They exist, I promise" +- Telling instead of showing: "This is genuinely hard", "This is what leadership actually looks like" + +**Structures (references/structures.md)** +- Binary contrasts: "Not X. But Y.", "It's not this. It's that.", "stops being X and starts being Y" +- Rhythm patterns: staccato fragmentation, dashes for dramatic pause, hedging as reassurance +- Word patterns: absolute words (always, never, everyone, etc.), AI-overused intensifiers (deeply, truly, fundamentally, inherently, simply, literally, inevitably) + +## 2026-01-12 + +- Restructured skill following Claude Code best practices (PR #1) +- Split into SKILL.md and references/ folder + +## 2025-01-12 + +- Initial release diff --git a/.agents/skills/stop-slop/LICENSE b/.agents/skills/stop-slop/LICENSE new file mode 100644 index 0000000..e0ede9b --- /dev/null +++ b/.agents/skills/stop-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hardik Pandya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/stop-slop/README.md b/.agents/skills/stop-slop/README.md new file mode 100644 index 0000000..3c98256 --- /dev/null +++ b/.agents/skills/stop-slop/README.md @@ -0,0 +1,62 @@ +# Stop Slop + +A skill for removing AI tells from prose. + +G-Yg4RVbIAAhVxW + +## What this is + +AI writing has patterns. Predictable phrases, structures, rhythms. This skill teaches Claude (or any LLM) to catch and remove them. + +## Skill Structure + +``` +stop-slop/ +β”œβ”€β”€ SKILL.md # Core instructions +β”œβ”€β”€ references/ +β”‚ β”œβ”€β”€ phrases.md # Phrases to remove +β”‚ β”œβ”€β”€ structures.md # Structural patterns to avoid +β”‚ └── examples.md # Before/after transformations +β”œβ”€β”€ README.md +└── LICENSE +``` + +## Quick start + +**Claude Code:** Add this folder as a skill. + +**Claude Projects:** Upload `SKILL.md` and reference files to project knowledge. + +**Custom instructions:** Copy core rules from `SKILL.md`. + +**API calls:** Include `SKILL.md` in your system prompt. Reference files load on demand. + +## What it catches + +**Banned phrases** - Throat-clearing openers, emphasis crutches, business jargon, all adverbs, vague declaratives, meta-commentary. See `references/phrases.md`. + +**Structural clichΓ©s** - Binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency, narrator-from-a-distance voice, passive voice. See `references/structures.md`. + +**Sentence-level rules** - No Wh- sentence starters, no em dashes, no staccato fragmentation, no lazy extremes, active voice required. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +|-----------|----------| +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Author + +[Hardik Pandya](https://hvpandya.com) + +## License + +MIT. Use freely, share widely. diff --git a/.agents/skills/stop-slop/SKILL.md b/.agents/skills/stop-slop/SKILL.md new file mode 100644 index 0000000..83f2032 --- /dev/null +++ b/.agents/skills/stop-slop/SKILL.md @@ -0,0 +1,68 @@ +--- +name: stop-slop +description: Remove AI writing patterns from prose. Use when drafting, editing, or reviewing text to eliminate predictable AI tells. +metadata: + trigger: Writing prose, editing drafts, reviewing content for AI patterns + author: Hardik Pandya (https://hvpandya.com) +--- + +# Stop Slop + +Eliminate predictable AI writing patterns from prose. + +## Core Rules + +1. **Cut filler phrases.** Remove throat-clearing openers, emphasis crutches, and all adverbs. See [references/phrases.md](references/phrases.md). + +2. **Break formulaic structures.** Avoid binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency. See [references/structures.md](references/structures.md). + +3. **Use active voice.** Every sentence needs a human subject doing something. No passive constructions. No inanimate objects performing human actions ("the complaint becomes a fix"). + +4. **Be specific.** No vague declaratives ("The reasons are structural"). Name the specific thing. No lazy extremes ("every," "always," "never") doing vague work. + +5. **Put the reader in the room.** No narrator-from-a-distance voice. "You" beats "People." Specifics beat abstractions. + +6. **Vary rhythm.** Mix sentence lengths. Two items beat three. End paragraphs differently. No em dashes. + +7. **Trust readers.** State facts directly. Skip softening, justification, hand-holding. + +8. **Cut quotables.** If it sounds like a pull-quote, rewrite it. + +## Quick Checks + +Before delivering prose: + +- Any adverbs? Kill them. +- Any passive voice? Find the actor, make them the subject. +- Inanimate thing doing a human verb ("the decision emerges")? Name the person. +- Sentence starts with a Wh- word? Restructure it. +- Any "here's what/this/that" throat-clearing? Cut to the point. +- Any "not X, it's Y" contrasts? State Y directly. +- Three consecutive sentences match length? Break one. +- Paragraph ends with punchy one-liner? Vary it. +- Em-dash anywhere? Remove it. +- Vague declarative ("The implications are significant")? Name the specific implication. +- Narrator-from-a-distance ("Nobody designed this")? Put the reader in the scene. +- Meta-joiners ("The rest of this essay...")? Delete. Let the essay move. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +|-----------|----------| +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Examples + +See [references/examples.md](references/examples.md) for before/after transformations. + +## License + +MIT diff --git a/.agents/skills/stop-slop/references/examples.md b/.agents/skills/stop-slop/references/examples.md new file mode 100644 index 0000000..0376b6d --- /dev/null +++ b/.agents/skills/stop-slop/references/examples.md @@ -0,0 +1,59 @@ +# Before/After Examples + +## Example 1: Throat-Clearing + Binary Contrast + +**Before:** +> "Here's the thing: building products is hard. Not because the technology is complex. Because people are complex. Let that sink in." + +**After:** +> "Building products is hard. Technology is manageable. People aren't." + +**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Direct statements. + +--- + +## Example 2: Filler + Unnecessary Reassurance + +**Before:** +> "It turns out that most teams struggle with alignment. The uncomfortable truth is that nobody wants to admit they're confused. And that's okay." + +**After:** +> "Teams struggle with alignment. Nobody admits confusion." + +**Changes:** Cut hedging ("most"), removed throat-clearing phrases, deleted permission-granting ending. + +--- + +## Example 3: Business Jargon Stack + +**Before:** +> "In today's fast-paced landscape, we need to lean into discomfort and navigate uncertainty with clarity. This matters because your competition isn't waiting." + +**After:** +> "Move faster. Your competition is." + +**Changes:** Eliminated jargon entirely. Core message in six words. + +--- + +## Example 4: Dramatic Fragmentation + +**Before:** +> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff." + +**After:** +> "Speed, quality, costβ€”pick two." + +**Changes:** Single sentence. No performative emphasis. + +--- + +## Example 5: Rhetorical Setup + +**Before:** +> "What if I told you that the best teams don't optimize for productivity? Here's what I mean: they optimize for learning. Think about it." + +**After:** +> "The best teams optimize for learning, not productivity." + +**Changes:** Direct claim. No rhetorical scaffolding. diff --git a/.agents/skills/stop-slop/references/phrases.md b/.agents/skills/stop-slop/references/phrases.md new file mode 100644 index 0000000..f9234fe --- /dev/null +++ b/.agents/skills/stop-slop/references/phrases.md @@ -0,0 +1,128 @@ +# Phrases to Remove + +## Throat-Clearing Openers + +Remove these announcement phrases. State the content directly. + +- "Here's the thing:" +- "Here's what [X]" +- "Here's this [X]" +- "Here's that [X]" +- "Here's why [X]" +- "The uncomfortable truth is" +- "It turns out" +- "The real [X] is" +- "Let me be clear" +- "The truth is," +- "I'll say it again:" +- "I'm going to be honest" +- "Can we talk about" +- "Here's what I find interesting" +- "Here's the problem though" + +Any "here's what/this/that" construction is throat-clearing before the point. Cut it and state the point. + +## Emphasis Crutches + +These add no meaning. Delete them. + +- "Full stop." / "Period." +- "Let that sink in." +- "This matters because" +- "Make no mistake" +- "Here's why that matters" + +## Business Jargon + +Replace with plain language. + +| Avoid | Use instead | +|-------|-------------| +| Navigate (challenges) | Handle, address | +| Unpack (analysis) | Explain, examine | +| Lean into | Accept, embrace | +| Landscape (context) | Situation, field | +| Game-changer | Significant, important | +| Double down | Commit, increase | +| Deep dive | Analysis, examination | +| Take a step back | Reconsider | +| Moving forward | Next, from now | +| Circle back | Return to, revisit | +| On the same page | Aligned, agreed | + +## Adverbs + +Kill all adverbs. No -ly words. No softeners, no intensifiers, no hedges. + +Specific offenders: + +- "really" +- "just" +- "literally" +- "genuinely" +- "honestly" +- "simply" +- "actually" +- "deeply" +- "truly" +- "fundamentally" +- "inherently" +- "inevitably" +- "interestingly" +- "importantly" +- "crucially" + +Also cut these filler phrases: + +- "At its core" +- "In today's [X]" +- "It's worth noting" +- "At the end of the day" +- "When it comes to" +- "In a world where" +- "The reality is" + +## Meta-Commentary + +Remove self-referential asides. The essay should move, not announce its own structure. + +- "Hint:" +- "Plot twist:" / "Spoiler:" +- "You already know this, but" +- "But that's another post" +- "X is a feature, not a bug" +- "Dressed up as" +- "The rest of this essay explains..." +- "Let me walk you through..." +- "In this section, we'll..." +- "As we'll see..." +- "I want to explore..." + +## Performative Emphasis + +False intimacy or manufactured sincerity: + +- "creeps in" +- "I promise" +- "They exist, I promise" + +## Telling Instead of Showing + +Announcing difficulty or significance rather than demonstrating it: + +- "This is genuinely hard" +- "This is what leadership actually looks like" +- "This is what X actually looks like" +- "actually matters" + +## Vague Declaratives + +Sentences that announce importance without naming the specific thing. Kill these. + +- "The reasons are structural" +- "The implications are significant" +- "This is the deepest problem" +- "The stakes are high" +- "The consequences are real" + +If a sentence says something is important/deep/structural without showing the specific thing, cut it or replace it with the specific thing. diff --git a/.agents/skills/stop-slop/references/structures.md b/.agents/skills/stop-slop/references/structures.md new file mode 100644 index 0000000..bbcc359 --- /dev/null +++ b/.agents/skills/stop-slop/references/structures.md @@ -0,0 +1,134 @@ +# Structures to Avoid + +## Binary Contrasts + +These create false drama. State the point directly. + +| Pattern | Problem | +|---------|---------| +| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | +| "[X] isn't the problem. [Y] is." | Formulaic reframe | +| "The answer isn't X. It's Y." | Predictable pivot | +| "It feels like X. It's actually Y." | Setup/reveal cliche | +| "The question isn't X. It's Y." | Rhetorical misdirection | +| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | +| "It's not this. It's that." | Same formula, different words | +| "stops being X and starts being Y" | False transformation arc | +| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | +| "is about X but not Y" | False distinction | +| "not just X but also Y" | Additive hedge | + +**Instead:** State Y directly. "The problem is Y." "Y matters here." Drop the negation entirely. + +## Negative Listing + +Listing what something is *not* before revealing what it *is*. A rhetorical striptease. + +| Pattern | Problem | +|---------|---------| +| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | +| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | + +**Instead:** State Z. The reader doesn't need the runway. + +## Dramatic Fragmentation + +Sentence fragments for emphasis read as manufactured profundity. + +| Pattern | Problem | +|---------|---------| +| "[Noun]. That's it. That's the [thing]." | Performative simplicity | +| "X. And Y. And Z." | Staccato drama | +| "This unlocks something. [Word]." | Artificial revelation | + +**Instead:** Complete sentences. Trust content over presentation. + +## Rhetorical Setups + +These announce insight rather than deliver it. + +| Pattern | Problem | +|---------|---------| +| "What if [reframe]?" | Socratic posturing | +| "Here's what I mean:" | Redundant preview | +| "Think about it:" | Condescending prompt | +| "And that's okay." | Unnecessary permission | + +**Instead:** Make the point. Let readers draw conclusions. + +## Formulaic Constructions + +| Pattern | Problem | +|---------|---------| +| "By the time X, I was Y." | Narrative template | +| "X that isn't Y" | Indirect. Say "X is broken" | + +## False Agency + +Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't "live or die." Decisions don't "emerge." A person does something to make those things happen. AI loves this because it avoids naming the actor. + +| Pattern | Problem | +|---------|---------| +| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | +| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | +| "the decision emerges" | Decisions don't emerge. Someone decides. | +| "the culture shifts" | Cultures don't shift on their own. People change behavior. | +| "the conversation moves toward" | Conversations don't move. Someone steers. | +| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | +| "the market rewards" | Markets don't reward. Buyers pay for things. | + +**Instead:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat. + +## Narrator-from-a-Distance + +Floating above the scene instead of putting the reader in it. + +| Pattern | Problem | +|---------|---------| +| "Nobody designed this." | Disembodied observation | +| "This happens because..." | Lecturer voice | +| "This is why..." | Same | +| "People tend to..." | Armchair sociologist | + +**Instead:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this." + +## Passive Voice + +Every sentence needs a subject doing something. Passive voice hides the actor and drains energy. + +| Pattern | Fix | +|---------|-----| +| "X was created" | Name who created it | +| "It is believed that" | Name who believes it | +| "Mistakes were made" | Name who made them | +| "The decision was reached" | Name who decided | + +**Instead:** Find the actor. Put them at the front of the sentence. + +## Sentence Starters to Avoid + +| Pattern | Fix | +|---------|-----| +| Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. | +| Paragraphs starting with "So" | Start with content | +| Sentences starting with "Look," | Remove | + +Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint. + +## Rhythm Patterns + +| Pattern | Fix | +|---------|-----| +| Three-item lists | Use two items or one | +| Questions answered immediately | Let questions breathe or cut them | +| Every paragraph ends punchily | Vary endings | +| Em-dashes | Remove. Use commas or periods. No em dashes at all. | +| Staccato fragmentation | Don't stack short punchy sentences | +| "Not always. Not perfectly." | Hedging disguised as reassurance | + +## Word Patterns + +| Pattern | Problem | +|---------|---------| +| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | +| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | diff --git a/skills-lock.json b/skills-lock.json index 2c0e8f6..c1f0b9d 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,10 +1,22 @@ { "version": 1, "skills": { + "frontend-design": { + "source": "anthropics/claude-code", + "sourceType": "github", + "skillPath": "plugins/frontend-design/skills/frontend-design/SKILL.md", + "computedHash": "e8118284a6365753790d44bb2758a6032b3af27fa84696428d9233f2be0f4e78" + }, "logging-best-practices": { "source": "aj-geddes/useful-ai-prompts", "sourceType": "github", "computedHash": "e7fd822c9e40d192715e3e82e3dd3cb9152a554f1bf323bc9aedffa3801d5ba4" + }, + "stop-slop": { + "source": "hardikpandya/stop-slop", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "09acd38f01d6c643bce871a739141c01a06a04f43d8b55959ef73550476e771c" } } } From c05563d3586ca77f3ddee167b166bfc1298c2c60 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 00:49:53 -0400 Subject: [PATCH 17/45] added functionality to fetch entries and gallery selection --- .../monthly-dumps/photo-grid-picker.tsx | 804 +++++++++++++----- frontend/hooks/use-gallery-images.ts | 106 +++ frontend/hooks/use-monthly-entries.ts | 67 ++ 3 files changed, 766 insertions(+), 211 deletions(-) create mode 100644 frontend/hooks/use-gallery-images.ts create mode 100644 frontend/hooks/use-monthly-entries.ts diff --git a/frontend/components/monthly-dumps/photo-grid-picker.tsx b/frontend/components/monthly-dumps/photo-grid-picker.tsx index 2faacb9..4049684 100644 --- a/frontend/components/monthly-dumps/photo-grid-picker.tsx +++ b/frontend/components/monthly-dumps/photo-grid-picker.tsx @@ -1,23 +1,70 @@ import React, { useMemo, useRef, useState } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, FlatList, Image, ActivityIndicator, Dimensions } from 'react-native'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import { MonthlyDumpService } from '@/services/monthly-dump-service'; -import { X } from 'lucide-react-native'; -import { useAuth } from '@/hooks/use-auth'; +import { + Alert, + Dimensions, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, + StatusBar, +} from 'react-native'; +import { Trash2, X } from 'lucide-react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import ViewShot from 'react-native-view-shot'; +import { Image } from 'expo-image'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const { width } = Dimensions.get('window'); -const COLUMN_WIDTH = width / 3; -const GRID_CAPTURE_WIDTH = 1080; -const GRID_CAPTURE_HEIGHT = 1620; -const GRID_COLUMNS = 2; -const GRID_ROWS = 3; -const GRID_CELL_WIDTH = GRID_CAPTURE_WIDTH / GRID_COLUMNS; -const GRID_CELL_HEIGHT = GRID_CAPTURE_HEIGHT / GRID_ROWS; +import GridImagePickerBottomTray from '@/components/monthly-dumps/grid-image-picker-bottom-tray'; +import GridImagePickerCameraModal from '@/components/monthly-dumps/grid-image-picker-camera-modal'; +import GridImagePicker from '@/components/monthly-dumps/grid-image-picker'; +import GridImagePickerRightActions from '@/components/monthly-dumps/grid-image-picker-right-actions'; +import GridImagePickerSelectionPill from '@/components/monthly-dumps/grid-image-picker-selection-pill'; +import GridImagePickerCaptureCanvas from '@/components/monthly-dumps/grid-image-picker-capture-canvas'; +import GridImagePickerCell from '@/components/monthly-dumps/grid-image-picker-cell'; +import { Colors } from '@/lib/constants'; +import { + MonthlyDumpGridLayout, + MonthlyDumpGridPhoto, +} from '@/services/monthly-dump-service'; -import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); +const TRAY_CLOSED_HEIGHT = 70; +const SOURCE_GRID_NUM_COLUMNS = 3; + +type GridCell = MonthlyDumpGridPhoto | null; + +type GridLayoutOption = { + requiredPhotos: number; + rows: number; + columns: number; + captureWidth: number; + captureHeight: number; +}; + +const GRID_LAYOUTS: Record = { + '2x2': { + requiredPhotos: 4, + rows: 2, + columns: 2, + captureWidth: 1080, + captureHeight: 1080, + }, + '2x3': { + requiredPhotos: 6, + rows: 3, + columns: 2, + captureWidth: 1080, + captureHeight: 1620, + }, +}; + +const GRID_LAYOUT_OPTIONS: MonthlyDumpGridLayout[] = ['2x2', '2x3']; export interface PhotoGridPickerCompletePayload { + gridLayout: MonthlyDumpGridLayout; selectedPhotos: MonthlyDumpGridPhoto[]; createGridImage: () => Promise; } @@ -28,52 +75,177 @@ interface PhotoGridPickerProps { onCancel: () => void; } +function resizeSlots(slots: GridCell[], nextCount: number): GridCell[] { + const nextSlots = slots.slice(0, nextCount); + while (nextSlots.length < nextCount) { + nextSlots.push(null); + } + return nextSlots; +} + +function getGridDimensions(layout: MonthlyDumpGridLayout, topInset: number, trayHeight: number) { + const config = GRID_LAYOUTS[layout]; + const boardHeight = Math.max(screenHeight - topInset - trayHeight, 0); + const boardWidth = screenWidth; + const cellWidth = boardWidth / config.columns; + const cellHeight = boardHeight / config.rows; + + return { + boardWidth, + boardHeight, + cellWidth, + cellHeight, + }; +} + export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGridPickerProps) { - const { user } = useAuth(); - const [selectedIds, setSelectedIds] = useState([]); - const [selectedPhotos, setSelectedPhotos] = useState([]); + const insets = useSafeAreaInsets(); + const [gridLayout, setGridLayout] = useState('2x3'); + const [gridSlots, setGridSlots] = useState( + () => Array.from({ length: GRID_LAYOUTS['2x3'].requiredPhotos }, () => null) + ); + const [focusedCellIndex, setFocusedCellIndex] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [sheetVisible, setSheetVisible] = useState(false); + const [showCameraModal, setShowCameraModal] = useState(false); + const [isCameraReady, setIsCameraReady] = useState(false); + const [isCameraCapturing, setIsCameraCapturing] = useState(false); + const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + const [pendingLayout, setPendingLayout] = useState(null); + const [removalIds, setRemovalIds] = useState([]); const gridShotRef = useRef(null); + const cameraRef = useRef(null); + + const gridConfig = GRID_LAYOUTS[gridLayout]; + const trayBottomSpacing = Math.max(insets.bottom, 8); + const trayHeight = TRAY_CLOSED_HEIGHT + trayBottomSpacing; + const { boardWidth, boardHeight, cellWidth, cellHeight } = useMemo( + () => getGridDimensions(gridLayout, Math.max(insets.top, 0), trayHeight), + [gridLayout, insets.top, trayHeight] + ); + const selectedPhotos = useMemo( + () => gridSlots.filter((slot): slot is MonthlyDumpGridPhoto => Boolean(slot)), + [gridSlots] + ); + const selectedCount = selectedPhotos.length; + const selectionComplete = selectedCount === gridConfig.requiredPhotos; + const emptyCellIndex = gridSlots.findIndex((slot) => slot === null); + const targetCellIndex = focusedCellIndex ?? (emptyCellIndex >= 0 ? emptyCellIndex : 0); + const removeCount = pendingLayout + ? selectedCount - GRID_LAYOUTS[pendingLayout].requiredPhotos + : 0; + + const gridCellsForCapture = useMemo( + () => gridSlots.slice(0, gridConfig.requiredPhotos), + [gridConfig.requiredPhotos, gridSlots] + ); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useInfiniteQuery({ - queryKey: ['monthPhotos', user?.id, month], - queryFn: async ({ pageParam = 1 }) => { - if (!user?.id) throw new Error('User not logged in'); - return MonthlyDumpService.getEntries(user.id, month, 'photo', pageParam as number); - }, - getNextPageParam: (lastPage) => lastPage.data.pagination.has_more ? lastPage.data.pagination.page + 1 : undefined, - enabled: !!user?.id, - initialPageParam: 1, - }); - - const photos = data?.pages.flatMap(page => page.data.entries) || []; - const gridCells = useMemo(() => { - if (!selectedPhotos.length) return []; - - const next = [...selectedPhotos]; - while (next.length < 6) { - next.push(selectedPhotos[next.length % selectedPhotos.length]); + const assignPhotoToCell = (cellIndex: number, photo: MonthlyDumpGridPhoto) => { + if (isSubmitting) return; + + setGridSlots((prev) => { + const next = prev.map((slot) => (slot?.id === photo.id ? null : slot)); + next[cellIndex] = photo; + return resizeSlots(next, gridConfig.requiredPhotos); + }); + setFocusedCellIndex(cellIndex); + }; + + const fillNextAvailableCell = (photo: MonthlyDumpGridPhoto) => { + if (isSubmitting) return false; + + const nextIndex = gridSlots.findIndex((slot) => slot === null); + if (nextIndex < 0) { + Alert.alert('Grid full', 'Remove a photo before adding another one.'); + return false; } - return next.slice(0, 6); - }, [selectedPhotos]); - const togglePhoto = (photo: MonthlyDumpGridPhoto) => { + setGridSlots((prev) => { + const next = prev.map((slot) => (slot?.id === photo.id ? null : slot)); + next[nextIndex] = photo; + return resizeSlots(next, gridConfig.requiredPhotos); + }); + setFocusedCellIndex(nextIndex); + return true; + }; + + const handleCellPress = (cellIndex: number) => { + if (isSubmitting) return; + + const slot = gridSlots[cellIndex]; + setFocusedCellIndex(cellIndex); + + if (!slot) { + setSheetVisible(true); + return; + } + + // Filled cells show their trash action once selected. + }; + + const openSheet = () => { + if (isSubmitting) return; + setSheetVisible(true); + }; + + const closeSheet = () => { + if (isSubmitting) return; + setSheetVisible(false); + }; + + const openCamera = async () => { if (isSubmitting) return; - if (selectedIds.includes(photo.id)) { - setSelectedIds(prev => prev.filter(id => id !== photo.id)); - setSelectedPhotos(prev => prev.filter(p => p.id !== photo.id)); - } else { - if (selectedIds.length >= 6) return; - setSelectedIds(prev => [...prev, photo.id]); - setSelectedPhotos(prev => [...prev, photo]); + const currentPermission = cameraPermission ?? (await requestCameraPermission()); + if (!currentPermission?.granted) { + Alert.alert('Camera access needed', 'Allow camera access to capture a photo.'); + return; } + + setIsCameraReady(false); + setShowCameraModal(true); + }; + + const closeCamera = () => { + if (isCameraCapturing) return; + + setShowCameraModal(false); + setIsCameraReady(false); + }; + + const captureCameraPhoto = async () => { + if (!cameraRef.current || !isCameraReady || isCameraCapturing) return; + + setIsCameraCapturing(true); + try { + const capture = await cameraRef.current.takePictureAsync({ quality: 0.9 }); + if (!capture?.uri) { + throw new Error('Missing camera output.'); + } + + const inserted = fillNextAvailableCell({ + id: `camera-${Date.now()}`, + content_url: capture.uri, + }); + if (inserted) { + setShowCameraModal(false); + } + } catch (error) { + Alert.alert('Camera error', 'Could not capture the photo.'); + } finally { + setIsCameraCapturing(false); + } + }; + + const removePhotoFromCell = (cellIndex: number) => { + if (isSubmitting) return; + + setGridSlots((prev) => { + const next = [...prev]; + next[cellIndex] = null; + return next; + }); + setFocusedCellIndex(null); }; const createGridImage = async (): Promise => { @@ -90,11 +262,12 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr }; const handleDone = async () => { - if (!selectedIds.length || isSubmitting) return; + if (!selectionComplete || isSubmitting) return; setIsSubmitting(true); try { await onComplete({ + gridLayout, selectedPhotos, createGridImage, }); @@ -103,87 +276,221 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr } }; - if (isLoading) { - return ( - - - + const applyLayoutChange = (nextLayout: MonthlyDumpGridLayout, removePhotoIds: string[] = []) => { + const nextConfig = GRID_LAYOUTS[nextLayout]; + setGridSlots((prev) => { + const filtered = removePhotoIds.length + ? prev.filter((slot) => !slot || !removePhotoIds.includes(slot.id)) + : prev; + return resizeSlots(filtered, nextConfig.requiredPhotos); + }); + setGridLayout(nextLayout); + setFocusedCellIndex((current) => + current === null ? null : Math.min(current, nextConfig.requiredPhotos - 1) ); - } + }; + + const handleGridLayoutChange = (nextLayout: MonthlyDumpGridLayout) => { + if (isSubmitting || nextLayout === gridLayout) return; + + const nextRequiredPhotos = GRID_LAYOUTS[nextLayout].requiredPhotos; + if (selectedCount > nextRequiredPhotos) { + setPendingLayout(nextLayout); + setRemovalIds([]); + return; + } + + applyLayoutChange(nextLayout); + }; + + const toggleRemovalSelection = (photoId: string) => { + if (!pendingLayout) return; + + const overflow = selectedCount - GRID_LAYOUTS[pendingLayout].requiredPhotos; + setRemovalIds((prev) => { + if (prev.includes(photoId)) { + return prev.filter((id) => id !== photoId); + } + + if (prev.length >= overflow) { + return prev; + } + + return [...prev, photoId]; + }); + }; + + const confirmLayoutReduction = () => { + if (!pendingLayout) return; + + applyLayoutChange(pendingLayout, removalIds); + setPendingLayout(null); + setRemovalIds([]); + }; + + const cancelLayoutReduction = () => { + setPendingLayout(null); + setRemovalIds([]); + }; return ( - - - - - Create your 3x2 Grid - - {isSubmitting ? ( - - ) : ( - Done - )} - + + + + + + - - {selectedIds.length} / 6 selected + + + + + + + - item.id} - renderItem={({ item }) => { - const isSelected = selectedIds.includes(item.id); - const selectionIndex = selectedIds.indexOf(item.id); - return ( - togglePhoto(item)} - > - - {isSelected && ( - - - {selectionIndex + 1} - - - )} - - ); - }} - onEndReached={() => hasNextPage && fetchNextPage()} - onEndReachedThreshold={0.5} - ListFooterComponent={() => isFetchingNextPage ? : null} - ListEmptyComponent={() => ( - - No photos found for {month}. - - )} + - - - {gridCells.map((photo, index) => ( - - - + + + {gridSlots.map((slot, index) => ( + ))} - + + + + + + + assignPhotoToCell(targetCellIndex, photo)} + /> + + setIsCameraReady(true)} + onCapture={captureCameraPhoto} + /> + + {pendingLayout ? ( + + + + + + + Remove {removeCount} photos + Tap the ones to drop before switching layout. + + + + + + + item.id} + renderItem={({ item }: { item: MonthlyDumpGridPhoto }) => { + const isSelected = removalIds.includes(item.id); + return ( + toggleRemovalSelection(item.id)} + style={styles.sourceTile} + > + + + + + + + + + ); + }} + contentContainerStyle={styles.sourceGrid} + columnWrapperStyle={styles.sourceColumnWrapper} + showsVerticalScrollIndicator={false} + /> + + + + Continue + + + + + ) : null} ); } @@ -191,118 +498,193 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F8FAFC', + backgroundColor: '#07111f', }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F8FAFC', + backgroundLayer: { + ...StyleSheet.absoluteFillObject, }, - header: { + purpleGlow: { + position: 'absolute', + top: -80, + left: -50, + width: 320, + height: 320, + borderRadius: 320, + opacity: 0.9, + }, + blueGlow: { + position: 'absolute', + right: -110, + top: 120, + width: 340, + height: 340, + borderRadius: 340, + opacity: 0.8, + }, + sheen: { + position: 'absolute', + top: -80, + right: -100, + width: 300, + height: 300, + borderRadius: 300, + opacity: 0.45, + transform: [{ rotate: '10deg' }], + }, + topBar: { + position: 'absolute', + left: 16, + right: 16, + zIndex: 30, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, - paddingTop: 60, - paddingBottom: 16, - backgroundColor: 'white', - borderBottomWidth: 1, - borderBottomColor: '#E2E8F0', - }, - headerButton: { - padding: 4, - minWidth: 50, - }, - title: { - fontSize: 18, - fontWeight: '700', - color: '#1E293B', - }, - doneText: { - fontSize: 16, - fontWeight: '600', - color: '#8B5CF6', - textAlign: 'right', - }, - disabledText: { - color: '#CBD5E1', - }, - selectionInfo: { - padding: 12, - backgroundColor: '#F1F5F9', + }, + closeButton: { + width: 42, + height: 42, + borderRadius: 21, alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#0B1320', + borderWidth: 1, + borderColor: '#111B2C', + }, + stage: { + flex: 1, + justifyContent: 'flex-start', + alignItems: 'center', + paddingTop: 0, + paddingBottom: 0, + }, + gridBoard: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 0, + justifyContent: 'center', + alignContent: 'center', + }, + sourceOverlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 50, + }, + sourceBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(7,17,31,0.48)', + }, + removalPanel: { + position: 'absolute', + left: 12, + right: 12, + top: 86, + bottom: 12, + borderRadius: 30, + padding: 16, + backgroundColor: 'rgba(8,16,30,0.92)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', }, - selectionText: { + sourceHeader: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 12, + marginBottom: 14, + }, + sourceHeading: { + flex: 1, + }, + sourceTitle: { + color: '#F8FAFC', + fontSize: 24, + lineHeight: 30, + fontFamily: 'Outfit-Bold', + letterSpacing: -0.4, + }, + sourceSubtitle: { + marginTop: 6, + color: '#C7D2E1', fontSize: 14, - color: '#64748B', - fontWeight: '500', + lineHeight: 20, + fontFamily: 'Outfit-Regular', + }, + sourceCloseButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + sourceGrid: { + paddingBottom: 8, + }, + sourceColumnWrapper: { + gap: 10, + marginBottom: 10, }, - photoContainer: { - width: COLUMN_WIDTH, - height: COLUMN_WIDTH, - padding: 1, - position: 'relative', + sourceTile: { + flex: 1, + aspectRatio: 1, + borderRadius: 18, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', }, - photo: { + sourceImage: { width: '100%', height: '100%', - backgroundColor: '#E2E8F0', }, - photoSelected: { - opacity: 0.7, + sourceTileBorder: { + ...StyleSheet.absoluteFillObject, + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.04)', }, - selectionOverlay: { + removalTileOverlay: { ...StyleSheet.absoluteFillObject, - justifyContent: 'center', alignItems: 'center', - backgroundColor: 'rgba(139, 92, 246, 0.2)', - }, - selectionCircle: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: '#8B5CF6', justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: 'white', + backgroundColor: 'rgba(7,17,31,0.10)', }, - selectionNumber: { - color: 'white', - fontSize: 14, - fontWeight: '700', + removalTileOverlayActive: { + backgroundColor: 'rgba(239,68,68,0.20)', }, - emptyContainer: { - flex: 1, - paddingTop: 100, + removalTileBadge: { + width: 42, + height: 42, + borderRadius: 21, alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(7,17,31,0.72)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', }, - emptyText: { - fontSize: 16, - color: '#94A3B8', + removalTileBadgeActive: { + backgroundColor: 'rgba(239,68,68,0.92)', + borderColor: 'rgba(255,255,255,0.18)', }, - captureCanvasContainer: { - position: 'absolute', - left: -9999, - top: -9999, - width: GRID_CAPTURE_WIDTH, - height: GRID_CAPTURE_HEIGHT, - }, - captureCanvas: { - width: GRID_CAPTURE_WIDTH, - height: GRID_CAPTURE_HEIGHT, - flexDirection: 'row', - flexWrap: 'wrap', - backgroundColor: '#000', - }, - captureCell: { - width: GRID_CELL_WIDTH, - height: GRID_CELL_HEIGHT, + confirmButton: { + marginTop: 14, + borderRadius: 999, overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.18)', }, - captureImage: { - width: '100%', - height: '100%', + confirmButtonFill: { + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + }, + confirmButtonText: { + color: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-SemiBold', + }, + confirmButtonDisabled: { + opacity: 0.5, }, }); diff --git a/frontend/hooks/use-gallery-images.ts b/frontend/hooks/use-gallery-images.ts new file mode 100644 index 0000000..6c38652 --- /dev/null +++ b/frontend/hooks/use-gallery-images.ts @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { z } from 'zod'; +import * as MediaLibrary from 'expo-media-library'; + +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; + +const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); +const PAGE_SIZE = 24; + +interface UseGalleryImagesResult { + photos: MonthlyDumpGridPhoto[]; + isLoading: boolean; + isFetchingNextPage: boolean; + hasMore: boolean; + loadMore: () => void; + refetch: () => void; + permissionGranted: boolean; + requestPermission: () => Promise; +} + +function getMonthBounds(month: string): { startMs: number; endMs: number } | null { + const parsed = monthSchema.safeParse(month); + if (!parsed.success) return null; + + const [yearText, monthText] = parsed.data.split('-'); + const year = Number(yearText); + const monthIndex = Number(monthText) - 1; + + if (Number.isNaN(year) || Number.isNaN(monthIndex)) return null; + + const start = new Date(year, monthIndex, 1, 0, 0, 0, 0); + const end = new Date(year, monthIndex + 1, 1, 0, 0, 0, 0); + + return { + startMs: start.getTime(), + endMs: end.getTime(), + }; +} + +/** + * Loads month-scoped device gallery images for the monthly grid picker. + */ +export function useGalleryImages(month: string, enabled = true): UseGalleryImagesResult { + const monthBounds = useMemo(() => getMonthBounds(month), [month]); + const [permissionResponse, requestPermission] = MediaLibrary.usePermissions(); + + const permissionGranted = permissionResponse?.status === 'granted'; + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ + queryKey: ['monthly-dump-gallery', monthBounds?.startMs, monthBounds?.endMs], + queryFn: async ({ pageParam = undefined as string | undefined }) => { + if (!monthBounds) { + return { + assets: [], + endCursor: undefined, + hasNextPage: false, + totalCount: 0, + } as any; + } + + return MediaLibrary.getAssetsAsync({ + first: PAGE_SIZE, + after: pageParam, + createdAfter: monthBounds.startMs, + createdBefore: monthBounds.endMs, + mediaType: MediaLibrary.MediaType.photo, + sortBy: [MediaLibrary.SortBy.creationTime], + } as any); + }, + getNextPageParam: (lastPage: any) => (lastPage?.hasNextPage ? lastPage.endCursor : undefined), + enabled: enabled && !!monthBounds && permissionGranted, + initialPageParam: undefined, + }); + + const photos = useMemo(() => { + const allAssets = data?.pages.flatMap((page: any) => page?.assets || []) || []; + const seen = new Set(); + + return allAssets + .filter((asset: any) => { + if (seen.has(asset.id)) return false; + seen.add(asset.id); + return true; + }) + .map((asset: any) => ({ + id: asset.id, + content_url: asset.uri, + })); + }, [data]); + + return { + photos, + isLoading, + isFetchingNextPage, + hasMore: !!hasNextPage, + loadMore: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + refetch, + permissionGranted, + requestPermission, + }; +} diff --git a/frontend/hooks/use-monthly-entries.ts b/frontend/hooks/use-monthly-entries.ts new file mode 100644 index 0000000..c0e5fe0 --- /dev/null +++ b/frontend/hooks/use-monthly-entries.ts @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { z } from 'zod'; + +import { useAuth } from '@/hooks/use-auth'; +import { MonthlyDumpGridPhoto, MonthlyDumpService } from '@/services/monthly-dump-service'; + +const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); + +interface UseMonthlyEntriesResult { + photos: MonthlyDumpGridPhoto[]; + isLoading: boolean; + isFetchingNextPage: boolean; + hasMore: boolean; + loadMore: () => void; + refetch: () => void; +} + +/** + * Loads month-scoped app entries for the monthly grid picker. + */ +export function useMonthlyEntries(month: string, enabled = true): UseMonthlyEntriesResult { + const { user } = useAuth(); + const validatedMonth = useMemo(() => { + const parsed = monthSchema.safeParse(month); + return parsed.success ? parsed.data : ''; + }, [month]); + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({ + queryKey: ['monthly-dump-entries', user?.id, validatedMonth], + queryFn: async ({ pageParam = 1 }) => { + if (!user?.id || !validatedMonth) { + return { data: { entries: [], pagination: { has_more: false, page: 1 } } } as any; + } + + return MonthlyDumpService.getEntries(user.id, validatedMonth, 'photo', pageParam as number); + }, + getNextPageParam: (lastPage: any) => + lastPage?.data?.pagination?.has_more ? lastPage.data.pagination.page + 1 : undefined, + enabled: enabled && !!user?.id && !!validatedMonth, + initialPageParam: 1, + }); + + const photos = useMemo(() => { + const allPhotos = data?.pages.flatMap((page: any) => page?.data?.entries || []) || []; + const seen = new Set(); + + return allPhotos.filter((photo: MonthlyDumpGridPhoto) => { + if (seen.has(photo.id)) return false; + seen.add(photo.id); + return true; + }); + }, [data]); + + return { + photos, + isLoading, + isFetchingNextPage, + hasMore: !!hasNextPage, + loadMore: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + refetch, + }; +} From 507ae5e1783c097dcacf23f29f7dd502a6080928 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 00:53:20 -0400 Subject: [PATCH 18/45] separated UI elements on photo grid into separate components --- .../grid-image-picker-bottom-tray.tsx | 49 +++ .../grid-image-picker-camera-modal.tsx | 150 +++++++++ .../grid-image-picker-capture-canvas.tsx | 71 +++++ .../monthly-dumps/grid-image-picker-cell.tsx | 154 ++++++++++ .../grid-image-picker-empty-state.tsx | 88 ++++++ .../grid-image-picker-layout-popover.tsx | 175 +++++++++++ .../grid-image-picker-right-actions.tsx | 80 +++++ .../grid-image-picker-selection-pill.tsx | 45 +++ .../monthly-dumps/grid-image-picker.tsx | 284 ++++++++++++++++++ .../monthly-dumps/photo-grid-empty-state.tsx | 112 +++++++ 10 files changed, 1208 insertions(+) create mode 100644 frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-capture-canvas.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-cell.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-empty-state.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker-selection-pill.tsx create mode 100644 frontend/components/monthly-dumps/grid-image-picker.tsx create mode 100644 frontend/components/monthly-dumps/photo-grid-empty-state.tsx diff --git a/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx b/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx new file mode 100644 index 0000000..59f9990 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Camera, ImagePlus } from 'lucide-react-native'; + +interface GridImagePickerBottomTrayProps { + bottomMargin: number; + onOpenEntries: () => void; + onOpenCamera: () => void; +} + +export default function GridImagePickerBottomTray({ + bottomMargin, + onOpenEntries, + onOpenCamera, +}: GridImagePickerBottomTrayProps) { + return ( + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + bottomTrayCollapsed: { + display: 'flex', + width: '100%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 16, + }, + trayActionButton: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx b/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx new file mode 100644 index 0000000..1b15925 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { + ActivityIndicator, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { CameraView } from 'expo-camera'; +import { X } from 'lucide-react-native'; + +interface GridImagePickerCameraModalProps { + visible: boolean; + onClose: () => void; + cameraRef: React.RefObject; + isCameraReady: boolean; + isCameraCapturing: boolean; + onCameraReady: () => void; + onCapture: () => void; +} + +export default function GridImagePickerCameraModal({ + visible, + onClose, + cameraRef, + isCameraReady, + isCameraCapturing, + onCameraReady, + onCapture, +}: GridImagePickerCameraModalProps) { + return ( + + + + + + + + {!isCameraReady ? ( + + + Preparing camera... + + ) : null} + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + cameraModalOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(7,17,31,0.22)', + }, + cameraBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(7,17,31,0.34)', + }, + cameraSheet: { + width: '100%', + height: '100%', + backgroundColor: '#000', + }, + cameraView: { + ...StyleSheet.absoluteFillObject, + }, + cameraLoadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 10, + backgroundColor: 'rgba(7,17,31,0.44)', + }, + cameraLoadingText: { + color: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-Medium', + }, + cameraControls: { + position: 'absolute', + left: 0, + right: 0, + bottom: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + }, + cameraCloseButton: { + width: 46, + height: 46, + borderRadius: 23, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(7,17,31,0.48)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + cameraCaptureButton: { + width: 92, + height: 92, + borderRadius: 46, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.18)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.22)', + }, + cameraCaptureButtonDisabled: { + opacity: 0.6, + }, + cameraCaptureInner: { + width: 74, + height: 74, + borderRadius: 37, + backgroundColor: '#F8FAFC', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.78)', + }, + cameraSpacer: { + width: 46, + height: 46, + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-capture-canvas.tsx b/frontend/components/monthly-dumps/grid-image-picker-capture-canvas.tsx new file mode 100644 index 0000000..0428dda --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-capture-canvas.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import ViewShot from 'react-native-view-shot'; +import { Image } from 'expo-image'; + +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; + +type GridCell = MonthlyDumpGridPhoto | null; + +interface GridImagePickerCaptureCanvasProps { + viewShotRef: React.RefObject; + cells: GridCell[]; + captureWidth: number; + captureHeight: number; + columns: number; + rows: number; +} + +export default function GridImagePickerCaptureCanvas({ + viewShotRef, + cells, + captureWidth, + captureHeight, + columns, + rows, +}: GridImagePickerCaptureCanvasProps) { + return ( + + + {cells.map((slot, index) => ( + + {slot ? : null} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + captureCanvasContainer: { + position: 'absolute', + left: -9999, + top: -9999, + }, + captureCanvas: { + flexDirection: 'row', + flexWrap: 'wrap', + backgroundColor: '#000', + }, + captureCell: { + overflow: 'hidden', + }, + captureImage: { + width: '100%', + height: '100%', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-cell.tsx b/frontend/components/monthly-dumps/grid-image-picker-cell.tsx new file mode 100644 index 0000000..288f619 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-cell.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Image } from 'expo-image'; +import { Trash2, ImagePlus } from 'lucide-react-native'; + +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; + +type GridCell = MonthlyDumpGridPhoto | null; + +interface GridImagePickerCellProps { + slot: GridCell; + index: number; + columns: number; + rows: number; + cellWidth: number; + cellHeight: number; + isFocused: boolean; + isRemovalSelected: boolean; + pendingLayout: boolean; + onPress: (cellIndex: number) => void; + onRemove: (cellIndex: number) => void; +} + +export default function GridImagePickerCell({ + slot, + index, + columns, + rows, + cellWidth, + cellHeight, + isFocused, + isRemovalSelected, + pendingLayout, + onPress, + onRemove, +}: GridImagePickerCellProps) { + const row = Math.floor(index / columns); + const column = index % columns; + const edgeBorderStyle = { + borderTopWidth: row === 0 ? StyleSheet.hairlineWidth : 0, + borderLeftWidth: column === 0 ? StyleSheet.hairlineWidth : 0, + borderRightWidth: column < columns - 1 ? StyleSheet.hairlineWidth : 0, + borderBottomWidth: row < rows - 1 ? StyleSheet.hairlineWidth : 0, + }; + + return ( + onPress(index)} + style={[ + styles.gridCell, + edgeBorderStyle, + { + width: cellWidth, + height: cellHeight, + }, + ]} + > + {slot ? ( + <> + + + {isFocused ? ( + + {pendingLayout ? ( + + Removing + + ) : ( + onRemove(index)} style={styles.trashButton}> + + + )} + + ) : null} + {pendingLayout && isRemovalSelected ? : null} + + ) : ( + + + + + + )} + + ); +} + +const styles = StyleSheet.create({ + gridCell: { + borderRadius: 0, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.05)', + borderColor: 'rgba(255,255,255,0.08)', + }, + gridImage: { + width: '100%', + height: '100%', + }, + gridCellBorder: { + ...StyleSheet.absoluteFillObject, + borderRadius: 0, + borderWidth: 0, + borderColor: 'rgba(255,255,255,0.04)', + }, + gridCellActionLayer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(7,17,31,0.18)', + }, + trashButton: { + width: 56, + height: 56, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(239,68,68,0.92)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.16)', + }, + removalBadge: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: 'rgba(7,17,31,0.75)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + removalBadgeText: { + color: '#F8FAFC', + fontSize: 12, + fontFamily: 'Outfit-Medium', + }, + removalOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(239,68,68,0.18)', + }, + emptyCell: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 10, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + emptyCellIcon: { + width: 56, + height: 56, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-empty-state.tsx b/frontend/components/monthly-dumps/grid-image-picker-empty-state.tsx new file mode 100644 index 0000000..d70417a --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-empty-state.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +import { Colors } from '@/lib/constants'; + +type GridPickerTab = 'entries' | 'gallery'; + +interface GridImagePickerEmptyStateProps { + activeSource: GridPickerTab; + permissionGranted: boolean; + onRequestPermission: () => void; +} + +export default function GridImagePickerEmptyState({ + activeSource, + permissionGranted, + onRequestPermission, +}: GridImagePickerEmptyStateProps) { + if (activeSource === 'gallery' && !permissionGranted) { + return ( + + Gallery access needed. + Allow access to use photos from this month. + + + Allow access + + + + ); + } + + return ( + + + {activeSource === 'entries' ? 'No entries for this month.' : 'No gallery photos yet.'} + + + {activeSource === 'entries' + ? 'Check your gallery instead.' + : 'Photos from this month will appear here automatically.'} + + + ); +} + +const styles = StyleSheet.create({ + sourceEmptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 18, + gap: 10, + }, + sourceEmptyTitle: { + color: '#F8FAFC', + fontSize: 18, + fontFamily: 'Outfit-SemiBold', + textAlign: 'center', + }, + sourceEmptySubtitle: { + color: '#C7D2E1', + fontSize: 13, + fontFamily: 'Outfit-Regular', + textAlign: 'center', + lineHeight: 19, + }, + sourceActionButton: { + marginTop: 4, + }, + sourceActionButtonFill: { + borderRadius: 18, + paddingVertical: 12, + paddingHorizontal: 16, + }, + sourceActionButtonText: { + color: '#F8FAFC', + fontSize: 14, + fontFamily: 'Outfit-SemiBold', + textAlign: 'center', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx new file mode 100644 index 0000000..5ee2eca --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx @@ -0,0 +1,175 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { Dimensions, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Portal } from 'react-native-portalize'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +import { + MonthlyDumpGrid2x2Icon, + MonthlyDumpGrid2x3Icon, +} from '@/components/monthly-dumps/monthly-dump-grid-icons'; +import { MonthlyDumpGridLayout } from '@/services/monthly-dump-service'; + +const { width: screenWidth } = Dimensions.get('window'); +const GRID_POPOVER_WIDTH = 192; + +type GridIconProps = { + size?: number; + color?: string; + mutedColor?: string; +}; + +const GRID_LAYOUT_OPTIONS: MonthlyDumpGridLayout[] = ['2x2', '2x3']; + +const GRID_LAYOUT_ICONS: Record> = { + '2x2': MonthlyDumpGrid2x2Icon, + '2x3': MonthlyDumpGrid2x3Icon, +}; + +interface GridImagePickerLayoutPopoverProps { + currentLayout: MonthlyDumpGridLayout; + isSubmitting: boolean; + onLayoutChange: (layout: MonthlyDumpGridLayout) => void; +} + +export default function GridImagePickerLayoutPopover({ + currentLayout, + isSubmitting, + onLayoutChange, +}: GridImagePickerLayoutPopoverProps) { + const buttonRef = useRef(null); + const [isPopoverVisible, setIsPopoverVisible] = useState(false); + const [popoverAnchor, setPopoverAnchor] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + + const CurrentLayoutIcon = useMemo(() => GRID_LAYOUT_ICONS[currentLayout], [currentLayout]); + + const handleButtonPress = () => { + if (isSubmitting) return; + + if (isPopoverVisible) { + setIsPopoverVisible(false); + return; + } + + buttonRef.current?.measureInWindow((x, y, width, height) => { + setPopoverAnchor({ x, y, width, height }); + setIsPopoverVisible(true); + }); + }; + + const layoutPopoverLeft = popoverAnchor + ? Math.min( + Math.max(popoverAnchor.x + popoverAnchor.width - GRID_POPOVER_WIDTH, 12), + screenWidth - GRID_POPOVER_WIDTH - 12 + ) + : 12; + const layoutPopoverTop = popoverAnchor ? popoverAnchor.y + popoverAnchor.height + 10 : 0; + + return ( + <> + + + + + + + {isPopoverVisible && popoverAnchor ? ( + + + setIsPopoverVisible(false)} /> + + + {GRID_LAYOUT_OPTIONS.map((layout) => { + const isActive = layout === currentLayout; + const Icon = GRID_LAYOUT_ICONS[layout]; + + return ( + { + onLayoutChange(layout); + setIsPopoverVisible(false); + }} + style={[styles.layoutOption, isActive && styles.layoutOptionActive]} + > + + + ); + })} + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + actionButton: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#0B1320', + borderWidth: 1, + borderColor: '#111B2C', + }, + popoverOverlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 40, + }, + popoverBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(7,17,31,0.34)', + }, + layoutPopover: { + position: 'absolute', + width: GRID_POPOVER_WIDTH, + padding: 10, + borderRadius: 18, + backgroundColor: '#0B1320', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + shadowColor: '#000', + shadowOpacity: 0.24, + shadowRadius: 20, + shadowOffset: { width: 0, height: 12 }, + elevation: 10, + flexDirection: 'row', + gap: 8, + }, + layoutOption: { + flex: 1, + borderRadius: 18, + paddingVertical: 12, + paddingHorizontal: 10, + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + layoutOptionActive: { + backgroundColor: 'rgba(139,92,246,0.24)', + borderColor: 'rgba(248,250,252,0.18)', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx b/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx new file mode 100644 index 0000000..6a3e504 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Check } from 'lucide-react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +import GridImagePickerLayoutPopover from '@/components/monthly-dumps/grid-image-picker-layout-popover'; +import { Colors } from '@/lib/constants'; +import { MonthlyDumpGridLayout } from '@/services/monthly-dump-service'; + +interface GridImagePickerRightActionsProps { + gridLayout: MonthlyDumpGridLayout; + selectionComplete: boolean; + isSubmitting: boolean; + onLayoutChange: (layout: MonthlyDumpGridLayout) => void; + onDone: () => void; +} + +export default function GridImagePickerRightActions({ + gridLayout, + selectionComplete, + isSubmitting, + onLayoutChange, + onDone, +}: GridImagePickerRightActionsProps) { + return ( + + + + + + {isSubmitting ? ( + + ) : ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + rightActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + doneButton: { + borderRadius: 21, + overflow: 'hidden', + borderWidth: 1, + borderColor: Colors.primaryDark, + backgroundColor: Colors.primary, + }, + doneButtonFill: { + width: 42, + height: 42, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.primary, + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker-selection-pill.tsx b/frontend/components/monthly-dumps/grid-image-picker-selection-pill.tsx new file mode 100644 index 0000000..1d3ea21 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-selection-pill.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; + +interface GridImagePickerSelectionPillProps { + selectedCount: number; + requiredPhotos: number; + style?: StyleProp; +} + +export default function GridImagePickerSelectionPill({ + selectedCount, + requiredPhotos, + style, +}: GridImagePickerSelectionPillProps) { + return ( + + + {selectedCount}/{requiredPhotos} + + + ); +} + +const styles = StyleSheet.create({ + selectionPill: { + position: 'absolute', + top: 96, + right: 16, + zIndex: 30, + minWidth: 38, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: 'rgba(7,17,31,0.58)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + alignItems: 'center', + justifyContent: 'center', + }, + selectionPillText: { + color: '#F8FAFC', + fontSize: 12, + fontFamily: 'Outfit-SemiBold', + }, +}); diff --git a/frontend/components/monthly-dumps/grid-image-picker.tsx b/frontend/components/monthly-dumps/grid-image-picker.tsx new file mode 100644 index 0000000..82bd8bd --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Dimensions, + FlatList, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { ChevronDown, X } from 'lucide-react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { Image } from 'expo-image'; + +import GridImagePickerEmptyState from '@/components/monthly-dumps/grid-image-picker-empty-state'; +import { useGalleryImages } from '@/hooks/use-gallery-images'; +import { useMonthlyEntries } from '@/hooks/use-monthly-entries'; +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; +import { verticalScale } from 'react-native-size-matters'; + +const { height: screenHeight } = Dimensions.get('window'); +const SHEET_HEIGHT = Math.round(screenHeight * 0.9); +const SOURCE_TILE_GAP = 10; +const SOURCE_GRID_NUM_COLUMNS = 3; + +type GridPickerTab = 'entries' | 'gallery'; + +interface GridImagePickerProps { + visible: boolean; + month: string; + onClose: () => void; + onSelectPhoto: (photo: MonthlyDumpGridPhoto) => void; +} + +function uniquePhotos(photos: MonthlyDumpGridPhoto[]): MonthlyDumpGridPhoto[] { + const seen = new Set(); + return photos.filter((photo) => { + if (seen.has(photo.id)) return false; + seen.add(photo.id); + return true; + }); +} + +export default function GridImagePicker({ visible, month, onClose, onSelectPhoto }: GridImagePickerProps) { + const [activeSource, setActiveSource] = useState('entries'); + const [showSourceMenu, setShowSourceMenu] = useState(false); + + useEffect(() => { + if (visible) { + setActiveSource('entries'); + setShowSourceMenu(false); + } + }, [visible]); + + const { + photos: entryPhotos, + isLoading: isEntriesLoading, + isFetchingNextPage: isEntriesFetchingNextPage, + loadMore: loadMoreEntries, + } = useMonthlyEntries(month, visible); + + const { + photos: galleryPhotos, + isLoading: isGalleryLoading, + isFetchingNextPage: isGalleryFetchingNextPage, + loadMore: loadMoreGallery, + permissionGranted, + requestPermission, + } = useGalleryImages(month, visible); + + const visiblePhotos = useMemo( + () => uniquePhotos(activeSource === 'entries' ? entryPhotos : galleryPhotos), + [activeSource, entryPhotos, galleryPhotos] + ); + + const isLoading = activeSource === 'entries' ? isEntriesLoading : isGalleryLoading; + const isFetchingNextPage = activeSource === 'entries' ? isEntriesFetchingNextPage : isGalleryFetchingNextPage; + const loadMore = activeSource === 'entries' ? loadMoreEntries : loadMoreGallery; + + const handleSelect = (photo: MonthlyDumpGridPhoto) => { + onSelectPhoto(photo); + onClose(); + }; + + return ( + + + + + + setShowSourceMenu((prev) => !prev)} style={styles.sourceMenuButton}> + {activeSource === 'entries' ? 'Entries' : 'Gallery'} + + + + + + + + + + {isLoading && visiblePhotos.length === 0 ? ( + + + + ) : visiblePhotos.length === 0 ? ( + void requestPermission()} + /> + ) : ( + item.id} + renderItem={({ item }) => ( + handleSelect(item)} style={styles.sourceTile}> + + + + )} + contentContainerStyle={styles.sourceGrid} + columnWrapperStyle={styles.sourceColumnWrapper} + onEndReached={loadMore} + onEndReachedThreshold={0.7} + ListFooterComponent={isFetchingNextPage ? : null} + showsVerticalScrollIndicator={false} + /> + )} + + + {showSourceMenu ? ( + + { + setActiveSource('entries'); + setShowSourceMenu(false); + }} + style={styles.sourceMenuOption} + > + + Entries + + + { + setActiveSource('gallery'); + setShowSourceMenu(false); + }} + style={styles.sourceMenuOption} + > + + Gallery + + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + }, + sheetContainer: { + height: SHEET_HEIGHT, + backgroundColor: 'rgba(8,16,30)', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderTopWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + paddingBottom: 16, + }, + sheetHandle: { + width: 40, + height: 4, + borderRadius: 999, + backgroundColor: 'rgba(255,255,255,0.22)', + alignSelf: 'center', + marginTop: 12, + marginBottom: 12, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 14, + paddingBottom: 10, + }, + sourceMenuButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + sourceMenuButtonText: { + color: '#F8FAFC', + fontSize: 14, + fontFamily: 'Outfit-SemiBold', + }, + trayCloseButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + trayContent: { + flex: 1, + paddingHorizontal: 12, + paddingBottom: 12, + }, + loadingState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + sourceGrid: { + paddingTop: 2, + paddingBottom: 8, + gap: SOURCE_TILE_GAP, + }, + sourceColumnWrapper: { + gap: SOURCE_TILE_GAP, + }, + sourceTile: { + flex: 1, + aspectRatio: 1, + borderRadius: 18, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + sourceImage: { + width: '100%', + height: '100%', + }, + sourceTileBorder: { + ...StyleSheet.absoluteFillObject, + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + sourceMenuPopover: { + position: 'absolute', + top: verticalScale(60), + left: 18, + width: 192, + padding: 8, + borderRadius: 20, + backgroundColor: '#0B1320', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + gap: 8, + }, + sourceMenuOption: { + paddingVertical: 12, + paddingHorizontal: 14, + borderRadius: 16, + backgroundColor: 'transparent' + }, + sourceMenuOptionText: { + color: '#F8FAFC', + fontSize: 14, + fontFamily: 'Outfit-Medium', + }, +}); diff --git a/frontend/components/monthly-dumps/photo-grid-empty-state.tsx b/frontend/components/monthly-dumps/photo-grid-empty-state.tsx new file mode 100644 index 0000000..b63d493 --- /dev/null +++ b/frontend/components/monthly-dumps/photo-grid-empty-state.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Images } from 'lucide-react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors } from '@/lib/constants'; + +interface PhotoGridEmptyStateProps { + onPickFromGallery: () => void; +} + +export default function PhotoGridEmptyState({ onPickFromGallery }: PhotoGridEmptyStateProps) { + return ( + + + + + + + + + + + Add a few shots and start the grid. + + + + Choose from gallery + + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 28, + paddingVertical: 32, + gap: 16, + }, + backgroundLayer: { + ...StyleSheet.absoluteFillObject, + }, + purpleGlow: { + position: 'absolute', + top: -60, + left: -30, + width: 240, + height: 240, + borderRadius: 240, + opacity: 0.95, + }, + blueGlow: { + position: 'absolute', + right: -80, + bottom: -90, + width: 240, + height: 240, + borderRadius: 240, + opacity: 0.85, + }, + iconWrap: { + width: 72, + height: 72, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + subtitle: { + color: '#C7D2E1', + fontSize: 15, + lineHeight: 22, + fontFamily: 'Outfit-Regular', + textAlign: 'center', + maxWidth: 320, + }, + button: { + borderRadius: 999, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.18)', + }, + buttonFill: { + paddingHorizontal: 20, + paddingVertical: 13, + }, + buttonText: { + color: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-SemiBold', + fontWeight: '600', + }, +}); From a9e5102fb45fa570422705ef072c6a97b09b6a87 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:07:31 -0400 Subject: [PATCH 19/45] redesigned UI for monthly dump --- frontend/app/monthly-dumps/[month].tsx | 142 +++++++------ .../monthly-dumps/monthly-dump-grid-icons.tsx | 63 ++++++ .../monthly-dump-grid-prompt-slide.tsx | 186 ++++++++++++++---- .../monthly-dump-image-slide.tsx | 66 +++++++ .../monthly-dump-progress-bar-item.tsx | 8 +- .../monthly-dump-status-screen.tsx | 140 +++++++++++++ frontend/hooks/use-monthly-dump.ts | 6 +- frontend/services/monthly-dump-service.ts | 36 +++- 8 files changed, 536 insertions(+), 111 deletions(-) create mode 100644 frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-image-slide.tsx create mode 100644 frontend/components/monthly-dumps/monthly-dump-status-screen.tsx diff --git a/frontend/app/monthly-dumps/[month].tsx b/frontend/app/monthly-dumps/[month].tsx index fc899d2..028b1a2 100644 --- a/frontend/app/monthly-dumps/[month].tsx +++ b/frontend/app/monthly-dumps/[month].tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { View, Text, StyleSheet, Dimensions, TouchableOpacity, StatusBar, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, Dimensions, TouchableOpacity, StatusBar } from 'react-native'; import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -8,6 +8,8 @@ import { useAuth } from '@/hooks/use-auth'; import { X } from 'lucide-react-native'; import { useSharedValue, withTiming, Easing, runOnJS, cancelAnimation } from 'react-native-reanimated'; import PhotoGridPicker, { PhotoGridPickerCompletePayload } from '@/components/monthly-dumps/photo-grid-picker'; +import MonthlyDumpImageSlide from '@/components/monthly-dumps/monthly-dump-image-slide'; +import MonthlyDumpStatusScreen from '@/components/monthly-dumps/monthly-dump-status-screen'; import MonthlyDumpVideoSlide from '@/components/monthly-dumps/monthly-dump-video-slide'; import MonthlyDumpProgressBarItem from '@/components/monthly-dumps/monthly-dump-progress-bar-item'; import MonthlyDumpAudioSlide from '@/components/monthly-dumps/monthly-dump-audio-slide'; @@ -15,36 +17,53 @@ import MonthlyDumpGridPromptSlide from '@/components/monthly-dumps/monthly-dump- import { logger } from '@/lib/logger'; import { MonthlyDumpService, MonthlyDumpSlide, CachedMonthlyDump } from '@/services/monthly-dump-service'; -const { width, height } = Dimensions.get('window'); +const { width } = Dimensions.get('window'); type Slide = MonthlyDumpSlide | { type: 'grid_prompt' }; export default function MonthlyDumpPage() { const { month } = useLocalSearchParams<{ month: string }>(); const { user } = useAuth(); - const { slides, isLoading } = useMonthlyDump(month); + const isValidMonth = typeof month === 'string' && /^\d{4}-\d{2}$/.test(month); + const requestedMonth = isValidMonth ? month : null; + const { slides, isLoading } = useMonthlyDump(requestedMonth); const [currentIndex, setCurrentIndex] = useState(0); const [showGridPicker, setShowGridPicker] = useState(false); const queryClient = useQueryClient(); const router = useRouter(); const progress = useSharedValue(0); + const hasImageSlides = useMemo( + () => slides.some((slide) => slide.type === 'image' && !!slide.url), + [slides] + ); const allSlides = useMemo(() => { const baseSlides = slides || []; return [...baseSlides, { type: 'grid_prompt' }]; }, [slides]); + useEffect(() => { + setCurrentIndex(0); + }, [requestedMonth]); + + useEffect(() => { + if (allSlides.length === 0) return; + if (currentIndex > allSlides.length - 1) { + setCurrentIndex(allSlides.length - 1); + } + }, [allSlides.length, currentIndex]); + const monthTitle = useMemo(() => { - if (!month) return ''; + if (!requestedMonth) return ''; try { - const [year, monthValue] = month.split('-'); + const [year, monthValue] = requestedMonth.split('-'); const parsed = new Date(parseInt(year, 10), parseInt(monthValue, 10) - 1, 1); return parsed.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } catch { - return month; + return requestedMonth; } - }, [month]); + }, [requestedMonth]); useEffect(() => { const imageUrls = Array.from( @@ -114,13 +133,14 @@ export default function MonthlyDumpPage() { }; const customGridMutation = useMutation({ - mutationFn: async ({ selectedPhotos, createGridImage }: PhotoGridPickerCompletePayload) => { + mutationFn: async ({ gridLayout, selectedPhotos, createGridImage }: PhotoGridPickerCompletePayload) => { if (!user?.id) throw new Error('User is required'); if (!month) throw new Error('Month is required'); const optimisticSlide = await MonthlyDumpService.enqueueCustomGridCreation({ userId: user.id, month, + gridLayout, photos: selectedPhotos.map((photo) => ({ id: String(photo.id), content_url: String(photo.content_url), @@ -158,18 +178,15 @@ export default function MonthlyDumpPage() { }); if (isLoading) { - return ( - - - - ); + return ; } - if (!month) { + if (!requestedMonth) { return ( - - Invalid month parameter - + ); } @@ -185,7 +202,7 @@ export default function MonthlyDumpPage() { ); } - const currentSlide = allSlides[currentIndex]; + const currentSlide = allSlides[currentIndex] ?? allSlides[0]; return ( @@ -196,40 +213,36 @@ export default function MonthlyDumpPage() { onPress={handleTap} style={styles.contentContainer} > - {currentSlide.type === 'image' && currentSlide.url && ( - - )} + {currentSlide?.type === 'image' && currentSlide.url && } - {currentSlide.type === 'video' && currentSlide.url && ( + {currentSlide?.type === 'video' && currentSlide.url && ( )} - {currentSlide.type === 'audio' && ( + {currentSlide?.type === 'audio' && ( )} - {currentSlide.type === 'grid_prompt' && ( + {currentSlide?.type === 'grid_prompt' && ( setShowGridPicker(true)} /> )} {/* Top Controls */} - - {allSlides.map((_, index) => ( - - ))} - + {hasImageSlides ? ( + + {allSlides.map((_, index) => ( + + ))} + + ) : null} - - {monthTitle} + + + {monthTitle} + router.back()} style={styles.closeButton}> @@ -245,48 +258,55 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: 'black', }, - centered: { - justifyContent: 'center', - alignItems: 'center', - }, contentContainer: { flex: 1, }, - media: { - width: width, - height: height, - }, topControls: { position: 'absolute', - top: 50, - left: 0, - right: 0, - paddingHorizontal: 10, + top: 42, + left: 16, + right: 16, + zIndex: 20, }, progressBars: { flexDirection: 'row', - height: 3, - marginBottom: 15, + height: 5, + marginBottom: 14, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 4, + gap: 12, }, - userInfo: { - flexDirection: 'row', - alignItems: 'center', + monthPill: { + flexShrink: 1, + alignSelf: 'flex-start', + maxWidth: '76%', + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: 'rgba(7, 17, 31, 0.42)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + overflow: 'hidden', }, monthText: { - color: 'white', - fontSize: 16, + color: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-SemiBold', fontWeight: '700', - textShadowColor: 'rgba(0,0,0,0.5)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, + letterSpacing: 0.2, + textAlign: 'left', }, closeButton: { - padding: 4, + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(7, 17, 31, 0.42)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', }, }); diff --git a/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx b/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx new file mode 100644 index 0000000..b75301f --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Svg, { Rect } from 'react-native-svg'; + +type GridIconProps = { + size?: number; + color?: string; + mutedColor?: string; +}; + +function GridCell({ + x, + y, + color, + width, + height, +}: { + x: number; + y: number; + color: string; + width: number; + height: number; +}) { + return ; +} + +export function MonthlyDumpGrid2x2Icon({ + size = 22, + color = '#F8FAFC', + mutedColor = 'rgba(248,250,252,0.28)', +}: GridIconProps) { + const cellSize = 6; + + return ( + + + + + + + + ); +} + +export function MonthlyDumpGrid2x3Icon({ + size = 22, + color = '#F8FAFC', + mutedColor = 'rgba(248,250,252,0.28)', +}: GridIconProps) { + const cellWidth = 6; + const cellHeight = 4.5; + + return ( + + + + + + + + + + ); +} diff --git a/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx b/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx index 5c6363e..2a8a308 100644 --- a/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx +++ b/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { BlurView } from 'expo-blur'; -import { Sparkles } from 'lucide-react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { ArrowRight, Sparkles } from 'lucide-react-native'; interface MonthlyDumpGridPromptSlideProps { onCreateGrid: () => void; @@ -12,14 +12,46 @@ const { width } = Dimensions.get('window'); export default function MonthlyDumpGridPromptSlide({ onCreateGrid }: MonthlyDumpGridPromptSlideProps) { return ( - - - Relive your month - Create a custom 3x2 photo grid of your favorite moments. - - Make your grid + + + + + + + + + Turn a few moments into something worth keeping. + + Pick the photos that feel like your month, then shape them into a clean little keepsake. + + + + + Create Your Dump + + - + ); } @@ -29,48 +61,128 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: '#111827', + backgroundColor: '#07111f', + paddingHorizontal: 24, + overflow: 'hidden', + }, + backgroundLayer: { + ...StyleSheet.absoluteFillObject, + }, + topSheen: { + position: 'absolute', + top: -90, + left: -40, + width: width * 0.95, + height: width * 0.95, + borderRadius: width, + opacity: 0.55, + transform: [{ rotate: '8deg' }], + }, + accentGlow: { + position: 'absolute', + top: width * 0.12, + right: -width * 0.16, + width: width * 0.72, + height: width * 0.72, + borderRadius: width, + opacity: 0.95, }, - gridPromptBlur: { - width: width * 0.85, - padding: 40, - borderRadius: 30, + secondaryGlow: { + position: 'absolute', + bottom: -width * 0.14, + left: -width * 0.12, + width: width * 0.62, + height: width * 0.62, + borderRadius: width, + opacity: 0.8, + }, + content: { + width: '100%', + maxWidth: 460, + alignItems: 'center', + paddingVertical: 24, + }, + eyebrowRow: { + marginBottom: 18, + }, + eyebrowPill: { + flexDirection: 'row', alignItems: 'center', + gap: 8, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - overflow: 'hidden', + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(255,255,255,0.05)', }, - icon: { - marginBottom: 20, + eyebrowText: { + color: '#F8FAFC', + fontSize: 12, + fontFamily: 'Outfit-SemiBold', + letterSpacing: 0.9, + textTransform: 'uppercase', }, gridPromptTitle: { - color: 'white', - fontSize: 28, - fontWeight: '800', + color: '#F8FAFC', + fontSize: 34, + lineHeight: 40, + fontFamily: 'Outfit-Bold', + fontWeight: '700', textAlign: 'center', - marginBottom: 12, + letterSpacing: -0.6, + marginBottom: 14, }, gridPromptSubtitle: { - color: '#9CA3AF', + color: '#C7D2E1', fontSize: 16, + lineHeight: 24, + fontFamily: 'Outfit-Regular', textAlign: 'center', - lineHeight: 22, - marginBottom: 30, + marginBottom: 24, + paddingHorizontal: 12, + maxWidth: 380, + }, + detailRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 10, + marginBottom: 28, + }, + detailChip: { + paddingHorizontal: 14, + paddingVertical: 9, + borderRadius: 999, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.11)', + backgroundColor: 'rgba(255,255,255,0.04)', + }, + detailChipText: { + color: '#E2E8F0', + fontSize: 13, + fontFamily: 'Outfit-Medium', + letterSpacing: 0.1, }, gridButton: { - backgroundColor: '#8B5CF6', - paddingHorizontal: 32, - paddingVertical: 16, - borderRadius: 100, - shadowColor: '#8B5CF6', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.4, - shadowRadius: 12, - elevation: 8, + borderRadius: 999, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.16)', + }, + gridButtonFill: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + paddingHorizontal: 24, + paddingVertical: 15, }, gridButtonText: { - color: 'white', - fontSize: 18, - fontWeight: '700', + color: '#F8FAFC', + fontSize: 16, + fontFamily: 'Outfit-SemiBold', + fontWeight: '600', + letterSpacing: 0.2, }, }); diff --git a/frontend/components/monthly-dumps/monthly-dump-image-slide.tsx b/frontend/components/monthly-dumps/monthly-dump-image-slide.tsx new file mode 100644 index 0000000..6c0e0cb --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-image-slide.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; + +interface MonthlyDumpImageSlideProps { + url: string; +} + +export default function MonthlyDumpImageSlide({ url }: MonthlyDumpImageSlideProps) { + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#020817', + overflow: 'hidden', + }, + image: { + width: '100%', + height: '100%', + }, + topFade: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: '28%', + }, + bottomFade: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: '34%', + }, + frame: { + ...StyleSheet.absoluteFillObject, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, +}); diff --git a/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx b/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx index 2a2a100..5c81c32 100644 --- a/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx +++ b/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx @@ -30,13 +30,15 @@ const styles = StyleSheet.create({ progressBarBackground: { flex: 1, height: '100%', - backgroundColor: 'rgba(255, 255, 255, 0.3)', + backgroundColor: 'rgba(255, 255, 255, 0.14)', marginHorizontal: 2, - borderRadius: 2, + borderRadius: 999, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', overflow: 'hidden', }, progressBarFill: { height: '100%', - backgroundColor: 'white', + backgroundColor: '#F8FAFC', }, }); diff --git a/frontend/components/monthly-dumps/monthly-dump-status-screen.tsx b/frontend/components/monthly-dumps/monthly-dump-status-screen.tsx new file mode 100644 index 0000000..59fdf50 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-status-screen.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { ActivityIndicator, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { AlertTriangle } from 'lucide-react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +interface MonthlyDumpStatusScreenProps { + title: string; + subtitle: string; + loading?: boolean; +} + +export default function MonthlyDumpStatusScreen({ + title, + subtitle, + loading = false, +}: MonthlyDumpStatusScreenProps) { + return ( + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#07111f', + paddingHorizontal: 24, + overflow: 'hidden', + }, + backgroundLayer: { + ...StyleSheet.absoluteFillObject, + }, + purpleGlow: { + position: 'absolute', + top: -80, + left: -40, + width: 360, + height: 360, + borderRadius: 360, + opacity: 0.9, + }, + blueGlow: { + position: 'absolute', + right: -90, + top: 120, + width: 320, + height: 320, + borderRadius: 320, + opacity: 0.85, + }, + sheen: { + position: 'absolute', + top: -60, + right: -60, + width: 320, + height: 320, + borderRadius: 320, + opacity: 0.5, + transform: [{ rotate: '12deg' }], + }, + content: { + width: '100%', + maxWidth: 420, + alignItems: 'center', + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + marginBottom: 18, + }, + pillText: { + color: '#F8FAFC', + fontSize: 12, + fontFamily: 'Outfit-SemiBold', + letterSpacing: 0.9, + textTransform: 'uppercase', + }, + title: { + color: '#F8FAFC', + fontSize: 32, + lineHeight: 38, + fontFamily: 'Outfit-Bold', + fontWeight: '700', + textAlign: 'center', + letterSpacing: -0.5, + marginBottom: 12, + }, + subtitle: { + color: '#C7D2E1', + fontSize: 16, + lineHeight: 24, + fontFamily: 'Outfit-Regular', + textAlign: 'center', + maxWidth: 360, + }, +}); diff --git a/frontend/hooks/use-monthly-dump.ts b/frontend/hooks/use-monthly-dump.ts index 0f125a6..043a548 100644 --- a/frontend/hooks/use-monthly-dump.ts +++ b/frontend/hooks/use-monthly-dump.ts @@ -55,10 +55,14 @@ function mergePendingLocalSlides(remoteSlides: MonthlyDumpSlide[], cached?: Cach return [...remoteSlides, ...pendingLocalSlides]; } -export function useMonthlyDump(requestedMonth?: string): UseMonthlyDumpResult { +export function useMonthlyDump(requestedMonth?: string | null): UseMonthlyDumpResult { const { user } = useAuth(); const { isEnabled, dumpMonth } = useMemo(() => { + if (requestedMonth === null) { + return { isEnabled: false, dumpMonth: undefined }; + } + if (requestedMonth) { return { isEnabled: true, dumpMonth: requestedMonth }; } diff --git a/frontend/services/monthly-dump-service.ts b/frontend/services/monthly-dump-service.ts index 89964f9..2f9b2bf 100644 --- a/frontend/services/monthly-dump-service.ts +++ b/frontend/services/monthly-dump-service.ts @@ -11,10 +11,16 @@ const MONTHLY_DUMP_GRID_QUEUE_KEY = 'monthly_dump_grid_queue'; const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); const userIdSchema = z.string().min(1); +const monthlyDumpGridLayoutSchema = z.enum(['2x2', '2x3']); +export type MonthlyDumpGridLayout = z.infer; +const MONTHLY_DUMP_GRID_PHOTO_COUNTS: Record = { + '2x2': 4, + '2x3': 6, +}; const monthlyDumpGridPhotoSchema = z.object({ id: z.string().min(1), - content_url: z.string().url(), + content_url: z.string().min(1), }); const monthlyDumpSlideSchema = z.object({ @@ -294,20 +300,30 @@ export class MonthlyDumpService { ); } - static async create3x2GridImage( + static async createGridImage( photos: MonthlyDumpGridPhoto[], - captureGridImage: (photos: MonthlyDumpGridPhoto[]) => Promise + gridLayout: MonthlyDumpGridLayout, + captureGridImage: () => Promise ): Promise { - const validatedPhotos = z.array(monthlyDumpGridPhotoSchema).min(1).max(6).parse(photos); + const validatedLayout = monthlyDumpGridLayoutSchema.parse(gridLayout); + const expectedCount = MONTHLY_DUMP_GRID_PHOTO_COUNTS[validatedLayout]; + z.array(monthlyDumpGridPhotoSchema).length(expectedCount).parse(photos); if (typeof captureGridImage !== 'function') { throw new Error('captureGridImage must be a function.'); } - const localUri = await captureGridImage(validatedPhotos); + const localUri = await captureGridImage(); z.string().min(1).parse(localUri); return localUri.startsWith('file://') ? localUri : `file://${localUri}`; } + static async create2x3GridImage( + photos: MonthlyDumpGridPhoto[], + captureGridImage: () => Promise + ): Promise { + return this.createGridImage(photos, '2x3', captureGridImage); + } + static async saveCreatedGridImageToStorage(params: { userId: string; month: string; @@ -349,20 +365,22 @@ export class MonthlyDumpService { userId: string; month: string; photos: MonthlyDumpGridPhoto[]; - captureGridImage: (photos: MonthlyDumpGridPhoto[]) => Promise; + gridLayout: MonthlyDumpGridLayout; + captureGridImage: () => Promise; }): Promise { const schema = z.object({ userId: userIdSchema, month: monthSchema, photos: z.array(monthlyDumpGridPhotoSchema).min(1).max(6), - captureGridImage: z.custom<(photos: MonthlyDumpGridPhoto[]) => Promise>((val) => typeof val === 'function'), + gridLayout: monthlyDumpGridLayoutSchema, + captureGridImage: z.custom<() => Promise>((val) => typeof val === 'function'), }); - const { userId, month, photos, captureGridImage } = schema.parse(params); + const { userId, month, photos, gridLayout, captureGridImage } = schema.parse(params); if (typeof captureGridImage !== 'function') { throw new Error('captureGridImage must be a function.'); } - const localGridUri = await this.create3x2GridImage(photos, captureGridImage); + const localGridUri = await this.createGridImage(photos, gridLayout, captureGridImage); const entryId = `custom-grid-${Date.now()}`; const optimisticSlide: MonthlyDumpSlide = { From e65e9d5813e95141a9601c2f9c24d4e2e1739cfb Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:11:57 -0400 Subject: [PATCH 20/45] installed expo media library package --- backend/.gitignore | 1 + frontend/bun.lock | 3 +++ frontend/package.json | 1 + 3 files changed, 5 insertions(+) diff --git a/backend/.gitignore b/backend/.gitignore index bc9bd4d..e658486 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -26,5 +26,6 @@ wheels/ *.log .DS_Store +.pytest_cache # Blog posts blogs/ diff --git a/frontend/bun.lock b/frontend/bun.lock index df4e134..e0b4659 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -45,6 +45,7 @@ "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", + "expo-media-library": "~18.2.1", "expo-notifications": "~0.32.16", "expo-router": "~6.0.22", "expo-sharing": "^14.0.8", @@ -1185,6 +1186,8 @@ "expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="], + "expo-media-library": ["expo-media-library@18.2.1", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA=="], + "expo-modules-autolinking": ["expo-modules-autolinking@3.0.24", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ=="], "expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="], diff --git a/frontend/package.json b/frontend/package.json index e149339..d3071fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,6 +62,7 @@ "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", + "expo-media-library": "~18.2.1", "expo-notifications": "~0.32.16", "expo-router": "~6.0.22", "expo-sharing": "^14.0.8", From ae3c7ccbf0421cbf6493675fbddf145d0c49ef25 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:14:44 -0400 Subject: [PATCH 21/45] removed console logs --- frontend/app/index.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx index c98f308..a5ab368 100644 --- a/frontend/app/index.tsx +++ b/frontend/app/index.tsx @@ -7,18 +7,15 @@ import { useFriends } from '@/hooks/use-friends'; export default function RootScreen() { const { user, loading, session } = useAuthContext(); - console.log("User: %s", user); - console.log("Session: %s", session); - //Get Hook to Prefetch Suggested Friends const { prefetchSuggestedFriends } = useFriends(); useEffect(() => { if (user && session) { prefetchSuggestedFriends(); - } + } }, [user, session, prefetchSuggestedFriends]) - + // Show loading while checking auth if (loading) { From 6b6a50ebdcfaadcf0346e52f6d505882cd702b01 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:36:58 -0400 Subject: [PATCH 22/45] created unit and integration tests for monthly dump feature --- .../__tests__/photo-grid-picker.test.tsx | 233 ++++++++++++++++++ .../create-grid-from-entries-flow.yaml | 37 +++ .../create-grid-from-gallery-flow.yaml | 39 +++ 3 files changed, 309 insertions(+) create mode 100644 frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx create mode 100644 frontend/maestro/monthly-dumps/create-grid-from-entries-flow.yaml create mode 100644 frontend/maestro/monthly-dumps/create-grid-from-gallery-flow.yaml diff --git a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx new file mode 100644 index 0000000..660cb8e --- /dev/null +++ b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import PhotoGridPicker from '../photo-grid-picker'; +import { useCameraPermissions } from 'expo-camera'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +let mockNextPhotoIndex = 0; + +jest.mock('expo-camera', () => ({ + CameraView: require('react').forwardRef(() => null), + useCameraPermissions: jest.fn(), +})); + +jest.mock('expo-image', () => ({ + Image: ({ children, ...props }: any) => { + const React = require('react'); + const { View } = require('react-native'); + return React.createElement(View, props, children); + }, +})); + +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: ({ children, ...props }: any) => { + const React = require('react'); + const { View } = require('react-native'); + return React.createElement(View, props, children); + }, +})); + +jest.mock('react-native-view-shot', () => { + const React = require('react'); + const { View } = require('react-native'); + return React.forwardRef((props: any, ref: any) => React.createElement(View, { ref, ...props })); +}); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: jest.fn(), +})); + +jest.mock('@/components/monthly-dumps/grid-image-picker-bottom-tray', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + __esModule: true, + default: ({ onOpenEntries, onOpenCamera }: any) => ( + + + open entries + + + open camera + + + ), + }; +}); + +jest.mock('@/components/monthly-dumps/grid-image-picker-camera-modal', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + __esModule: true, + default: ({ visible, onCapture }: any) => { + if (!visible) return null; + return ( + + camera modal + + capture + + + ); + }, + }; +}); + +jest.mock('@/components/monthly-dumps/grid-image-picker', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + __esModule: true, + default: ({ visible, onClose, onSelectPhoto }: any) => { + if (!visible) return null; + return ( + + { + onSelectPhoto({ + id: `mock-photo-${mockNextPhotoIndex}`, + content_url: `https://example.com/photo-${mockNextPhotoIndex}.jpg`, + }); + mockNextPhotoIndex += 1; + onClose(); + }} + > + select photo + + + ); + }, + }; +}); + +jest.mock('@/components/monthly-dumps/grid-image-picker-right-actions', () => { + const React = require('react'); + const { Text, TouchableOpacity, View } = require('react-native'); + return { + __esModule: true, + default: ({ gridLayout, selectionComplete, isSubmitting, onLayoutChange, onDone }: any) => ( + + {gridLayout} + onLayoutChange('2x2')}> + 2x2 + + onLayoutChange('2x3')}> + 2x3 + + + {selectionComplete ? 'done enabled' : 'done disabled'} + + + ), + }; +}); + +jest.mock('@/components/monthly-dumps/grid-image-picker-selection-pill', () => { + const React = require('react'); + const { Text } = require('react-native'); + return { + __esModule: true, + default: ({ selectedCount, requiredPhotos }: any) => ( + + {selectedCount}/{requiredPhotos} + + ), + }; +}); + +jest.mock('@/components/monthly-dumps/grid-image-picker-capture-canvas', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('@/components/monthly-dumps/grid-image-picker-cell', () => { + const React = require('react'); + const { Text, TouchableOpacity } = require('react-native'); + return { + __esModule: true, + default: ({ slot, index, onPress }: any) => ( + onPress(index)}> + {slot ? `filled-${index}` : `empty-${index}`} + + ), + }; +}); + +describe('PhotoGridPicker', () => { + const onCancel = jest.fn(); + const onComplete = jest.fn().mockResolvedValue(undefined); + const requestPermission = jest.fn(); + + beforeEach(() => { + mockNextPhotoIndex = 0; + jest.clearAllMocks(); + (useSafeAreaInsets as jest.Mock).mockReturnValue({ top: 0, bottom: 0, left: 0, right: 0 }); + (useCameraPermissions as jest.Mock).mockReturnValue([{ granted: true }, requestPermission]); + }); + + const renderPicker = () => + render(); + + const fillCell = async (screen: ReturnType, cellIndex: number) => { + fireEvent.press(screen.getByTestId(`grid-cell-${cellIndex}`)); + + await waitFor(() => expect(screen.getByTestId('source-sheet')).toBeTruthy()); + fireEvent.press(screen.getByTestId('source-sheet-select-photo')); + + await waitFor(() => expect(screen.getByText(`filled-${cellIndex}`)).toBeTruthy()); + }; + + it('renders the default 2x3 grid and closes from the top button', () => { + const screen = renderPicker(); + + expect(screen.getByTestId('selection-pill').props.children.join('')).toContain('0/6'); + expect(screen.getByTestId('current-grid-layout').props.children).toBe('2x3'); + expect(screen.getByTestId('grid-cell-0')).toBeTruthy(); + expect(screen.getByTestId('grid-cell-5')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('monthly-dump-grid-close-button')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('fills the next empty cell from the source sheet', async () => { + const screen = renderPicker(); + + await fillCell(screen, 0); + + expect(screen.getByText('filled-0')).toBeTruthy(); + expect(screen.getByTestId('selection-pill').props.children.join('')).toContain('1/6'); + }); + + it('switches to a 2x2 layout when requested', () => { + const screen = renderPicker(); + + fireEvent.press(screen.getByTestId('layout-switch-2x2')); + + expect(screen.getByTestId('current-grid-layout').props.children).toBe('2x2'); + expect(screen.getByTestId('grid-cell-3')).toBeTruthy(); + expect(screen.queryByTestId('grid-cell-4')).toBeNull(); + expect(screen.queryByTestId('grid-cell-5')).toBeNull(); + expect(screen.getByTestId('selection-pill').props.children.join('')).toContain('0/4'); + }); + + it('calls onComplete after the grid is full and done is pressed', async () => { + const screen = renderPicker(); + + for (let index = 0; index < 6; index += 1) { + // eslint-disable-next-line no-await-in-loop + await fillCell(screen, index); + } + + fireEvent.press(screen.getByTestId('done-button')); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + const payload = onComplete.mock.calls[0][0]; + expect(payload.gridLayout).toBe('2x3'); + expect(payload.selectedPhotos).toHaveLength(6); + expect(typeof payload.createGridImage).toBe('function'); + }); +}); diff --git a/frontend/maestro/monthly-dumps/create-grid-from-entries-flow.yaml b/frontend/maestro/monthly-dumps/create-grid-from-entries-flow.yaml new file mode 100644 index 0000000..b20d1f1 --- /dev/null +++ b/frontend/maestro/monthly-dumps/create-grid-from-entries-flow.yaml @@ -0,0 +1,37 @@ +appId: com.fortunethedev.keepsafe +--- +# Assumes the selected month already has a monthly-dump route available. +- openLink: keepsafe://monthly-dumps/2026-05 +- assertVisible: "Create Your Dump" +- tapOn: "Create Your Dump" + +# Default source is Entries. +- tapOn: + id: monthly-dump-grid-open-entries-button + +- tapOn: + id: monthly-dump-grid-source-tile-0 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-1 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-2 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-3 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-4 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-5 + +- tapOn: + id: monthly-dump-grid-done-button +- assertNotVisible: "Create Your Dump" diff --git a/frontend/maestro/monthly-dumps/create-grid-from-gallery-flow.yaml b/frontend/maestro/monthly-dumps/create-grid-from-gallery-flow.yaml new file mode 100644 index 0000000..95553d2 --- /dev/null +++ b/frontend/maestro/monthly-dumps/create-grid-from-gallery-flow.yaml @@ -0,0 +1,39 @@ +appId: com.fortunethedev.keepsafe +--- +# Assumes the selected month already has a monthly-dump route available. +- openLink: keepsafe://monthly-dumps/2026-05 +- assertVisible: "Create Your Dump" +- tapOn: "Create Your Dump" + +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-menu-button +- tapOn: "Gallery" + +- tapOn: + id: monthly-dump-grid-source-tile-0 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-1 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-2 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-3 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-4 +- tapOn: + id: monthly-dump-grid-open-entries-button +- tapOn: + id: monthly-dump-grid-source-tile-5 + +- tapOn: + id: monthly-dump-grid-done-button +- assertNotVisible: "Create Your Dump" From 12fac3dc5f17a5b0fc39a9646580782f3262c204 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:37:35 -0400 Subject: [PATCH 23/45] addeddtest ids for uni tests --- .../grid-image-picker-bottom-tray.tsx | 14 +++++++++-- .../grid-image-picker-layout-popover.tsx | 7 +++++- .../grid-image-picker-right-actions.tsx | 1 + .../monthly-dumps/grid-image-picker.tsx | 23 +++++++++++++++---- .../monthly-dumps/monthly-dump-banner.tsx | 1 + .../monthly-dumps/photo-grid-picker.tsx | 14 +++++++++-- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx b/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx index 59f9990..3d22b0f 100644 --- a/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx @@ -15,11 +15,21 @@ export default function GridImagePickerBottomTray({ }: GridImagePickerBottomTrayProps) { return ( - + - + diff --git a/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx index 5ee2eca..75f9590 100644 --- a/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx @@ -72,7 +72,12 @@ export default function GridImagePickerLayoutPopover({ return ( <> - + diff --git a/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx b/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx index 6a3e504..4c9264d 100644 --- a/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx @@ -31,6 +31,7 @@ export default function GridImagePickerRightActions({ /> - setShowSourceMenu((prev) => !prev)} style={styles.sourceMenuButton}> + setShowSourceMenu((prev) => !prev)} + style={styles.sourceMenuButton} + > {activeSource === 'entries' ? 'Entries' : 'Gallery'} - + @@ -115,8 +125,13 @@ export default function GridImagePicker({ visible, month, onClose, onSelectPhoto data={visiblePhotos} numColumns={SOURCE_GRID_NUM_COLUMNS} keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - handleSelect(item)} style={styles.sourceTile}> + renderItem={({ item, index }) => ( + handleSelect(item)} + style={styles.sourceTile} + > diff --git a/frontend/components/monthly-dumps/monthly-dump-banner.tsx b/frontend/components/monthly-dumps/monthly-dump-banner.tsx index 0f478cf..4fcad7a 100644 --- a/frontend/components/monthly-dumps/monthly-dump-banner.tsx +++ b/frontend/components/monthly-dumps/monthly-dump-banner.tsx @@ -104,6 +104,7 @@ export default function MonthlyDumpBanner({ month, animationProgress }: MonthlyD - + @@ -441,7 +446,12 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr Remove {removeCount} photos Tap the ones to drop before switching layout. - + From 00e6c0c2024d7835a1240ce30d437d59df979216 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 01:48:51 -0400 Subject: [PATCH 24/45] reduced width or layout popover and removed rectangle --- .../monthly-dumps/grid-image-picker-layout-popover.tsx | 8 ++++---- .../components/monthly-dumps/monthly-dump-grid-icons.tsx | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx index 75f9590..1d4609a 100644 --- a/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx @@ -10,7 +10,7 @@ import { import { MonthlyDumpGridLayout } from '@/services/monthly-dump-service'; const { width: screenWidth } = Dimensions.get('window'); -const GRID_POPOVER_WIDTH = 192; +const GRID_POPOVER_WIDTH = 160; type GridIconProps = { size?: number; @@ -63,9 +63,9 @@ export default function GridImagePickerLayoutPopover({ const layoutPopoverLeft = popoverAnchor ? Math.min( - Math.max(popoverAnchor.x + popoverAnchor.width - GRID_POPOVER_WIDTH, 12), - screenWidth - GRID_POPOVER_WIDTH - 12 - ) + Math.max(popoverAnchor.x + popoverAnchor.width - GRID_POPOVER_WIDTH, 12), + screenWidth - GRID_POPOVER_WIDTH - 12 + ) : 12; const layoutPopoverTop = popoverAnchor ? popoverAnchor.y + popoverAnchor.height + 10 : 0; diff --git a/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx b/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx index b75301f..ce89d3f 100644 --- a/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx +++ b/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx @@ -32,7 +32,6 @@ export function MonthlyDumpGrid2x2Icon({ return ( - @@ -51,7 +50,6 @@ export function MonthlyDumpGrid2x3Icon({ return ( - From 436e508ea3124feb851de8b993383f620ad05855 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 02:24:50 -0400 Subject: [PATCH 25/45] removed unused supabase mock --- .../tests/test_monthly_dump_queue_notifications.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_monthly_dump_queue_notifications.py b/backend/tests/test_monthly_dump_queue_notifications.py index 9a3ae3f..7e764a6 100644 --- a/backend/tests/test_monthly_dump_queue_notifications.py +++ b/backend/tests/test_monthly_dump_queue_notifications.py @@ -6,13 +6,9 @@ from services.monthly_dump_service import MonthlyDumpResult -@pytest.fixture -def mock_supabase(): - return MagicMock() - - @pytest.mark.asyncio -async def test_monthly_dump_queue_processes_missing_entries_as_failed(): +@patch("services.queues.monthly_dump_queue_service.get_supabase_client", return_value=MagicMock()) +async def test_monthly_dump_queue_processes_missing_entries_as_failed(_mock_get_supabase_client): service = MonthlyDumpQueueService() # Mock queue messages @@ -53,7 +49,8 @@ async def test_monthly_dump_queue_processes_missing_entries_as_failed(): @pytest.mark.asyncio -async def test_monthly_dump_queue_enqueues_notification_on_success(): +@patch("services.queues.monthly_dump_queue_service.get_supabase_client", return_value=MagicMock()) +async def test_monthly_dump_queue_enqueues_notification_on_success(_mock_get_supabase_client): service = MonthlyDumpQueueService() # Mock queue messages From ec8b970b495b4adcf89fc2717cd6936e096e6227 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 02:25:28 -0400 Subject: [PATCH 26/45] fixed wrong bucket fopr dumps --- frontend/hooks/use-monthly-dump.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/hooks/use-monthly-dump.ts b/frontend/hooks/use-monthly-dump.ts index 043a548..caeb8da 100644 --- a/frontend/hooks/use-monthly-dump.ts +++ b/frontend/hooks/use-monthly-dump.ts @@ -35,7 +35,7 @@ function buildSupabasePublicUrl(storagePath?: string): string { const [possibleBucket, ...remaining] = sanitizedPath.split('/'); const hasBucketPrefix = Object.values(STORAGE_BUCKETS).includes(possibleBucket as (typeof STORAGE_BUCKETS)[keyof typeof STORAGE_BUCKETS]) && remaining.length > 0; - const bucket = hasBucketPrefix ? possibleBucket : STORAGE_BUCKETS.MEDIA; + const bucket = hasBucketPrefix ? possibleBucket : STORAGE_BUCKETS.MONTHLY_DUMPS; const path = hasBucketPrefix ? remaining.join('/') : sanitizedPath; return `${baseUrl}${SUPABASE_STORAGE_PUBLIC_SEGMENT}${bucket}/${path}`; From e1467f2004128bfa3d47722cf3d4a481513fec7d Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 02:26:08 -0400 Subject: [PATCH 27/45] Added structured logs --- backend/services/notification_enqueue_service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/services/notification_enqueue_service.py b/backend/services/notification_enqueue_service.py index 9cd93e4..98cbacb 100644 --- a/backend/services/notification_enqueue_service.py +++ b/backend/services/notification_enqueue_service.py @@ -295,13 +295,19 @@ async def enqueue_monthly_dump_notifications( ) if not filtered_recipients: - logger.info(f"No recipients with push_notifications enabled for {month_name} dump batch") + logger.info( + "No recipients with push_notifications enabled for dump batch", + extra={"month_name": month_name, "filtered_recipients": len(filtered_recipients)}, + ) return True push_tokens = self._get_push_tokens_for_users(filtered_recipients) if not push_tokens: - logger.info(f"No push tokens found for {month_name} dump batch") + logger.info( + "No push tokens found for dump batch", + extra={"month_name": month_name, "push_tokens": len(push_tokens)}, + ) return True title = f"Your {month_name} Dump is Ready! πŸŽ‰" From 7a6bf7d0c7e23a378e5d3064acb8921ede64da29 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 02:26:44 -0400 Subject: [PATCH 28/45] added unit test for cached monthly dump --- .../__tests__/monthly-dump-service.test.ts | 40 +++++++++++++++++++ frontend/services/monthly-dump-service.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 frontend/services/__tests__/monthly-dump-service.test.ts diff --git a/frontend/services/__tests__/monthly-dump-service.test.ts b/frontend/services/__tests__/monthly-dump-service.test.ts new file mode 100644 index 0000000..18b1d9f --- /dev/null +++ b/frontend/services/__tests__/monthly-dump-service.test.ts @@ -0,0 +1,40 @@ +import { MonthlyDumpService } from '../monthly-dump-service'; +import { deviceStorage } from '../device-storage'; + +jest.mock('../device-storage', () => ({ + deviceStorage: { + setItem: jest.fn(), + getItem: jest.fn(), + emit: jest.fn(), + }, +})); + +describe('MonthlyDumpService.setCachedMonthlyDump', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('accepts slides without duration_seconds and defaults them to zero', async () => { + await MonthlyDumpService.setCachedMonthlyDump('user-1', '2026-05', { + hasDump: true, + slides: [ + { + type: 'image', + url: 'https://example.com/photo.jpg', + } as any, + { + type: 'video', + url: 'https://example.com/video.mp4', + duration_seconds: 0, + } as any, + ], + }); + + expect(deviceStorage.setItem).toHaveBeenCalledTimes(1); + const payload = (deviceStorage.setItem as jest.Mock).mock.calls[0][1]; + expect(payload.slides).toEqual([ + expect.objectContaining({ duration_seconds: 0 }), + expect.objectContaining({ duration_seconds: 0 }), + ]); + }); +}); diff --git a/frontend/services/monthly-dump-service.ts b/frontend/services/monthly-dump-service.ts index 2f9b2bf..0bd27e1 100644 --- a/frontend/services/monthly-dump-service.ts +++ b/frontend/services/monthly-dump-service.ts @@ -26,7 +26,7 @@ const monthlyDumpGridPhotoSchema = z.object({ const monthlyDumpSlideSchema = z.object({ type: z.enum(['image', 'video', 'audio']), url: z.string().min(1), - duration_seconds: z.number().min(1), + duration_seconds: z.number().min(0).default(0), entry_id: z.string().optional(), storage_path: z.string().optional(), }); From ff0ab57308fd5aa1fdab3b29beef254138d6f953 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 02:35:36 -0400 Subject: [PATCH 29/45] added CI ob for frontend tests --- .github/workflows/frontend-tests.yml | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/frontend-tests.yml diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..f7c087d --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,33 @@ +name: Frontend Tests + +on: + pull_request: + branches: [ main, staging ] + paths: + - 'frontend/**' + push: + branches: [ staging ] + paths: + - 'frontend/**' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Run tests + working-directory: ./frontend + run: npx jest --ci --runInBand From eed56eb36b47c9f20e36bc248ed6525519b841ee Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 29 May 2026 03:04:51 -0400 Subject: [PATCH 30/45] fixed frontend test --- .github/workflows/frontend-tests.yml | 2 +- frontend/package-lock.json | 240 ++++++++++++++++++++++++++- 2 files changed, 237 insertions(+), 5 deletions(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index f7c087d..b17a9fc 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies working-directory: ./frontend - run: npm ci + run: npm ci --legacy-peer-deps - name: Run tests working-directory: ./frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff0386f..c125698 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,19 @@ { "name": "Keepsafe", - "version": "0.9.8", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Keepsafe", - "version": "0.9.8", + "version": "1.0.1", "dependencies": { "@date-fns/tz": "^1.4.1", "@expo-google-fonts/inter": "^0.4.1", "@expo-google-fonts/jost": "^0.4.2", "@expo-google-fonts/outfit": "^0.4.3", "@expo/vector-icons": "^15.0.3", + "@hookform/resolvers": "^5.2.2", "@lucide/lab": "^0.1.2", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "^5.0.1", @@ -48,6 +49,7 @@ "expo-linking": "~8.0.11", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", + "expo-media-library": "~18.2.1", "expo-notifications": "~0.32.16", "expo-router": "~6.0.22", "expo-sharing": "^14.0.8", @@ -61,8 +63,10 @@ "expo-web-browser": "~15.0.10", "lucide-react-native": "^0.475.0", "posthog-react-native": "^4.17.0", + "posthog-react-native-session-replay": "^1.2.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.72.0", "react-native": "0.81.5", "react-native-error-boundary": "^3.1.0", "react-native-gesture-handler": "~2.28.0", @@ -71,7 +75,7 @@ "react-native-page-flipper": "^1.0.1", "react-native-portalize": "^1.0.7", "react-native-reanimated": "~4.1.1", - "react-native-safe-area-context": "^5.6.1", + "react-native-safe-area-context": "^5.6.2", "react-native-screens": "~4.16.0", "react-native-size-matters": "^0.4.2", "react-native-svg": "15.12.1", @@ -82,7 +86,8 @@ "react-native-web": "^0.21.0", "react-native-webview": "13.15.0", "react-native-worklets": "^0.5.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.3.6" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -94,6 +99,7 @@ "eslint-config-expo": "^10.0.0", "jest": "~29.7.0", "jest-expo": "~54.0.16", + "lefthook": "^2.1.4", "supabase": "^2.39.2", "typescript": "~5.9.2" } @@ -2673,6 +2679,18 @@ "excpretty": "build/cli.js" } }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4194,6 +4212,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.71.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", @@ -8467,6 +8491,16 @@ "expo": "*" } }, + "node_modules/expo-media-library": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.1.tgz", + "integrity": "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -11633,6 +11667,169 @@ "lan-network": "dist/lan-network-cli.js" } }, + "node_modules/lefthook": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.8.tgz", + "integrity": "sha512-tJIoVpFF52PuU8YPJI9bRprGwzI6FR2GNeBbpMnXdRjjfJHyOR4VRLXilzoQ6lbhKVHfTohXhrQgLpU41bKITg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "lefthook": "bin/index.js" + }, + "optionalDependencies": { + "lefthook-darwin-arm64": "2.1.8", + "lefthook-darwin-x64": "2.1.8", + "lefthook-freebsd-arm64": "2.1.8", + "lefthook-freebsd-x64": "2.1.8", + "lefthook-linux-arm64": "2.1.8", + "lefthook-linux-x64": "2.1.8", + "lefthook-openbsd-arm64": "2.1.8", + "lefthook-openbsd-x64": "2.1.8", + "lefthook-windows-arm64": "2.1.8", + "lefthook-windows-x64": "2.1.8" + } + }, + "node_modules/lefthook-darwin-arm64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.8.tgz", + "integrity": "sha512-6dZr2QUdJOOvy9FjQHZoFVfPjgxb9IH5f9DeU0OBYMQ0cUGvb5YjHnkUkRrWIlASmwFm1bk3OPwhqKU7pTsICw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-darwin-x64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.8.tgz", + "integrity": "sha512-DW1yc+W5RBHdwaPJ94/mwFNROmNHI8Osu0iziIeJFXJIdkQ2P+KHfoxBWejYd2QA2Eu5W9i+gBssTDkJ4kX2kA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-freebsd-arm64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.8.tgz", + "integrity": "sha512-rmWVdImTihY/V1bLSb3zeDxEHjRBQtudnkKKsoph934enIWPwzIap5zVHHAj8q9mzp0wpn5r1ybX55aO2wM61A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-freebsd-x64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.8.tgz", + "integrity": "sha512-o1AG4CpmgESxLqZWzkXhne+PhLhLFV0GHVAIJCmieOwq4q2+rDYAudGhtot/NrgSpyMCo84qVSQmI8Dgnu1XJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-linux-arm64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.8.tgz", + "integrity": "sha512-er3zTjx2DMxojPJ1LZv0G3ug9Th+mAapqWrt5ZZhQNcXWW28pfvo2fCqBs6Fz14GMn4xassmwOpGovutSh1UtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-linux-x64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.8.tgz", + "integrity": "sha512-3yGx0VFbPcaKiIir313ETNcyq34CfAwkIU+Ry3WMGDjrsRNuA/YlDxm0BHKLcum7u+rpVfT4Uz6r8gHdaHXolg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-openbsd-arm64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.8.tgz", + "integrity": "sha512-Dq+GJdJdclOwxt4NneTFHjLSA4v8tI7XUZq40KUVtpUQDpZcYhXSdkTytB0uLmD52tbFKt9Kx0VbB6uvxPvLvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-openbsd-x64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.8.tgz", + "integrity": "sha512-/Gv2EdlzyiDoK+9fDWIn+EeTgrNeVncQsSeAF47X2Abe5LGxuFjZbBXxEIkY1BU79OQNNLnkx0gFHbrr5mmd9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-windows-arm64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.8.tgz", + "integrity": "sha512-S+/pBBj/7hMQOl9pLBS4Ut8+U0feQbzmD7iN0ifNth4r/uqW8UFFAHwERbclfsVnni4ceHpt7lFr7sXsu0RU8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/lefthook-windows-x64": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.8.tgz", + "integrity": "sha512-MpdgKMU/JLLCsEpTqJ9jWlxngSdDh3EknvUHveWePrIms7G11y6R3oZBNRSqZ+zx/PGNl/HKvqEtbwtw8Hz3gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -13646,6 +13843,16 @@ } } }, + "node_modules/posthog-react-native-session-replay": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/posthog-react-native-session-replay/-/posthog-react-native-session-replay-1.6.0.tgz", + "integrity": "sha512-OCaei77mtgg7JT+TgHSCgpWeKq2XXENUOPNxGbjhXZa/aJpptOW5VsBqjtH4BPzM2c1veS1DK4/Fb/uV4Rb3cg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13931,6 +14138,22 @@ "react": ">=17.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.76.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", + "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", @@ -16937,6 +17160,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } From 4714704049e486ac50a216b878e02a6647a54f33 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:39:48 -0400 Subject: [PATCH 31/45] pinned react query version --- frontend/bun.lock | 6 +++--- frontend/package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index e0b4659..8755d7b 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -18,7 +18,7 @@ "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.2.12", "@supabase/supabase-js": "^2.56.0", - "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query": "5.85.5", "@types/uuid": "^10.0.0", "axios": "^1.12.2", "date-fns": "^4.1.0", @@ -57,7 +57,7 @@ "expo-updates": "~29.0.16", "expo-video": "~3.0.15", "expo-web-browser": "~15.0.10", - "lucide-react-native": "^0.475.0", + "lucide-react-native": "^1.17.0", "posthog-react-native": "^4.17.0", "posthog-react-native-session-replay": "^1.2.0", "react": "19.1.0", @@ -1634,7 +1634,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react-native": ["lucide-react-native@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-V5tho5qQ89GD4qdzL07ZyXdrnpXZFLirGfaG6BB2vKhO6X1iA7UYYqntgBQ//ZuTUEdevskl+dVT5O4A9oOJUg=="], + "lucide-react-native": ["lucide-react-native@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-H5eM7dZkXJbYcrsjczlDC6Sq1/siWcM5O5BLLx6ljT0XDIGorZFjul+AdThuMs0I614nwowv5qbDivuQ+349Xw=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], diff --git a/frontend/package.json b/frontend/package.json index d3071fd..3849bd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,7 @@ "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.2.12", "@supabase/supabase-js": "^2.56.0", - "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query": "5.85.5", "@types/uuid": "^10.0.0", "axios": "^1.12.2", "date-fns": "^4.1.0", @@ -74,7 +74,7 @@ "expo-updates": "~29.0.16", "expo-video": "~3.0.15", "expo-web-browser": "~15.0.10", - "lucide-react-native": "^0.475.0", + "lucide-react-native": "^1.17.0", "posthog-react-native": "^4.17.0", "posthog-react-native-session-replay": "^1.2.0", "react": "19.1.0", From 13e6d09c27648d901891ff46563987254392a038 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:41:40 -0400 Subject: [PATCH 32/45] removed unused imports --- .../monthly-dumps/__tests__/photo-grid-picker.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx index 660cb8e..40cf37a 100644 --- a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx +++ b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Text, TouchableOpacity, View } from 'react-native'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import PhotoGridPicker from '../photo-grid-picker'; From ebd3a11dc657a1291d97ba90e5906c929bdb6f54 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:42:51 -0400 Subject: [PATCH 33/45] added accessiblity labels to buttons in grid picker --- .../monthly-dumps/grid-image-picker-camera-modal.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx b/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx index 1b15925..b3af946 100644 --- a/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx @@ -51,11 +51,19 @@ export default function GridImagePickerCameraModal({ ) : null} - + Date: Mon, 1 Jun 2026 12:43:36 -0400 Subject: [PATCH 34/45] fixed bad color scheme --- frontend/components/monthly-dumps/grid-image-picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/monthly-dumps/grid-image-picker.tsx b/frontend/components/monthly-dumps/grid-image-picker.tsx index 20b306b..d517caf 100644 --- a/frontend/components/monthly-dumps/grid-image-picker.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker.tsx @@ -187,7 +187,7 @@ const styles = StyleSheet.create({ }, sheetContainer: { height: SHEET_HEIGHT, - backgroundColor: 'rgba(8,16,30)', + backgroundColor: 'rgb(8,16,30)', borderTopLeftRadius: 28, borderTopRightRadius: 28, borderTopWidth: 1, From 04b390bdae21cb783342b7a98525a0373883d782 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:47:13 -0400 Subject: [PATCH 35/45] removed focused cell index when sheet is open --- .../__tests__/photo-grid-picker.test.tsx | 24 +++++++++++++++++++ .../monthly-dumps/photo-grid-picker.tsx | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx index 40cf37a..76225ac 100644 --- a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx +++ b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx @@ -229,4 +229,28 @@ describe('PhotoGridPicker', () => { expect(payload.selectedPhotos).toHaveLength(6); expect(typeof payload.createGridImage).toBe('function'); }); + + it('resets focused cell when opening entries tray so next empty cell is used', async () => { + const screen = renderPicker(); + + // 1. Fill the first cell + await fillCell(screen, 0); + expect(screen.getByText('filled-0')).toBeTruthy(); + + // 2. Tap the first cell to "focus" it (even if already focused, this ensures state) + fireEvent.press(screen.getByTestId('grid-cell-0')); + // Close the sheet that opened automatically + fireEvent.press(screen.getByTestId('monthly-dump-grid-close-button')); + + // 3. Open entries from the bottom tray + fireEvent.press(screen.getByTestId('bottom-tray-open-entries')); + + // 4. Select a photo + await waitFor(() => expect(screen.getByTestId('source-sheet')).toBeTruthy()); + fireEvent.press(screen.getByTestId('source-sheet-select-photo')); + + // 5. Verify it filled cell 1, not cell 0 again + await waitFor(() => expect(screen.getByText('filled-1')).toBeTruthy()); + expect(screen.getByText('filled-0')).toBeTruthy(); // Cell 0 should still be filled + }); }); diff --git a/frontend/components/monthly-dumps/photo-grid-picker.tsx b/frontend/components/monthly-dumps/photo-grid-picker.tsx index 0363696..e924e4c 100644 --- a/frontend/components/monthly-dumps/photo-grid-picker.tsx +++ b/frontend/components/monthly-dumps/photo-grid-picker.tsx @@ -185,6 +185,7 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr const openSheet = () => { if (isSubmitting) return; + setFocusedCellIndex(null); setSheetVisible(true); }; @@ -443,7 +444,7 @@ export default function PhotoGridPicker({ month, onComplete, onCancel }: PhotoGr - Remove {removeCount} photos + Remove {removeCount} photo{removeCount === 1 ? '' : 's'} Tap the ones to drop before switching layout. Date: Mon, 1 Jun 2026 12:50:43 -0400 Subject: [PATCH 36/45] mocked react natie reanimated in setup --- .../__tests__/photo-grid-picker.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx index 76225ac..14475f5 100644 --- a/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx +++ b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx @@ -34,6 +34,32 @@ jest.mock('react-native-view-shot', () => { return React.forwardRef((props: any, ref: any) => React.createElement(View, { ref, ...props })); }); +jest.mock('react-native-reanimated', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + __esModule: true, + default: View, + FadeIn: { duration: jest.fn().mockReturnValue({}) }, + FadeOut: { duration: jest.fn().mockReturnValue({}) }, + LinearTransition: { + springify: () => ({ + damping: () => ({ stiffness: () => ({}) }), + }), + }, + useSharedValue: (val: any) => ({ value: val }), + useAnimatedStyle: (cb: any) => cb(), + withTiming: (val: any) => val, + runOnJS: (fn: any) => fn, + cancelAnimation: jest.fn(), + Easing: { + linear: (v: any) => v, + inOut: (v: any) => v, + cubic: (v: any) => v, + }, + }; +}); + jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(), })); @@ -253,4 +279,30 @@ describe('PhotoGridPicker', () => { await waitFor(() => expect(screen.getByText('filled-1')).toBeTruthy()); expect(screen.getByText('filled-0')).toBeTruthy(); // Cell 0 should still be filled }); + + it('pluralizes the removal title correctly in the layout reduction overlay', async () => { + const screen = renderPicker(); + + // 1. Fill 5 cells (more than the 2x2 requirement of 4) + for (let index = 0; index < 5; index += 1) { + // eslint-disable-next-line no-await-in-loop + await fillCell(screen, index); + } + + // 2. Switch to 2x2 layout (requires 4) + fireEvent.press(screen.getByTestId('layout-switch-2x2')); + + // 3. Verify it shows "Remove 1 photo" + await waitFor(() => expect(screen.getByText('Remove 1 photo')).toBeTruthy()); + + // 4. Cancel and fill one more cell (total 6) + fireEvent.press(screen.getByTestId('monthly-dump-grid-source-overlay-close-button')); + await fillCell(screen, 5); + + // 5. Switch to 2x2 again + fireEvent.press(screen.getByTestId('layout-switch-2x2')); + + // 6. Verify it shows "Remove 2 photos" + await waitFor(() => expect(screen.getByText('Remove 2 photos')).toBeTruthy()); + }); }); From 42a3754354721bbb983f870ae7f0de1adfcd3a3b Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:53:29 -0400 Subject: [PATCH 37/45] removed resetting of entries --- frontend/components/monthly-dumps/grid-image-picker.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/components/monthly-dumps/grid-image-picker.tsx b/frontend/components/monthly-dumps/grid-image-picker.tsx index d517caf..863b650 100644 --- a/frontend/components/monthly-dumps/grid-image-picker.tsx +++ b/frontend/components/monthly-dumps/grid-image-picker.tsx @@ -48,7 +48,6 @@ export default function GridImagePicker({ visible, month, onClose, onSelectPhoto useEffect(() => { if (visible) { - setActiveSource('entries'); setShowSourceMenu(false); } }, [visible]); From d7b944c2a06951dddae3fe6bd52106067e5bb8ab Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 12:58:12 -0400 Subject: [PATCH 38/45] added path filters to exclude skill files from being reviewd by code rabbit --- .coderabbit.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..4f1b293 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,3 @@ +path_filters: + - "!**/SKILL.md" + - ".agents/**.md" \ No newline at end of file From 09f604bc717a1326941e5764382d80d66aca6c68 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:12:17 -0400 Subject: [PATCH 39/45] added feature flag to disable phone number sheet for now --- .../phone-number/use-manage-phone-sheet.ts | 4 +++- frontend/hooks/posthog/use-feature-flag.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 frontend/hooks/posthog/use-feature-flag.ts diff --git a/frontend/hooks/phone-number/use-manage-phone-sheet.ts b/frontend/hooks/phone-number/use-manage-phone-sheet.ts index b95aff6..3b46aa1 100644 --- a/frontend/hooks/phone-number/use-manage-phone-sheet.ts +++ b/frontend/hooks/phone-number/use-manage-phone-sheet.ts @@ -3,11 +3,13 @@ import { supabase } from '@/lib/supabase'; import { useAuthContext } from '@/providers/auth-provider'; import { getPhonePromptState } from '@/services/phone-number-prompt-service'; import { TABLES } from '@/constants/supabase'; +import { useFeatureFlag, FEATURE_FLAGS } from '@/hooks/posthog/use-feature-flag'; export function useManagePhoneSheet() { const { user } = useAuthContext(); const [showPhoneSheet, setShowPhoneSheet] = useState(false); const { profile } = useAuthContext(); + const hidePhoneSheetFlag = useFeatureFlag(FEATURE_FLAGS.HIDE_PHONE_NUMBER_SHEET); useEffect(() => { let cancelled = false; @@ -45,7 +47,7 @@ export function useManagePhoneSheet() { }, [profile?.phone_number, user?.id]); return { - showPhoneSheet, + showPhoneSheet: hidePhoneSheetFlag ? false : showPhoneSheet, setShowPhoneSheet } } \ No newline at end of file diff --git a/frontend/hooks/posthog/use-feature-flag.ts b/frontend/hooks/posthog/use-feature-flag.ts new file mode 100644 index 0000000..c9920d2 --- /dev/null +++ b/frontend/hooks/posthog/use-feature-flag.ts @@ -0,0 +1,20 @@ +import { useFeatureFlag as usePostHogFeatureFlag } from 'posthog-react-native'; + +/** + * Hook to check if a PostHog feature flag is enabled. + * Returns true if enabled, false otherwise. + * + * @param flagName The name of the feature flag to check + * @returns boolean indicating if the flag is enabled + */ +export function useFeatureFlag(flagName: string): boolean { + const isEnabled = usePostHogFeatureFlag(flagName); + return !!isEnabled; +} + +/** + * Constants for common feature flag names + */ +export const FEATURE_FLAGS = { + HIDE_PHONE_NUMBER_SHEET: 'phone-number-sheet', +} as const; From 5c25c7f2aa55770583f27354d19003085d529db1 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:16:11 -0400 Subject: [PATCH 40/45] only show recap when there is a dump for that month --- frontend/app/capture/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/capture/index.tsx b/frontend/app/capture/index.tsx index b654411..b415cd6 100644 --- a/frontend/app/capture/index.tsx +++ b/frontend/app/capture/index.tsx @@ -128,7 +128,7 @@ export default function CaptureScreen() { }; const defaultAvatarUrl = getDefaultAvatarUrl(profile?.full_name || ''); - const canShowRecap = !!month && (hasDump || isEnabled); + const canShowRecap = !!month && hasDump && isEnabled; const formatRecapChipMonth = (value?: string) => { if (!value) return ''; From bcf82062ebc36a22c8044d089ffe8d3aa1043cfa Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:30:03 -0400 Subject: [PATCH 41/45] centralized validations for monhtly dump feature --- frontend/app/monthly-dumps/[month].tsx | 8 ++-- frontend/hooks/use-gallery-images.ts | 4 +- frontend/hooks/use-monthly-dump.ts | 12 ++++-- frontend/hooks/use-monthly-entries.ts | 4 +- frontend/lib/validations/monthly-dump.ts | 28 ++++++++++++++ frontend/services/monthly-dump-service.ts | 45 +++++++++-------------- 6 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 frontend/lib/validations/monthly-dump.ts diff --git a/frontend/app/monthly-dumps/[month].tsx b/frontend/app/monthly-dumps/[month].tsx index 028b1a2..68e62c6 100644 --- a/frontend/app/monthly-dumps/[month].tsx +++ b/frontend/app/monthly-dumps/[month].tsx @@ -16,6 +16,7 @@ import MonthlyDumpAudioSlide from '@/components/monthly-dumps/monthly-dump-audio import MonthlyDumpGridPromptSlide from '@/components/monthly-dumps/monthly-dump-grid-prompt-slide'; import { logger } from '@/lib/logger'; import { MonthlyDumpService, MonthlyDumpSlide, CachedMonthlyDump } from '@/services/monthly-dump-service'; +import { monthSchema } from '@/lib/validations/monthly-dump'; const { width } = Dimensions.get('window'); @@ -24,9 +25,9 @@ type Slide = MonthlyDumpSlide | { type: 'grid_prompt' }; export default function MonthlyDumpPage() { const { month } = useLocalSearchParams<{ month: string }>(); const { user } = useAuth(); - const isValidMonth = typeof month === 'string' && /^\d{4}-\d{2}$/.test(month); + const isValidMonth = typeof month === 'string' && monthSchema.safeParse(month).success; const requestedMonth = isValidMonth ? month : null; - const { slides, isLoading } = useMonthlyDump(requestedMonth); + const { slides, isLoading, hasDump } = useMonthlyDump(requestedMonth); const [currentIndex, setCurrentIndex] = useState(0); const [showGridPicker, setShowGridPicker] = useState(false); const queryClient = useQueryClient(); @@ -40,8 +41,9 @@ export default function MonthlyDumpPage() { const allSlides = useMemo(() => { const baseSlides = slides || []; + if (!hasDump) return baseSlides; return [...baseSlides, { type: 'grid_prompt' }]; - }, [slides]); + }, [slides, hasDump]); useEffect(() => { setCurrentIndex(0); diff --git a/frontend/hooks/use-gallery-images.ts b/frontend/hooks/use-gallery-images.ts index 6c38652..8f63b35 100644 --- a/frontend/hooks/use-gallery-images.ts +++ b/frontend/hooks/use-gallery-images.ts @@ -1,11 +1,9 @@ import { useMemo } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { z } from 'zod'; import * as MediaLibrary from 'expo-media-library'; import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; - -const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); +import { monthSchema } from '@/lib/validations/monthly-dump'; const PAGE_SIZE = 24; interface UseGalleryImagesResult { diff --git a/frontend/hooks/use-monthly-dump.ts b/frontend/hooks/use-monthly-dump.ts index caeb8da..d9f29c9 100644 --- a/frontend/hooks/use-monthly-dump.ts +++ b/frontend/hooks/use-monthly-dump.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useAuth } from './use-auth'; import { CachedMonthlyDump, MonthlyDumpService, MonthlyDumpSlide } from '@/services/monthly-dump-service'; +import { monthSchema } from '@/lib/validations/monthly-dump'; import { getDate, getDaysInMonth, subMonths, format } from 'date-fns'; import { useMemo } from 'react'; import { STORAGE_BUCKETS } from '@/constants/supabase'; @@ -60,11 +61,16 @@ export function useMonthlyDump(requestedMonth?: string | null): UseMonthlyDumpRe const { isEnabled, dumpMonth } = useMemo(() => { if (requestedMonth === null) { - return { isEnabled: false, dumpMonth: undefined }; + return { isEnabled: false, dumpMonth: '' }; } - if (requestedMonth) { - return { isEnabled: true, dumpMonth: requestedMonth }; + if (requestedMonth !== undefined) { + const parsedMonth = monthSchema.safeParse(requestedMonth); + if (!parsedMonth.success) { + return { isEnabled: false, dumpMonth: '' }; + } + + return { isEnabled: true, dumpMonth: parsedMonth.data }; } const today = new Date(); diff --git a/frontend/hooks/use-monthly-entries.ts b/frontend/hooks/use-monthly-entries.ts index c0e5fe0..70be5a7 100644 --- a/frontend/hooks/use-monthly-entries.ts +++ b/frontend/hooks/use-monthly-entries.ts @@ -1,11 +1,9 @@ import { useMemo } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { z } from 'zod'; import { useAuth } from '@/hooks/use-auth'; import { MonthlyDumpGridPhoto, MonthlyDumpService } from '@/services/monthly-dump-service'; - -const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); +import { monthSchema } from '@/lib/validations/monthly-dump'; interface UseMonthlyEntriesResult { photos: MonthlyDumpGridPhoto[]; diff --git a/frontend/lib/validations/monthly-dump.ts b/frontend/lib/validations/monthly-dump.ts new file mode 100644 index 0000000..ba6ad34 --- /dev/null +++ b/frontend/lib/validations/monthly-dump.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); + +export const monthlyDumpGridLayoutSchema = z.enum(['2x2', '2x3']); + +export const monthlyDumpGridPhotoSchema = z.object({ + id: z.string().min(1), + content_url: z.string().min(1), +}); + +export const monthlyDumpSlideSchema = z.object({ + type: z.enum(['image', 'video', 'audio']), + url: z.string().min(1), + duration_seconds: z.number().min(0).default(0), + entry_id: z.string().optional(), + storage_path: z.string().optional(), +}); + +export const monthlyDumpResponseSchema = z.object({ + status: z.enum(['completed', 'pending', 'processing', 'failed']), + slides: z.array(monthlyDumpSlideSchema), +}); + +export type MonthlyDumpGridLayout = z.infer; +export type MonthlyDumpGridPhoto = z.infer; +export type MonthlyDumpSlide = z.infer; +export type MonthlyDumpResponse = z.infer; diff --git a/frontend/services/monthly-dump-service.ts b/frontend/services/monthly-dump-service.ts index 0bd27e1..39b99f7 100644 --- a/frontend/services/monthly-dump-service.ts +++ b/frontend/services/monthly-dump-service.ts @@ -4,45 +4,27 @@ import { supabase } from '@/lib/supabase'; import { TABLES, STORAGE_BUCKETS } from '@/constants/supabase'; import { convertToArrayBuffer } from '@/lib/utils'; import { deviceStorage } from './device-storage'; +import { + monthSchema, + monthlyDumpGridLayoutSchema, + monthlyDumpGridPhotoSchema, + monthlyDumpSlideSchema, + type MonthlyDumpGridLayout, + type MonthlyDumpGridPhoto, + type MonthlyDumpResponse, + type MonthlyDumpSlide, +} from '@/lib/validations/monthly-dump'; const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL ?? 'http://localhost:8000'; const MONTHLY_DUMP_CACHE_TTL_MINUTES = 31 * 24 * 60; const MONTHLY_DUMP_GRID_QUEUE_KEY = 'monthly_dump_grid_queue'; -const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); const userIdSchema = z.string().min(1); -const monthlyDumpGridLayoutSchema = z.enum(['2x2', '2x3']); -export type MonthlyDumpGridLayout = z.infer; const MONTHLY_DUMP_GRID_PHOTO_COUNTS: Record = { '2x2': 4, '2x3': 6, }; -const monthlyDumpGridPhotoSchema = z.object({ - id: z.string().min(1), - content_url: z.string().min(1), -}); - -const monthlyDumpSlideSchema = z.object({ - type: z.enum(['image', 'video', 'audio']), - url: z.string().min(1), - duration_seconds: z.number().min(0).default(0), - entry_id: z.string().optional(), - storage_path: z.string().optional(), -}); - -export type MonthlyDumpSlide = z.infer; - -export interface MonthlyDumpResponse { - status: 'completed' | 'pending' | 'processing' | 'failed'; - slides: MonthlyDumpSlide[]; -} - -export interface MonthlyDumpGridPhoto { - id: string; - content_url: string; -} - export interface CachedMonthlyDump { hasDump: boolean; slides: MonthlyDumpSlide[]; @@ -448,3 +430,10 @@ export class MonthlyDumpService { }); } } + +export type { + MonthlyDumpGridLayout, + MonthlyDumpGridPhoto, + MonthlyDumpResponse, + MonthlyDumpSlide, +} from '@/lib/validations/monthly-dump'; From b12050052d51c8fb1bac41ba2b65497696833691 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:30:17 -0400 Subject: [PATCH 42/45] updated rules to group validations --- frontend/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6d80879..848556b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -23,7 +23,7 @@ 2. When Calling backend APIs use the `apiFetch` helper (or `apiFetchStream` if streaming endpoint) -3. Before making any calls to backend APIs, perform input validation with zod to ensure data is valid. +3. Before making any calls to backend APIs, perform input validation with zod to ensure data is valid. Keep all validations in the `lib/validations` directory, and group them by feature (eg. `lib/validations/auth.ts` for Auth, `lib/validation/entries.ts` for Entries) ### Code Style 1. Always evaulate negative conditions first eg. From 687cea5f0e013184f279bf417e69e85edfaeea6a Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:30:39 -0400 Subject: [PATCH 43/45] migrated from useEffect to fetch updates --- .../phone-number/use-manage-phone-sheet.ts | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/frontend/hooks/phone-number/use-manage-phone-sheet.ts b/frontend/hooks/phone-number/use-manage-phone-sheet.ts index 3b46aa1..d9894ce 100644 --- a/frontend/hooks/phone-number/use-manage-phone-sheet.ts +++ b/frontend/hooks/phone-number/use-manage-phone-sheet.ts @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/lib/supabase'; import { useAuthContext } from '@/providers/auth-provider'; import { getPhonePromptState } from '@/services/phone-number-prompt-service'; @@ -6,27 +7,48 @@ import { TABLES } from '@/constants/supabase'; import { useFeatureFlag, FEATURE_FLAGS } from '@/hooks/posthog/use-feature-flag'; export function useManagePhoneSheet() { - const { user } = useAuthContext(); + const { user, profile } = useAuthContext(); const [showPhoneSheet, setShowPhoneSheet] = useState(false); - const { profile } = useAuthContext(); const hidePhoneSheetFlag = useFeatureFlag(FEATURE_FLAGS.HIDE_PHONE_NUMBER_SHEET); + const { + data: pendingRecord, + isPending: isPendingPhoneUpdates, + isError: isPhoneUpdatesError + } = useQuery({ + queryKey: ['phone_updates', user?.id], + enabled: !!user?.id, + queryFn: async () => { + if (!user?.id) { + throw new Error('Missing user id'); + } + + const { data } = await supabase + .from(TABLES.PHONE_NUMBER_UPDATES) + .select('id') + .eq('user_id', user.id) + .maybeSingle() as { data: { id: string } | null }; + + return data; + }, + }); useEffect(() => { let cancelled = false; const checkShouldShowPhonePrompt = async () => { - if (!user?.id) return; - if (profile?.phone_number) { + if (!user?.id) { if (!cancelled) setShowPhoneSheet(false); return; } - // If the user already has a pending OTP record, always show the sheet. - const { data: pendingRecord } = await supabase - .from(TABLES.PHONE_NUMBER_UPDATES) - .select('id') - .eq('user_id', user.id) - .maybeSingle() as { data: { id: string } | null }; + if (isPendingPhoneUpdates || isPhoneUpdatesError) { + return; + } + + if (profile?.phone_number) { + if (!cancelled) setShowPhoneSheet(false); + return; + } if (pendingRecord?.id) { if (!cancelled) setShowPhoneSheet(true); @@ -44,10 +66,10 @@ export function useManagePhoneSheet() { return () => { cancelled = true; }; - }, [profile?.phone_number, user?.id]); + }, [isPendingPhoneUpdates, isPhoneUpdatesError, pendingRecord, profile?.phone_number, user?.id]); return { showPhoneSheet: hidePhoneSheetFlag ? false : showPhoneSheet, setShowPhoneSheet } -} \ No newline at end of file +} From 9cd0d0f23dcb5dd183ac744f8d23e9c8e822e00f Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:41:49 -0400 Subject: [PATCH 44/45] added retries to enqueue and removed only messages that were successful --- .../queues/monthly_dump_queue_service.py | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/backend/services/queues/monthly_dump_queue_service.py b/backend/services/queues/monthly_dump_queue_service.py index eeff030..29157c5 100644 --- a/backend/services/queues/monthly_dump_queue_service.py +++ b/backend/services/queues/monthly_dump_queue_service.py @@ -95,8 +95,9 @@ async def process_queue(self) -> Dict[str, int]: logger.info("No monthly dump messages available", extra={"queue": self.queue_name}) return stats - # Group successful users by their dump month to send batch notifications - month_to_user_ids: Dict[str, List[str]] = {} + # Group completed dump messages by month so notification enqueue happens + # before we acknowledge/delete the source queue message. + month_to_messages: Dict[str, List[Dict[str, Any]]] = {} for message in messages: # We need the month from the message data to group notifications @@ -108,15 +109,61 @@ async def process_queue(self) -> Dict[str, int]: msg_month = None user_id = await self._process_message(message, stats) - if user_id and msg_month: - if msg_month not in month_to_user_ids: - month_to_user_ids[msg_month] = [] - month_to_user_ids[msg_month].append(user_id) + if not user_id or not msg_month: + continue - if month_to_user_ids: - notification_enqueue_service = NotificationEnqueueService() - for msg_month, user_ids in month_to_user_ids.items(): - await notification_enqueue_service.enqueue_monthly_dump_notifications(user_ids, msg_month) + if msg_month not in month_to_messages: + month_to_messages[msg_month] = [] + + month_to_messages[msg_month].append({ + "msg_id": message.get("msg_id"), + "user_id": user_id, + }) + + if not month_to_messages: + return stats + + notification_enqueue_service = NotificationEnqueueService() + for msg_month, month_messages in month_to_messages.items(): + user_ids = list(dict.fromkeys( + message["user_id"] + for message in month_messages + if message.get("user_id") + )) + if not user_ids: + continue + + enqueue_success = await notification_enqueue_service.enqueue_monthly_dump_notifications( + user_ids, + msg_month, + ) + if not enqueue_success: + logger.warning( + "Failed to enqueue monthly dump notifications", + extra={"queue": self.queue_name, "month": msg_month, "user_count": len(user_ids)}, + ) + stats["failed"] += len(month_messages) + continue + + for message in month_messages: + msg_id = message.get("msg_id") + if msg_id is None: + continue + + try: + self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) + stats["succeeded"] += 1 + except Exception as delete_exc: # noqa: BLE001 + logger.error( + "Failed to delete monthly dump queue message after notification enqueue", + extra={ + "queue": self.queue_name, + "msg_id": msg_id, + "month": msg_month, + "error": str(delete_exc), + }, + ) + stats["failed"] += 1 logger.info( "Monthly dump queue processing complete", @@ -190,9 +237,7 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) "Monthly dump already completed, skipping processing", extra={"monthly_dump_id": monthly_dump_id, "queue": self.queue_name}, ) - self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) - stats["succeeded"] += 1 - return None + return user_id persisted_seed = existing_dump.get("random_seed") if existing_dump else None if persisted_seed is not None: @@ -259,8 +304,6 @@ async def _process_message(self, message: Dict[str, Any], stats: Dict[str, int]) } ) - self.queue_service.delete_message(queue_name=self.queue_name, message_id=msg_id) - stats["succeeded"] += 1 return user_id except Exception as exc: # noqa: BLE001 msg_data["last_error"] = str(exc) From f0512051debdc7dd4e4e66227828bd290f1ecf64 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Mon, 1 Jun 2026 13:44:24 -0400 Subject: [PATCH 45/45] switched frontend tests to bun --- .github/workflows/frontend-tests.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index b17a9fc..7380131 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -17,17 +17,15 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Node - uses: actions/setup-node@v4 + - name: Set up Bun + uses: oven-sh/setup-bun@v2 with: - node-version: 20 - cache: npm - cache-dependency-path: frontend/package-lock.json + bun-version: latest - name: Install dependencies working-directory: ./frontend - run: npm ci --legacy-peer-deps + run: bun install --frozen-lockfile - name: Run tests working-directory: ./frontend - run: npx jest --ci --runInBand + run: bunx jest --ci --runInBand