AnuLunar user experience refinement#5
Conversation
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
|
Cursor Agent can help with this pull request. Just |
|
Deployment failed with the following error: Learn More: https://vercel.com/docs/environment-variables |
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses critical trust issues and improves user engagement through three main improvements: fixing mathematically incorrect Life Path numerology calculations, adding session persistence to prevent form data loss, and implementing analytics infrastructure with daily personalized micro-insights.
- Life Path calculations now correctly reduce each date component (day, month, year) individually before summing, fixing a core calculation error
- Session persistence prevents user drop-off by auto-saving birth data form inputs to localStorage
- New analytics system tracks user funnel events with anonymous session support, and daily micro-insights provide personalized content based on moon phases and user profiles
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase-schema.sql | Adds client_analytics_events table with anonymous-friendly RLS policy and performance indexes |
| src/utils/numerology.js | Fixes Life Path calculation to reduce date components individually; improves code formatting and adds radix parameter to parseInt |
| src/utils/dailyGuidance.js | New utility providing moon phase calculations and personalized daily/weekly micro-insights |
| src/utils/analytics.js | New analytics tracking system with automatic table discovery and session ID management |
| src/pages/Home.jsx | Integrates analytics tracking for blueprint generation events and updates table references to profiles/spiritual_reports |
| src/pages/Dashboard.jsx | Adds daily guidance card displaying personalized micro-insights with copy functionality |
| src/lib/supabase.js | Adds isSupabaseConfigured flag and console warning when environment variables are missing |
| src/components/BirthDataForm.jsx | Implements localStorage-based session persistence to prevent data loss during form completion |
| src/components/CosmicQuizFlow-themed.jsx | Fixes Life Path calculation to match corrected algorithm |
| src/anulunar_intake_with_cosmic_logo.html | Updates Life Path calculation in standalone HTML file |
| src/anulunar_cosmic_quiz_flow.html | Updates Life Path calculation in quiz flow HTML file |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await supabase.from('profiles').upsert( | ||
| [ | ||
| { | ||
| user_id: user.id, | ||
| birth_data: reportData.personalInfo, | ||
| report_data: reportData, | ||
| created_at: new Date().toISOString() | ||
| id: user.id, | ||
| email: user.email, | ||
| first_name: reportData?.personalInfo?.name?.split(' ')?.[0] || user?.user_metadata?.first_name || null, | ||
| last_name: | ||
| reportData?.personalInfo?.name?.split(' ')?.slice(1)?.join(' ') || | ||
| user?.user_metadata?.last_name || | ||
| null | ||
| } | ||
| ]) | ||
| ], | ||
| { onConflict: 'id' } | ||
| ) |
There was a problem hiding this comment.
The upsert operation on the profiles table may fail silently. The error from this operation is not captured or logged, which could lead to the spiritual_reports insert succeeding while the profile creation fails. This creates an inconsistent state. Consider capturing the error from the profiles upsert and handling it appropriately.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| export const getMoonPhase = (date = new Date()) => { | ||
| const diffMs = date.getTime() - KNOWN_NEW_MOON.getTime() | ||
| const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) | ||
| const phase = ((diffDays % LUNAR_CYCLE_DAYS) / LUNAR_CYCLE_DAYS + 1) % 1 | ||
| const phaseIndex = Math.floor(phase * 8) % 8 | ||
|
|
||
| return { | ||
| name: PHASES[phaseIndex], | ||
| lunarDay: Math.floor(phase * LUNAR_CYCLE_DAYS) + 1 | ||
| } | ||
| } | ||
|
|
||
| export const getDailyMicroInsight = ( | ||
| { | ||
| firstName, | ||
| celticMoonSign, | ||
| sunSign, | ||
| lifePath | ||
| } = {}, | ||
| date = new Date() | ||
| ) => { | ||
| const { name: phaseName } = getMoonPhase(date) | ||
| const seed = hashToInt(`${date.toISOString().slice(0, 10)}|${celticMoonSign}|${sunSign}|${lifePath}`) | ||
|
|
||
| const openers = [ | ||
| 'Today asks for honesty, not speed.', | ||
| 'Today is quieter than it looks.', | ||
| 'Today is a threshold day.', | ||
| 'Today wants one clean choice.' | ||
| ] | ||
|
|
||
| const phaseLines = { | ||
| 'New Moon': [ | ||
| 'Start small. Let the first step be private.', | ||
| 'Plant the intention before you explain it.', | ||
| 'Name what you want, then protect it.' | ||
| ], | ||
| 'Waxing Crescent': [ | ||
| 'Follow the thread that keeps returning.', | ||
| 'Try it once, gently. That is enough.', | ||
| 'Let curiosity lead. Perfection can wait.' | ||
| ], | ||
| 'First Quarter': [ | ||
| 'Choose one boundary and hold it.', | ||
| 'Decide, then stop re-deciding.', | ||
| 'Do the brave thing in a small way.' | ||
| ], | ||
| 'Waxing Gibbous': [ | ||
| 'Refine, don’t restart.', | ||
| 'Make it clearer, not bigger.', | ||
| 'Ask for what would make this easier.' | ||
| ], | ||
| 'Full Moon': [ | ||
| 'Let what’s true be seen.', | ||
| 'Release what you’ve been carrying alone.', | ||
| 'Celebrate the progress you kept minimizing.' | ||
| ], | ||
| 'Waning Gibbous': [ | ||
| 'Share what you learned. Keep it simple.', | ||
| 'Forgive the version of you that didn’t know yet.', | ||
| 'Close a loop that’s been draining you.' | ||
| ], | ||
| 'Last Quarter': [ | ||
| 'Cut what’s noisy. Keep what’s real.', | ||
| 'Finish one thing you’ve been avoiding.', | ||
| 'Let “no” be devotional today.' | ||
| ], | ||
| 'Waning Crescent': [ | ||
| 'Rest is part of the work.', | ||
| 'Let the day be softer than your standards.', | ||
| 'Make space. Something wants to arrive.' | ||
| ] | ||
| } | ||
|
|
||
| const identityLines = [ | ||
| celticMoonSign ? `Your Celtic Moon (${celticMoonSign}) is awake in the background.` : null, | ||
| sunSign ? `Your Sun (${sunSign}) wants you to stay true to your center.` : null, | ||
| lifePath ? `Life Path ${lifePath}: don’t abandon the lesson for the shortcut.` : null | ||
| ].filter(Boolean) | ||
|
|
||
| const addressedName = firstName ? `${firstName}, ` : '' | ||
| const identity = identityLines.length ? ` ${pick(identityLines, seed + 7)}` : '' | ||
|
|
||
| return `${addressedName}${pick(openers, seed)} ${pick(phaseLines[phaseName] || phaseLines['New Moon'], seed + 3)}${identity}` | ||
| } | ||
|
|
||
| export const getWeeklyTheme = ({ celticMoonSign } = {}, date = new Date()) => { | ||
| const { name: phaseName } = getMoonPhase(date) | ||
| const seed = hashToInt(`${date.toISOString().slice(0, 10)}|weekly|${celticMoonSign}`) | ||
|
|
||
| const themesByPhase = { | ||
| 'New Moon': ['Begin again', 'Private intention', 'Clean slate'], | ||
| 'Waxing Crescent': ['Gentle momentum', 'Trust the thread', 'Small brave steps'], | ||
| 'First Quarter': ['Boundaries', 'Decision', 'Courage in practice'], | ||
| 'Waxing Gibbous': ['Refinement', 'Clarity', 'Preparation'], | ||
| 'Full Moon': ['Revelation', 'Release', 'Celebration'], | ||
| 'Waning Gibbous': ['Integration', 'Gratitude', 'Teach what you learned'], | ||
| 'Last Quarter': ['Editing', 'Completion', 'Truth over noise'], | ||
| 'Waning Crescent': ['Restoration', 'Surrender', 'Quiet closure'] | ||
| } | ||
|
|
||
| const theme = pick(themesByPhase[phaseName] || themesByPhase['New Moon'], seed) | ||
| return { phaseName, theme } | ||
| } | ||
|
|
There was a problem hiding this comment.
The getMoonPhase, getDailyMicroInsight, and getWeeklyTheme functions lack documentation explaining their parameters, return values, and the algorithms used. For a utility that generates user-facing content based on astronomical calculations, this documentation would help future maintainers understand the logic, especially the somewhat complex phase calculation and the seeded randomization approach.
|
|
||
| for (const table of candidates) { | ||
| const { error } = await supabase.from(table).insert([payload]) | ||
| if (!error) { | ||
| cachePreferredTable(table) | ||
| return | ||
| } | ||
| } |
There was a problem hiding this comment.
The trackEvent function iterates through multiple table candidates on every failed insert attempt, which could result in multiple unnecessary database calls if the correct table isn't found early in the list. While this is mitigated by caching the successful table name, the first call from each session will still make potentially multiple failed attempts. Consider logging or monitoring these failures to identify when the schema doesn't match expectations, or fail faster after the first attempt if the table truly doesn't exist.
| for (const table of candidates) { | |
| const { error } = await supabase.from(table).insert([payload]) | |
| if (!error) { | |
| cachePreferredTable(table) | |
| return | |
| } | |
| } | |
| const errors = [] | |
| for (const table of candidates) { | |
| const { error } = await supabase.from(table).insert([payload]) | |
| if (!error) { | |
| cachePreferredTable(table) | |
| return | |
| } | |
| // Collect errors so schema or configuration issues are visible in development. | |
| errors.push({ | |
| table, | |
| message: error?.message, | |
| code: error?.code | |
| }) | |
| } | |
| if (errors.length && import.meta?.env?.DEV) { | |
| console.warn('[analytics] Failed to insert event into any candidate table', { | |
| event: payload, | |
| attempts: errors | |
| }) | |
| } |
| await trackEvent('blueprint_generate_failed', { | ||
| message: error?.message | ||
| }) |
There was a problem hiding this comment.
The trackEvent calls in the catch block (lines 44-46 and 90-92) are not awaited properly in the error flow. If trackEvent throws an error or rejects, it could mask the original error or create unhandled promise rejections. While the function is designed to never throw (due to internal try-catch), explicitly handling these analytics calls with try-catch or Promise.catch() would make the error handling more defensive and prevent potential issues.
| export const getMoonPhase = (date = new Date()) => { | ||
| const diffMs = date.getTime() - KNOWN_NEW_MOON.getTime() | ||
| const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) | ||
| const phase = ((diffDays % LUNAR_CYCLE_DAYS) / LUNAR_CYCLE_DAYS + 1) % 1 |
There was a problem hiding this comment.
The modulo operation on line 27 is incorrect for moon phase calculation. When diffDays is negative (for dates before the reference new moon), the modulo will return a negative result, which will produce incorrect phase calculations. The current calculation (diffDays % LUNAR_CYCLE_DAYS) should use a proper modulo operation that handles negative numbers correctly, such as ((diffDays % LUNAR_CYCLE_DAYS) + LUNAR_CYCLE_DAYS) % LUNAR_CYCLE_DAYS.
| const phase = ((diffDays % LUNAR_CYCLE_DAYS) / LUNAR_CYCLE_DAYS + 1) % 1 | |
| const phase = ((((diffDays % LUNAR_CYCLE_DAYS) + LUNAR_CYCLE_DAYS) % LUNAR_CYCLE_DAYS) / LUNAR_CYCLE_DAYS + 1) % 1 |
| useEffect(() => { | ||
| if (!hasHydratedRef.current) return | ||
| try { | ||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(formData)) | ||
| } catch { | ||
| // ignore quota/blocked storage | ||
| } | ||
| }, [formData]) |
There was a problem hiding this comment.
Storing personally identifiable information (PII) including firstName, lastName, birthDate, birthTime, and birthPlace in localStorage without encryption poses a privacy risk. This sensitive data persists in the browser and could be accessed by browser extensions or other scripts. Consider either: 1) warning users about the persistence, 2) using sessionStorage for shorter-lived persistence, or 3) implementing encryption before storage.
| -- (No SELECT policy is defined; reads stay locked down by default.) | ||
| CREATE POLICY "Anyone can insert analytics events" | ||
| ON client_analytics_events FOR INSERT | ||
| WITH CHECK (true); |
There was a problem hiding this comment.
The RLS policy allows unrestricted inserts to the client_analytics_events table with WITH CHECK (true). While this enables anonymous tracking, it creates a potential for abuse through unlimited event insertion. Consider adding rate limiting at the application level or adding constraints such as requiring a valid session_id format or limiting the size of the properties JSONB field to prevent potential DoS attacks or data storage abuse.
| WITH CHECK (true); | |
| WITH CHECK ( | |
| -- Require either an authenticated user or a non-null session identifier | |
| (auth.uid() IS NOT NULL OR session_id IS NOT NULL) | |
| AND | |
| -- If provided, session_id must look like a reasonable token | |
| (session_id IS NULL OR session_id ~ '^[A-Za-z0-9_-]{16,128}$') | |
| AND | |
| -- Limit properties to a reasonably sized JSON object to avoid abuse | |
| (properties IS NULL OR jsonb_typeof(properties) = 'object') | |
| AND | |
| (properties IS NULL OR pg_column_size(properties) <= 4096) | |
| ); |
| const todayMessage = getDailyMicroInsight( | ||
| { firstName, celticMoonSign, sunSign, lifePath }, | ||
| new Date() | ||
| ) | ||
| const { phaseName, theme } = getWeeklyTheme({ celticMoonSign }, new Date()) | ||
| const { lunarDay } = getMoonPhase(new Date()) | ||
|
|
||
| const handleCopy = async () => { | ||
| try { | ||
| await navigator.clipboard.writeText(todayMessage) |
There was a problem hiding this comment.
The Dashboard component attempts to access nested properties from latestReport without proper null checking. If reports[0] exists but synthesized_content and report_data are both null/undefined, the subsequent property accesses (celticMoonSign?.sign, astrology?.sunSign, etc.) will work due to optional chaining, but this creates a scenario where getDailyMicroInsight is called with all undefined values, which may not be the intended behavior. Consider adding a check to ensure latestReport has valid data before using it.
| const todayMessage = getDailyMicroInsight( | |
| { firstName, celticMoonSign, sunSign, lifePath }, | |
| new Date() | |
| ) | |
| const { phaseName, theme } = getWeeklyTheme({ celticMoonSign }, new Date()) | |
| const { lunarDay } = getMoonPhase(new Date()) | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(todayMessage) | |
| // Determine if we have any meaningful report data before generating insights | |
| const hasValidReportData = | |
| !!latestReport && | |
| !!( | |
| latestReport.celticMoonSign || | |
| latestReport.astrology || | |
| latestReport.numerology | |
| ) | |
| const todayMessage = hasValidReportData | |
| ? getDailyMicroInsight( | |
| { firstName, celticMoonSign, sunSign, lifePath }, | |
| new Date() | |
| ) | |
| : null | |
| const { phaseName, theme } = hasValidReportData | |
| ? getWeeklyTheme({ celticMoonSign }, new Date()) | |
| : { phaseName: null, theme: null } | |
| const { lunarDay } = getMoonPhase(new Date()) | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(todayMessage || '') |
| birthPlace: '' | ||
| }) | ||
| const [errors, setErrors] = useState({}) | ||
| const hasHydratedRef = useRef(false) |
There was a problem hiding this comment.
The variable name 'hasHydratedRef' could be more descriptive. The term 'hydrated' is commonly used in React contexts for server-side rendering, but here it's simply tracking whether the initial localStorage read has completed. Consider renaming to 'hasLoadedFromStorageRef' or 'isInitializedRef' for clarity.
| first_name: reportData?.personalInfo?.name?.split(' ')?.[0] || user?.user_metadata?.first_name || null, | ||
| last_name: | ||
| reportData?.personalInfo?.name?.split(' ')?.slice(1)?.join(' ') || | ||
| user?.user_metadata?.last_name || | ||
| null |
There was a problem hiding this comment.
The name parsing logic to extract first_name and last_name from reportData.personalInfo.name is fragile and could fail if the name format is unexpected (e.g., single name, multiple middle names, special characters). This could result in incorrect profile data being stored. Consider using explicit firstName/lastName fields from the form data rather than parsing the combined name string.
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
Co-authored-by: mcquillan.melissa <mcquillan.melissa@gmail.com>
|
@mistycmissy I've opened a new pull request, #6, to work on those changes. Once the pull request is ready, I'll request review from you. |
Fix Life Path calculation errors, add session persistence to the birth data form, and implement analytics and daily micro-insights to improve user trust and engagement.
The Life Path calculation was mathematically incorrect, which was trust-critical. Session persistence prevents data loss during the quiz, addressing a key drop-off point. New analytics provide essential visibility into user funnels, and the introduction of daily micro-insights aligns with the "Hyper-Personalization That Feels Alive" strategy to foster user habit and retention. Supabase table names were also updated in the frontend to align with the larger, active schema.