// Timing utility functions export function calculateElapsedHours(createdAt: string): number { const now = Date.now() const created = new Date(createdAt).getTime() return (now - created) / (1000 * 60 * 60) } export function getAvailableStage(review: any): 'stage2' | 'stage3' | null { const elapsed = calculateElapsedHours(review.createdAt) // Already completed (has all Stage 3 fields) if (review.endRating && review.complexity && review.longevity && review.overallRating) { return null } // Stage 2: 1.5-4 hours AND no drydownRating yet if (elapsed >= 1.5 && elapsed <= 4 && !review.drydownRating) { return 'stage2' } // Stage 3: 4+ hours AND no endRating yet if (elapsed >= 4 && !review.endRating) { return 'stage3' } return null } export function isReviewEditable(review: any): boolean { const elapsed = calculateElapsedHours(review.createdAt) return elapsed < 24 } export function isReviewCompleted(review: any): boolean { return !!(review.endRating && review.complexity && review.longevity && review.overallRating) } import type { IdealScores, PropertyImportance } from '../data/preferenceDefinitions' import { BASE_PROPERTY_IMPORTANCE, DEFAULT_PREFERENCES } from '../data/preferenceDefinitions' /** * Convert user preferences to ideal scores. * Maps the 4 preference values to the user's "perfect" ratings. */ function preferencesToIdealScores(preferences: UserPreferencesForScoring): IdealScores { return { // Quality ratings: always 5 (everyone wants best quality) openingRating: 5, drydownRating: 5, endRating: 5, overallRating: 5, // Performance: based on presenceStyle (1-5) // Statement: "I prefer close-to-skin" (1) vs "I like to announce myself" (5) openingProjection: preferences.presenceStyle ?? 3, midProjection: preferences.presenceStyle ?? 3, sillage: preferences.presenceStyle ?? 3, // Longevity: based on longevityPriority (1-5) // Statement: "A few hours is enough" (1) vs "All-day is essential" (5) longevity: preferences.longevityPriority ?? 3, // Complexity: based on complexityPreference (1-5) // Statement: "I prefer simple scents" (1) vs "I love intricate compositions" (5) complexity: preferences.complexityPreference ?? 3, } } /** * Adjust property importance based on scoringApproach preference. * Instinct-driven users value gut feeling more, analytical users value technical details more. */ function adjustImportanceForScoringApproach( scoringApproach: number ): PropertyImportance { const adjusted: PropertyImportance = { midProjection: BASE_PROPERTY_IMPORTANCE.midProjection, openingProjection: BASE_PROPERTY_IMPORTANCE.openingProjection, complexity: BASE_PROPERTY_IMPORTANCE.complexity, longevity: BASE_PROPERTY_IMPORTANCE.longevity, sillage: BASE_PROPERTY_IMPORTANCE.sillage, drydownRating: BASE_PROPERTY_IMPORTANCE.drydownRating, openingRating: BASE_PROPERTY_IMPORTANCE.openingRating, endRating: BASE_PROPERTY_IMPORTANCE.endRating, overallRating: BASE_PROPERTY_IMPORTANCE.overallRating, } if (scoringApproach <= 2) { // Instinct-driven (1-2): boost gut feeling importance adjusted.overallRating *= 1.3 } else if (scoringApproach >= 4) { // Analytical (4-5): boost technical properties adjusted.complexity *= 1.2 adjusted.longevity *= 1.2 adjusted.sillage *= 1.1 adjusted.midProjection *= 1.1 adjusted.openingProjection *= 1.1 } // scoringApproach = 3 (balanced): no adjustments return adjusted } /** * Calculate review score based on distance from user's ideal scores. * * Flow: * 1. Convert user preferences → ideal scores * 2. Adjust property importance based on scoringApproach * 3. For each property: calculate distance from ideal * 4. Apply power 1.5 curve to get fit score * 5. Weight by adjusted importance * 6. Sum and normalize to 0-5 scale * * @param review - The review record * @param preferences - User preferences to generate ideal scores from * @returns Score from 0-5 (rounded to 3 decimal places) */ export function calculateIdealBasedScore(review: any, preferences: UserPreferencesForScoring): number { const MAX_DISTANCE = 4 // Scale is 1-5, max difference is 4 // Step 1: Convert preferences to ideal scores const idealScores = preferencesToIdealScores(preferences) // Step 2: Adjust importance based on scoringApproach const importance = adjustImportanceForScoringApproach( preferences.scoringApproach ?? 3 ) let totalWeightedFit = 0 let totalImportance = 0 /** * Add contribution for a single property */ function addProperty( actualValue: number | undefined, idealValue: number, propertyImportance: number ): void { if (actualValue === undefined) return // Skip missing values // Step 3: Calculate distance and normalize (0-1) const distance = Math.abs(actualValue - idealValue) const normalizedDistance = distance / MAX_DISTANCE // Step 4: Apply power 1.5 curve const fit = Math.pow(1 - normalizedDistance, 1.5) // Step 5: Weight by property importance const weightedFit = fit * propertyImportance totalWeightedFit += weightedFit totalImportance += propertyImportance } // Process all 9 properties addProperty(review.openingRating, idealScores.openingRating, importance.openingRating) addProperty(review.openingProjection, idealScores.openingProjection, importance.openingProjection) addProperty(review.drydownRating, idealScores.drydownRating, importance.drydownRating) addProperty(review.midProjection, idealScores.midProjection, importance.midProjection) addProperty(review.sillage, idealScores.sillage, importance.sillage) addProperty(review.endRating, idealScores.endRating, importance.endRating) addProperty(review.complexity, idealScores.complexity, importance.complexity) addProperty(review.longevity, idealScores.longevity, importance.longevity) addProperty(review.overallRating, idealScores.overallRating, importance.overallRating) // Edge case: no properties present if (totalImportance === 0) return 0 // Step 6: Calculate final score (0-5 scale) const score = (totalWeightedFit / totalImportance) * 5 // Round to 3 decimal places return Math.round(score * 1000) / 1000 } /** * Legacy function: Calculate weighted score using default equal weights. * Used for storing original reviewer scores in weightedScore field. * * @deprecated Use calculateIdealBasedScore for personalized scoring */ export function calculateWeightedScore(review: any): number { // Use ideal-based scoring with default preferences const defaultPreferences: UserPreferencesForScoring = { presenceStyle: DEFAULT_PREFERENCES.presenceStyle, longevityPriority: DEFAULT_PREFERENCES.longevityPriority, complexityPreference: DEFAULT_PREFERENCES.complexityPreference, scoringApproach: DEFAULT_PREFERENCES.scoringApproach, } return calculateIdealBasedScore(review, defaultPreferences) } export function encodeWeightedScore(score: number): number { return Math.round(score * 1000) } export function decodeWeightedScore(encoded: number): number { return encoded / 1000 } export function categorizeReviews(reviews: Array<{ uri: string; value: any }>) { const active: typeof reviews = [] const past: typeof reviews = [] for (const review of reviews) { if (isReviewEditable(review.value)) { active.push(review) } else { past.push(review) } } // Sort active reviews by most recently created/updated active.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) // Sort past reviews by most recently created past.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) return { active, past } } export function getReviewActionState(reviewValue: any): { action: 'stage2' | 'stage3' | null, hint: string | null } { const elapsed = calculateElapsedHours(reviewValue.createdAt) // After 24 hours, can only edit text and fragrance if (elapsed >= 24) { return { action: null, hint: 'Edit text/fragrance' } } // Stage 3 logic (end rating) if (reviewValue.drydownRating && !reviewValue.endRating) { if (elapsed >= 4) { return { action: 'stage3', hint: 'Optional: Add Final Thoughts' } } else { const minsToStage3 = Math.ceil((4 - elapsed) * 60) return { action: null, hint: `Final notes available in ${minsToStage3}m` } } } // Stage 2 logic (drydown) if (!reviewValue.drydownRating) { if (elapsed >= 1.5) { return { action: 'stage2', hint: 'Optional: Add Heart Notes' } } else { const minsToStage2 = Math.ceil((1.5 - elapsed) * 60) return { action: null, hint: `Heart notes available in ${minsToStage2}m` } } } // If both are completed, no action, but still editable return { action: null, hint: 'Review complete' } } /** * Get the display score for a review, handling all cases * Returns the calculated weighted score whether it's stored or needs to be computed */ export function getReviewDisplayScore(review: any): number { if (review.weightedScore != null) { return decodeWeightedScore(review.weightedScore) } // Fallback: calculate from available ratings return calculateWeightedScore(review) } export type ScoreLens = 'theirs' | 'mine' export interface UserPreferencesForScoring { presenceStyle?: number longevityPriority?: number complexityPreference?: number scoringApproach?: number scoreLens?: ScoreLens } /** * Get the display score for a review with optional personalized scoring. * * @param review - The review record * @param userPreferences - Optional user preferences for personalized scoring * @param isOwnReview - Whether this review belongs to the current user * @returns Object with score and whether it's personalized */ export function getPersonalizedScore( review: any, userPreferences?: UserPreferencesForScoring, _isOwnReview: boolean = false ): { score: number; isPersonalized: boolean } { // No preferences available - use stored score if (!userPreferences) { return { score: getReviewDisplayScore(review), isPersonalized: false, } } // User wants original scores (theirs or their own original) if (userPreferences.scoreLens === 'theirs') { return { score: getReviewDisplayScore(review), isPersonalized: false, } } // User wants scores through their current preference lens // This applies to BOTH own reviews AND others' reviews return { score: calculateIdealBasedScore(review, userPreferences), isPersonalized: true, } } /** * Format notification time window for a specific stage * Returns formatted text like "in 1.5-4 hours" or specific times */ export function formatNotificationWindow(createdAt: string, stage: 'stage2' | 'stage3'): string { const created = new Date(createdAt) if (stage === 'stage2') { const start = new Date(created.getTime() + 1.5 * 60 * 60 * 1000) const end = new Date(created.getTime() + 4 * 60 * 60 * 1000) const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) const endTime = end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) return `${startTime} - ${endTime}` } else { // Stage 3: starts at 4 hours, no end time const start = new Date(created.getTime() + 4 * 60 * 60 * 1000) const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) return `after ${startTime}` } }