a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social
at main 337 lines 12 kB view raw
1// Timing utility functions 2export function calculateElapsedHours(createdAt: string): number { 3 const now = Date.now() 4 const created = new Date(createdAt).getTime() 5 return (now - created) / (1000 * 60 * 60) 6} 7 8export function getAvailableStage(review: any): 'stage2' | 'stage3' | null { 9 const elapsed = calculateElapsedHours(review.createdAt) 10 11 // Already completed (has all Stage 3 fields) 12 if (review.endRating && review.complexity && review.longevity && review.overallRating) { 13 return null 14 } 15 16 // Stage 2: 1.5-4 hours AND no drydownRating yet 17 if (elapsed >= 1.5 && elapsed <= 4 && !review.drydownRating) { 18 return 'stage2' 19 } 20 21 // Stage 3: 4+ hours AND no endRating yet 22 if (elapsed >= 4 && !review.endRating) { 23 return 'stage3' 24 } 25 26 return null 27} 28 29export function isReviewEditable(review: any): boolean { 30 const elapsed = calculateElapsedHours(review.createdAt) 31 return elapsed < 24 32} 33 34export function isReviewCompleted(review: any): boolean { 35 return !!(review.endRating && review.complexity && review.longevity && review.overallRating) 36} 37 38import type { IdealScores, PropertyImportance } from '../data/preferenceDefinitions' 39import { BASE_PROPERTY_IMPORTANCE, DEFAULT_PREFERENCES } from '../data/preferenceDefinitions' 40 41/** 42 * Convert user preferences to ideal scores. 43 * Maps the 4 preference values to the user's "perfect" ratings. 44 */ 45function preferencesToIdealScores(preferences: UserPreferencesForScoring): IdealScores { 46 return { 47 // Quality ratings: always 5 (everyone wants best quality) 48 openingRating: 5, 49 drydownRating: 5, 50 endRating: 5, 51 overallRating: 5, 52 53 // Performance: based on presenceStyle (1-5) 54 // Statement: "I prefer close-to-skin" (1) vs "I like to announce myself" (5) 55 openingProjection: preferences.presenceStyle ?? 3, 56 midProjection: preferences.presenceStyle ?? 3, 57 sillage: preferences.presenceStyle ?? 3, 58 59 // Longevity: based on longevityPriority (1-5) 60 // Statement: "A few hours is enough" (1) vs "All-day is essential" (5) 61 longevity: preferences.longevityPriority ?? 3, 62 63 // Complexity: based on complexityPreference (1-5) 64 // Statement: "I prefer simple scents" (1) vs "I love intricate compositions" (5) 65 complexity: preferences.complexityPreference ?? 3, 66 } 67} 68 69/** 70 * Adjust property importance based on scoringApproach preference. 71 * Instinct-driven users value gut feeling more, analytical users value technical details more. 72 */ 73function adjustImportanceForScoringApproach( 74 scoringApproach: number 75): PropertyImportance { 76 const adjusted: PropertyImportance = { 77 midProjection: BASE_PROPERTY_IMPORTANCE.midProjection, 78 openingProjection: BASE_PROPERTY_IMPORTANCE.openingProjection, 79 complexity: BASE_PROPERTY_IMPORTANCE.complexity, 80 longevity: BASE_PROPERTY_IMPORTANCE.longevity, 81 sillage: BASE_PROPERTY_IMPORTANCE.sillage, 82 drydownRating: BASE_PROPERTY_IMPORTANCE.drydownRating, 83 openingRating: BASE_PROPERTY_IMPORTANCE.openingRating, 84 endRating: BASE_PROPERTY_IMPORTANCE.endRating, 85 overallRating: BASE_PROPERTY_IMPORTANCE.overallRating, 86 } 87 88 if (scoringApproach <= 2) { 89 // Instinct-driven (1-2): boost gut feeling importance 90 adjusted.overallRating *= 1.3 91 } else if (scoringApproach >= 4) { 92 // Analytical (4-5): boost technical properties 93 adjusted.complexity *= 1.2 94 adjusted.longevity *= 1.2 95 adjusted.sillage *= 1.1 96 adjusted.midProjection *= 1.1 97 adjusted.openingProjection *= 1.1 98 } 99 // scoringApproach = 3 (balanced): no adjustments 100 101 return adjusted 102} 103 104/** 105 * Calculate review score based on distance from user's ideal scores. 106 * 107 * Flow: 108 * 1. Convert user preferences → ideal scores 109 * 2. Adjust property importance based on scoringApproach 110 * 3. For each property: calculate distance from ideal 111 * 4. Apply power 1.5 curve to get fit score 112 * 5. Weight by adjusted importance 113 * 6. Sum and normalize to 0-5 scale 114 * 115 * @param review - The review record 116 * @param preferences - User preferences to generate ideal scores from 117 * @returns Score from 0-5 (rounded to 3 decimal places) 118 */ 119export function calculateIdealBasedScore(review: any, preferences: UserPreferencesForScoring): number { 120 const MAX_DISTANCE = 4 // Scale is 1-5, max difference is 4 121 122 // Step 1: Convert preferences to ideal scores 123 const idealScores = preferencesToIdealScores(preferences) 124 125 // Step 2: Adjust importance based on scoringApproach 126 const importance = adjustImportanceForScoringApproach( 127 preferences.scoringApproach ?? 3 128 ) 129 130 let totalWeightedFit = 0 131 let totalImportance = 0 132 133 /** 134 * Add contribution for a single property 135 */ 136 function addProperty( 137 actualValue: number | undefined, 138 idealValue: number, 139 propertyImportance: number 140 ): void { 141 if (actualValue === undefined) return // Skip missing values 142 143 // Step 3: Calculate distance and normalize (0-1) 144 const distance = Math.abs(actualValue - idealValue) 145 const normalizedDistance = distance / MAX_DISTANCE 146 147 // Step 4: Apply power 1.5 curve 148 const fit = Math.pow(1 - normalizedDistance, 1.5) 149 150 // Step 5: Weight by property importance 151 const weightedFit = fit * propertyImportance 152 153 totalWeightedFit += weightedFit 154 totalImportance += propertyImportance 155 } 156 157 // Process all 9 properties 158 addProperty(review.openingRating, idealScores.openingRating, importance.openingRating) 159 addProperty(review.openingProjection, idealScores.openingProjection, importance.openingProjection) 160 addProperty(review.drydownRating, idealScores.drydownRating, importance.drydownRating) 161 addProperty(review.midProjection, idealScores.midProjection, importance.midProjection) 162 addProperty(review.sillage, idealScores.sillage, importance.sillage) 163 addProperty(review.endRating, idealScores.endRating, importance.endRating) 164 addProperty(review.complexity, idealScores.complexity, importance.complexity) 165 addProperty(review.longevity, idealScores.longevity, importance.longevity) 166 addProperty(review.overallRating, idealScores.overallRating, importance.overallRating) 167 168 // Edge case: no properties present 169 if (totalImportance === 0) return 0 170 171 // Step 6: Calculate final score (0-5 scale) 172 const score = (totalWeightedFit / totalImportance) * 5 173 174 // Round to 3 decimal places 175 return Math.round(score * 1000) / 1000 176} 177 178/** 179 * Legacy function: Calculate weighted score using default equal weights. 180 * Used for storing original reviewer scores in weightedScore field. 181 * 182 * @deprecated Use calculateIdealBasedScore for personalized scoring 183 */ 184export function calculateWeightedScore(review: any): number { 185 // Use ideal-based scoring with default preferences 186 const defaultPreferences: UserPreferencesForScoring = { 187 presenceStyle: DEFAULT_PREFERENCES.presenceStyle, 188 longevityPriority: DEFAULT_PREFERENCES.longevityPriority, 189 complexityPreference: DEFAULT_PREFERENCES.complexityPreference, 190 scoringApproach: DEFAULT_PREFERENCES.scoringApproach, 191 } 192 193 return calculateIdealBasedScore(review, defaultPreferences) 194} 195 196export function encodeWeightedScore(score: number): number { 197 return Math.round(score * 1000) 198} 199 200export function decodeWeightedScore(encoded: number): number { 201 return encoded / 1000 202} 203 204export function categorizeReviews(reviews: Array<{ uri: string; value: any }>) { 205 const active: typeof reviews = [] 206 const past: typeof reviews = [] 207 208 for (const review of reviews) { 209 if (isReviewEditable(review.value)) { 210 active.push(review) 211 } else { 212 past.push(review) 213 } 214 } 215 216 // Sort active reviews by most recently created/updated 217 active.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) 218 // Sort past reviews by most recently created 219 past.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) 220 221 return { active, past } 222} 223 224export function getReviewActionState(reviewValue: any): { action: 'stage2' | 'stage3' | null, hint: string | null } { 225 const elapsed = calculateElapsedHours(reviewValue.createdAt) 226 227 // After 24 hours, can only edit text and fragrance 228 if (elapsed >= 24) { 229 return { action: null, hint: 'Edit text/fragrance' } 230 } 231 232 // Stage 3 logic (end rating) 233 if (reviewValue.drydownRating && !reviewValue.endRating) { 234 if (elapsed >= 4) { 235 return { action: 'stage3', hint: 'Optional: Add Final Thoughts' } 236 } else { 237 const minsToStage3 = Math.ceil((4 - elapsed) * 60) 238 return { action: null, hint: `Final notes available in ${minsToStage3}m` } 239 } 240 } 241 242 // Stage 2 logic (drydown) 243 if (!reviewValue.drydownRating) { 244 if (elapsed >= 1.5) { 245 return { action: 'stage2', hint: 'Optional: Add Heart Notes' } 246 } else { 247 const minsToStage2 = Math.ceil((1.5 - elapsed) * 60) 248 return { action: null, hint: `Heart notes available in ${minsToStage2}m` } 249 } 250 } 251 252 // If both are completed, no action, but still editable 253 return { action: null, hint: 'Review complete' } 254} 255 256/** 257 * Get the display score for a review, handling all cases 258 * Returns the calculated weighted score whether it's stored or needs to be computed 259 */ 260export function getReviewDisplayScore(review: any): number { 261 if (review.weightedScore != null) { 262 return decodeWeightedScore(review.weightedScore) 263 } 264 // Fallback: calculate from available ratings 265 return calculateWeightedScore(review) 266} 267 268export type ScoreLens = 'theirs' | 'mine' 269 270export interface UserPreferencesForScoring { 271 presenceStyle?: number 272 longevityPriority?: number 273 complexityPreference?: number 274 scoringApproach?: number 275 scoreLens?: ScoreLens 276} 277 278/** 279 * Get the display score for a review with optional personalized scoring. 280 * 281 * @param review - The review record 282 * @param userPreferences - Optional user preferences for personalized scoring 283 * @param isOwnReview - Whether this review belongs to the current user 284 * @returns Object with score and whether it's personalized 285 */ 286export function getPersonalizedScore( 287 review: any, 288 userPreferences?: UserPreferencesForScoring, 289 _isOwnReview: boolean = false 290): { score: number; isPersonalized: boolean } { 291 // No preferences available - use stored score 292 if (!userPreferences) { 293 return { 294 score: getReviewDisplayScore(review), 295 isPersonalized: false, 296 } 297 } 298 299 // User wants original scores (theirs or their own original) 300 if (userPreferences.scoreLens === 'theirs') { 301 return { 302 score: getReviewDisplayScore(review), 303 isPersonalized: false, 304 } 305 } 306 307 // User wants scores through their current preference lens 308 // This applies to BOTH own reviews AND others' reviews 309 return { 310 score: calculateIdealBasedScore(review, userPreferences), 311 isPersonalized: true, 312 } 313} 314 315/** 316 * Format notification time window for a specific stage 317 * Returns formatted text like "in 1.5-4 hours" or specific times 318 */ 319export function formatNotificationWindow(createdAt: string, stage: 'stage2' | 'stage3'): string { 320 const created = new Date(createdAt) 321 322 if (stage === 'stage2') { 323 const start = new Date(created.getTime() + 1.5 * 60 * 60 * 1000) 324 const end = new Date(created.getTime() + 4 * 60 * 60 * 1000) 325 326 const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 327 const endTime = end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 328 329 return `${startTime} - ${endTime}` 330 } else { 331 // Stage 3: starts at 4 hours, no end time 332 const start = new Date(created.getTime() + 4 * 60 * 60 * 1000) 333 const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 334 335 return `after ${startTime}` 336 } 337}