a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere.
drydown.social
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}