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/.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 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..7380131 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,31 @@ +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 Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + working-directory: ./frontend + run: bun install --frozen-lockfile + + - name: Run tests + working-directory: ./frontend + run: bunx jest --ci --runInBand 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/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) diff --git a/backend/services/notification_enqueue_service.py b/backend/services/notification_enqueue_service.py index 9be6170..98cbacb 100644 --- a/backend/services/notification_enqueue_service.py +++ b/backend/services/notification_enqueue_service.py @@ -262,3 +262,87 @@ 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], + 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. + """ + if not user_ids: + return True + + try: + # 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 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( + "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! 🎉" + body = f"Relive your best moments from {month_name} {year}." + + success = self.notification_service.enqueue_notification( + title=title, + body=body, + recipients=push_tokens, + priority="normal", + metadata={ + "notification_type": "monthly_dump_ready", + "month": month + }, + data={ + "page_url": f"/monthly-dumps/{month}", + } + ) + + if success: + logger.info( + "Monthly dump batch notification enqueued", + extra={"month": month, "recipients": len(push_tokens)}, + ) + else: + logger.error( + "Failed to enqueue monthly dump batch notification", + extra={"month": month}, + ) + + return success + + except Exception: + logger.exception( + "Error enqueueing monthly dump batch notification", + extra={"month": month}, + ) + return False diff --git a/backend/services/queues/monthly_dump_queue_service.py b/backend/services/queues/monthly_dump_queue_service.py index 0d40e49..29157c5 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 @@ -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,75 @@ async def process_queue(self) -> Dict[str, int]: logger.info("No monthly dump messages available", extra={"queue": self.queue_name}) return stats + # 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: - await self._process_message(message, stats) + # 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 + + user_id = await self._process_message(message, stats) + if not user_id or not msg_month: + continue + + 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", @@ -103,7 +171,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 +187,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 +203,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( @@ -169,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 + return user_id persisted_seed = existing_dump.get("random_seed") if existing_dump else None if persisted_seed is not None: @@ -198,8 +264,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 @@ -238,8 +304,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 +317,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..7e764a6 --- /dev/null +++ b/backend/tests/test_monthly_dump_queue_notifications.py @@ -0,0 +1,101 @@ +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.mark.asyncio +@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 + 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 +@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 + 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 = 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( + 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"], "2026-04") diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7f353ea..848556b 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. 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. ```js 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..b415cd6 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() { > - + + + + + + + + + { if (user && session) { prefetchSuggestedFriends(); - } + } }, [user, session, prefetchSuggestedFriends]) - + // Show loading while checking auth if (loading) { diff --git a/frontend/app/monthly-dumps/[month].tsx b/frontend/app/monthly-dumps/[month].tsx new file mode 100644 index 0000000..68e62c6 --- /dev/null +++ b/frontend/app/monthly-dumps/[month].tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +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'; +import { useMonthlyDump } from '@/hooks/use-monthly-dump'; +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'; +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'); + +type Slide = MonthlyDumpSlide | { type: 'grid_prompt' }; + +export default function MonthlyDumpPage() { + const { month } = useLocalSearchParams<{ month: string }>(); + const { user } = useAuth(); + const isValidMonth = typeof month === 'string' && monthSchema.safeParse(month).success; + const requestedMonth = isValidMonth ? month : null; + const { slides, isLoading, hasDump } = 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 || []; + if (!hasDump) return baseSlides; + return [...baseSlides, { type: 'grid_prompt' }]; + }, [slides, hasDump]); + + 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 (!requestedMonth) return ''; + try { + 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 requestedMonth; + } + }, [requestedMonth]); + + 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 = useCallback(() => { + if (currentIndex < allSlides.length - 1) { + setCurrentIndex(prev => prev + 1); + progress.value = 0; + } else { + router.back(); + } + }, [currentIndex, allSlides.length, router, progress]); + + const prevSlide = useCallback(() => { + if (currentIndex > 0) { + setCurrentIndex(prev => prev - 1); + progress.value = 0; + } + }, [currentIndex, progress]); + + 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 () => { + cancelAnimation(progress); + progress.value = 0; + }; + }, [currentIndex, allSlides, showGridPicker, nextSlide, progress]); + + const handleTap = (evt: { nativeEvent: { locationX: number } }) => { + const x = evt.nativeEvent.locationX; + if (x < width * 0.33) { + prevSlide(); + } else { + nextSlide(); + } + }; + + const customGridMutation = useMutation({ + 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), + })), + captureGridImage: createGridImage, + }); + + return optimisticSlide; + }, + onSuccess: (optimisticSlide) => { + if (!user?.id || !month) return; + + let targetIndex = slides.length; + 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 + ); + 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 (!requestedMonth) { + return ( + + ); + } + + if (showGridPicker) { + return ( + setShowGridPicker(false)} + onComplete={async (payload) => { + await customGridMutation.mutateAsync(payload); + }} + /> + ); + } + + const currentSlide = allSlides[currentIndex] ?? allSlides[0]; + + return ( + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'black', + }, + contentContainer: { + flex: 1, + }, + topControls: { + position: 'absolute', + top: 42, + left: 16, + right: 16, + zIndex: 20, + }, + progressBars: { + flexDirection: 'row', + height: 5, + marginBottom: 14, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + }, + 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: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-SemiBold', + fontWeight: '700', + letterSpacing: 0.2, + textAlign: 'left', + }, + closeButton: { + 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/bun.lock b/frontend/bun.lock index df4e134..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", @@ -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", @@ -56,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", @@ -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=="], @@ -1631,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/components/capture/capture-header.tsx b/frontend/components/capture/capture-header.tsx index 1b94dbc..1c326b6 100644 --- a/frontend/components/capture/capture-header.tsx +++ b/frontend/components/capture/capture-header.tsx @@ -9,9 +9,21 @@ interface CaptureHeaderProps { profile: any; defaultAvatarUrl: string; convertToLocalTimezone: (date: Date | string) => 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/__tests__/photo-grid-picker.test.tsx b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx new file mode 100644 index 0000000..14475f5 --- /dev/null +++ b/frontend/components/monthly-dumps/__tests__/photo-grid-picker.test.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +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-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(), +})); + +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'); + }); + + 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 + }); + + 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()); + }); +}); 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..3d22b0f --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-bottom-tray.tsx @@ -0,0 +1,59 @@ +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..b3af946 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-camera-modal.tsx @@ -0,0 +1,158 @@ +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..1d4609a --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-layout-popover.tsx @@ -0,0 +1,180 @@ +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 = 160; + +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..4c9264d --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker-right-actions.tsx @@ -0,0 +1,81 @@ +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..863b650 --- /dev/null +++ b/frontend/components/monthly-dumps/grid-image-picker.tsx @@ -0,0 +1,298 @@ +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) { + 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, index }) => ( + 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: 'rgb(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/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-banner.tsx b/frontend/components/monthly-dumps/monthly-dump-banner.tsx new file mode 100644 index 0000000..4fcad7a --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-banner.tsx @@ -0,0 +1,194 @@ +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 }; + }); + + const handlePress = () => { + if (!month) return; + router.push(`/monthly-dumps/${month}`); + } + + return ( + + + + + + + + + + + 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, + }, +}); 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..ce89d3f --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-grid-icons.tsx @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..2a8a308 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-grid-prompt-slide.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { ArrowRight, Sparkles } from 'lucide-react-native'; + +interface MonthlyDumpGridPromptSlideProps { + onCreateGrid: () => void; +} + +const { width } = Dimensions.get('window'); + +export default function MonthlyDumpGridPromptSlide({ onCreateGrid }: MonthlyDumpGridPromptSlideProps) { + return ( + + + + + + + + + + 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 + + + + + + ); +} + +const styles = StyleSheet.create({ + gridPromptContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + 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, + }, + 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.14)', + backgroundColor: 'rgba(255,255,255,0.05)', + }, + eyebrowText: { + color: '#F8FAFC', + fontSize: 12, + fontFamily: 'Outfit-SemiBold', + letterSpacing: 0.9, + textTransform: 'uppercase', + }, + gridPromptTitle: { + color: '#F8FAFC', + fontSize: 34, + lineHeight: 40, + fontFamily: 'Outfit-Bold', + fontWeight: '700', + textAlign: 'center', + letterSpacing: -0.6, + marginBottom: 14, + }, + gridPromptSubtitle: { + color: '#C7D2E1', + fontSize: 16, + lineHeight: 24, + fontFamily: 'Outfit-Regular', + textAlign: 'center', + 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: { + 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: '#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 new file mode 100644 index 0000000..5c81c32 --- /dev/null +++ b/frontend/components/monthly-dumps/monthly-dump-progress-bar-item.tsx @@ -0,0 +1,44 @@ +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.14)', + marginHorizontal: 2, + borderRadius: 999, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + 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/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-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', + }, +}); 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..e924e4c --- /dev/null +++ b/frontend/components/monthly-dumps/photo-grid-picker.tsx @@ -0,0 +1,701 @@ +import React, { useMemo, useRef, useState } from 'react'; +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'; + +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'; + +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; +} + +interface PhotoGridPickerProps { + month: string; + onComplete: (payload: PhotoGridPickerCompletePayload) => Promise; + 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 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 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; + } + + 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; + setFocusedCellIndex(null); + setSheetVisible(true); + }; + + const closeSheet = () => { + if (isSubmitting) return; + setSheetVisible(false); + }; + + const openCamera = async () => { + if (isSubmitting) return; + + 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 => { + 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 (!selectionComplete || isSubmitting) return; + + setIsSubmitting(true); + try { + await onComplete({ + gridLayout, + selectedPhotos, + createGridImage, + }); + } finally { + setIsSubmitting(false); + } + }; + + 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 ( + + + + + + + + + + + + + + + + + + + + + + {gridSlots.map((slot, index) => ( + + ))} + + + + + + + + assignPhotoToCell(targetCellIndex, photo)} + /> + + setIsCameraReady(true)} + onCapture={captureCameraPhoto} + /> + + {pendingLayout ? ( + + + + + + + Remove {removeCount} photo{removeCount === 1 ? '' : 's'} + 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} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#07111f', + }, + backgroundLayer: { + ...StyleSheet.absoluteFillObject, + }, + 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', + }, + 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)', + }, + 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, + 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, + }, + 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.04)', + }, + removalTileOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(7,17,31,0.10)', + }, + removalTileOverlayActive: { + backgroundColor: 'rgba(239,68,68,0.20)', + }, + 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)', + }, + removalTileBadgeActive: { + backgroundColor: 'rgba(239,68,68,0.92)', + borderColor: 'rgba(255,255,255,0.18)', + }, + confirmButton: { + marginTop: 14, + borderRadius: 999, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.18)', + }, + confirmButtonFill: { + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + }, + confirmButtonText: { + color: '#F8FAFC', + fontSize: 15, + fontFamily: 'Outfit-SemiBold', + }, + confirmButtonDisabled: { + opacity: 0.5, + }, +}); 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/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/phone-number/use-manage-phone-sheet.ts b/frontend/hooks/phone-number/use-manage-phone-sheet.ts new file mode 100644 index 0000000..d9894ce --- /dev/null +++ b/frontend/hooks/phone-number/use-manage-phone-sheet.ts @@ -0,0 +1,75 @@ +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'; +import { TABLES } from '@/constants/supabase'; +import { useFeatureFlag, FEATURE_FLAGS } from '@/hooks/posthog/use-feature-flag'; + +export function useManagePhoneSheet() { + const { user, profile } = useAuthContext(); + const [showPhoneSheet, setShowPhoneSheet] = useState(false); + 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) { + if (!cancelled) setShowPhoneSheet(false); + return; + } + + if (isPendingPhoneUpdates || isPhoneUpdatesError) { + return; + } + + if (profile?.phone_number) { + if (!cancelled) setShowPhoneSheet(false); + return; + } + + 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; + }; + }, [isPendingPhoneUpdates, isPhoneUpdatesError, pendingRecord, profile?.phone_number, user?.id]); + + return { + showPhoneSheet: hidePhoneSheetFlag ? false : showPhoneSheet, + setShowPhoneSheet + } +} 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; diff --git a/frontend/hooks/use-gallery-images.ts b/frontend/hooks/use-gallery-images.ts new file mode 100644 index 0000000..8f63b35 --- /dev/null +++ b/frontend/hooks/use-gallery-images.ts @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import * as MediaLibrary from 'expo-media-library'; + +import { MonthlyDumpGridPhoto } from '@/services/monthly-dump-service'; +import { monthSchema } from '@/lib/validations/monthly-dump'; +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-dump.ts b/frontend/hooks/use-monthly-dump.ts new file mode 100644 index 0000000..d9f29c9 --- /dev/null +++ b/frontend/hooks/use-monthly-dump.ts @@ -0,0 +1,143 @@ +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'; + +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.MONTHLY_DUMPS; + 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 | null): UseMonthlyDumpResult { + const { user } = useAuth(); + + const { isEnabled, dumpMonth } = useMemo(() => { + if (requestedMonth === null) { + return { isEnabled: false, dumpMonth: '' }; + } + + 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(); + 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/hooks/use-monthly-entries.ts b/frontend/hooks/use-monthly-entries.ts new file mode 100644 index 0000000..70be5a7 --- /dev/null +++ b/frontend/hooks/use-monthly-entries.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { useAuth } from '@/hooks/use-auth'; +import { MonthlyDumpGridPhoto, MonthlyDumpService } from '@/services/monthly-dump-service'; +import { monthSchema } from '@/lib/validations/monthly-dump'; + +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, + }; +} 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 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/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" 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" + } } } } diff --git a/frontend/package.json b/frontend/package.json index e149339..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", @@ -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", @@ -73,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", 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 new file mode 100644 index 0000000..39b99f7 --- /dev/null +++ b/frontend/services/monthly-dump-service.ts @@ -0,0 +1,439 @@ +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'; +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 userIdSchema = z.string().min(1); +const MONTHLY_DUMP_GRID_PHOTO_COUNTS: Record = { + '2x2': 4, + '2x3': 6, +}; + +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 peekGridQueueItem(): Promise { + const queue = await this.loadGridQueue(); + 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 { + 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.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 { + 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.enum(['photo', 'video', 'audio']).parse(type); + 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 createGridImage( + photos: MonthlyDumpGridPhoto[], + gridLayout: MonthlyDumpGridLayout, + captureGridImage: () => Promise + ): Promise { + 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(); + 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; + 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[]; + gridLayout: MonthlyDumpGridLayout; + captureGridImage: () => Promise; + }): Promise { + const schema = z.object({ + userId: userIdSchema, + month: monthSchema, + photos: z.array(monthlyDumpGridPhotoSchema).min(1).max(6), + gridLayout: monthlyDumpGridLayoutSchema, + captureGridImage: z.custom<() => Promise>((val) => typeof val === 'function'), + }); + + 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.createGridImage(photos, gridLayout, 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, + }); + } +} + +export type { + MonthlyDumpGridLayout, + MonthlyDumpGridPhoto, + MonthlyDumpResponse, + MonthlyDumpSlide, +} from '@/lib/validations/monthly-dump'; 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 961c9d1..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: { @@ -372,6 +375,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 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" } } } 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