a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

Add edit review and dashboard features to the app

+722 -34
+40 -9
src/app.tsx
··· 5 5 import { initAuth, logout } from './auth' 6 6 import { LoginForm } from './components/LoginForm' 7 7 import { CreateReview } from './components/CreateReview' 8 + import { ReviewDashboard } from './components/ReviewDashboard' 9 + import { EditReview } from './components/EditReview' 8 10 import type { OAuthSession } from '@atproto/oauth-client-browser' 9 11 10 12 export function App() { 11 13 const [session, setSession] = useState<OAuthSession | null>(null) 12 14 const [isInitializing, setIsInitializing] = useState(true) 13 - const [view, setView] = useState<'home' | 'create-review'>('home') 15 + const [view, setView] = useState<'home' | 'create-review' | 'edit-review'>('home') 16 + const [editReviewUri, setEditReviewUri] = useState<string | null>(null) 17 + const [editReviewStage, setEditReviewStage] = useState<'stage2' | 'stage3' | null>(null) 14 18 15 19 useEffect(() => { 16 20 const initialize = async () => { ··· 52 56 } 53 57 } 54 58 59 + const handleCreateNew = () => { 60 + setView('create-review') 61 + } 62 + 63 + const handleEditReview = (uri: string, stage: 'stage2' | 'stage3') => { 64 + setEditReviewUri(uri) 65 + setEditReviewStage(stage) 66 + setView('edit-review') 67 + } 68 + 69 + const handleBackToDashboard = () => { 70 + setView('home') 71 + setEditReviewUri(null) 72 + setEditReviewStage(null) 73 + } 74 + 55 75 if (isInitializing) { 56 76 return <h1>Loading...</h1> 57 77 } ··· 76 96 <p>You are now signed in via OAuth.</p> 77 97 78 98 {view === 'home' ? ( 79 - <div style={{ display: 'flex', gap: '1rem', flexDirection: 'column', alignItems: 'center' }}> 80 - <button onClick={() => setView('create-review')}>Create New Review</button> 81 - <button onClick={handleLogout}>Sign Out</button> 82 - </div> 99 + <> 100 + <ReviewDashboard 101 + session={session} 102 + onCreateNew={handleCreateNew} 103 + /> 104 + <button onClick={handleLogout} style={{ marginTop: '2rem' }}>Sign Out</button> 105 + </> 106 + ) : view === 'create-review' ? ( 107 + <CreateReview 108 + session={session} 109 + onCancel={handleBackToDashboard} 110 + onSuccess={handleBackToDashboard} 111 + /> 83 112 ) : ( 84 - <CreateReview 85 - session={session} 86 - onCancel={() => setView('home')} 87 - onSuccess={() => setView('home')} 113 + <EditReview 114 + session={session} 115 + reviewUri={editReviewUri!} 116 + stage={editReviewStage!} 117 + onCancel={handleBackToDashboard} 118 + onSuccess={handleBackToDashboard} 88 119 /> 89 120 )} 90 121 </div>
+70 -25
src/components/CreateReview.tsx
··· 30 30 const [selectedFragranceUri, setSelectedFragranceUri] = useState<string>('') 31 31 32 32 // Review state 33 - const [rating, setRating] = useState<number>(0) 34 - const [text, setText] = useState('') 33 + const [openingRating, setOpeningRating] = useState<number>(0) 34 + const [openingProjection, setOpeningProjection] = useState<number>(0) 35 35 const [isSubmitting, setIsSubmitting] = useState(false) 36 36 37 37 // Client ··· 147 147 { repo: session.sub }, 148 148 { 149 149 fragrance: selectedFragranceUri, 150 - overallRating: rating, 151 - text: text, 150 + openingRating: openingRating, 151 + openingProjection: openingProjection, 152 152 createdAt: new Date().toISOString() 153 153 } 154 154 ) 155 - alert("Review created!") 156 155 onSuccess() 157 156 } catch (e) { 158 157 console.error("Failed to submit review", e) ··· 194 193 /> 195 194 {selectedFragranceUri && <div style={{ fontSize: '0.8rem', color: 'green', marginBottom: '1rem'}}>Fragrance selected</div>} 196 195 197 - <div style={{ marginBottom: '1rem' }}> 198 - <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>Rating (1-5)</label> 199 - <input 200 - type="number" 201 - min="1" 202 - max="5" 203 - value={rating} 204 - onInput={(e) => setRating(parseInt((e.target as HTMLInputElement).value))} 196 + {selectedFragranceUri && ( 197 + <div style={{ marginTop: '2rem', marginBottom: '1.5rem' }}> 198 + <h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' }}> 199 + First Impression 200 + </h3> 201 + <p style={{ fontSize: '0.9rem', color: '#666', margin: '0' }}> 202 + Rate these immediately after applying 203 + </p> 204 + </div> 205 + )} 206 + 207 + {selectedFragranceUri && ( 208 + <> 209 + <div style={{ marginBottom: '1rem' }}> 210 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 211 + Opening Rating (1-5) 212 + </label> 213 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 214 + How it smells immediately 215 + </p> 216 + <input 217 + type="number" 218 + min="1" 219 + max="5" 220 + value={openingRating || ''} 221 + onInput={(e) => { 222 + const val = parseInt((e.target as HTMLInputElement).value) 223 + setOpeningRating(isNaN(val) ? 0 : val) 224 + }} 205 225 style={{ width: '100%', padding: '0.5rem' }} 206 - /> 207 - </div> 226 + placeholder="Rate 1-5" 227 + /> 228 + </div> 208 229 209 - <div style={{ marginBottom: '1rem' }}> 210 - <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>Review Text</label> 211 - <textarea 212 - value={text} 213 - onInput={(e) => setText((e.target as HTMLInputElement).value)} 214 - style={{ width: '100%', padding: '0.5rem', minHeight: '100px' }} 215 - /> 216 - </div> 230 + <div style={{ marginBottom: '1rem' }}> 231 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 232 + Opening Projection (1-5) 233 + </label> 234 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 235 + Immediate scent bubble radius 236 + </p> 237 + <input 238 + type="number" 239 + min="1" 240 + max="5" 241 + value={openingProjection || ''} 242 + onInput={(e) => { 243 + const val = parseInt((e.target as HTMLInputElement).value) 244 + setOpeningProjection(isNaN(val) ? 0 : val) 245 + }} 246 + style={{ width: '100%', padding: '0.5rem' }} 247 + placeholder="Rate 1-5" 248 + /> 249 + </div> 250 + </> 251 + )} 217 252 218 - <button type="submit" disabled={isSubmitting || !selectedFragranceUri}> 219 - {isSubmitting ? 'Submitting...' : 'Submit Review'} 253 + <button 254 + type="submit" 255 + disabled={ 256 + isSubmitting || 257 + !selectedFragranceUri || 258 + openingRating < 1 || 259 + openingRating > 5 || 260 + openingProjection < 1 || 261 + openingProjection > 5 262 + } 263 + > 264 + {isSubmitting ? 'Starting Review...' : 'Start Reviewing'} 220 265 </button> 221 266 </form> 222 267
+289
src/components/EditReview.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { AtpBaseClient } from '../client/index' 3 + import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 5 + 6 + interface EditReviewProps { 7 + session: OAuthSession 8 + reviewUri: string 9 + stage: 'stage2' | 'stage3' 10 + onCancel: () => void 11 + onSuccess: () => void 12 + } 13 + 14 + export function EditReview({ session, reviewUri, stage, onCancel, onSuccess }: EditReviewProps) { 15 + const [review, setReview] = useState<any>(null) 16 + const [fragranceName, setFragranceName] = useState<string>('') 17 + const [isLoading, setIsLoading] = useState(true) 18 + const [isSubmitting, setIsSubmitting] = useState(false) 19 + 20 + // Stage 2 state 21 + const [drydownRating, setDrydownRating] = useState<number>(0) 22 + const [midProjection, setMidProjection] = useState<number>(0) 23 + const [sillage, setSillage] = useState<number>(0) 24 + 25 + // Stage 3 state 26 + const [endRating, setEndRating] = useState<number>(0) 27 + const [complexity, setComplexity] = useState<number>(0) 28 + const [longevity, setLongevity] = useState<number>(0) 29 + const [overallRating, setOverallRating] = useState<number>(0) 30 + const [text, setText] = useState<string>('') 31 + 32 + const [atp, setAtp] = useState<AtpBaseClient | null>(null) 33 + 34 + useEffect(() => { 35 + async function loadReview() { 36 + const baseClient = new AtpBaseClient({ 37 + did: session.did, 38 + fetchHandler: (url, init) => session.fetchHandler(url, init) 39 + }) 40 + setAtp(baseClient) 41 + 42 + const rkey = reviewUri.split('/').pop()! 43 + const reviewData = await baseClient.social.drydown.review.get({ 44 + repo: session.sub, 45 + rkey 46 + }) 47 + setReview(reviewData.value) 48 + 49 + // Fetch fragrance name 50 + const fragranceRkey = reviewData.value.fragrance.split('/').pop()! 51 + const fragranceData = await baseClient.social.drydown.fragrance.get({ 52 + repo: session.sub, 53 + rkey: fragranceRkey 54 + }) 55 + setFragranceName(fragranceData.value.name) 56 + 57 + setIsLoading(false) 58 + } 59 + loadReview() 60 + }, [reviewUri, session]) 61 + 62 + async function handleSubmit(e: Event) { 63 + e.preventDefault() 64 + if (!atp || !review) return 65 + 66 + setIsSubmitting(true) 67 + try { 68 + const rkey = reviewUri.split('/').pop()! 69 + 70 + const updates = stage === 'stage2' ? { 71 + drydownRating, 72 + midProjection, 73 + sillage 74 + } : { 75 + endRating, 76 + complexity, 77 + longevity, 78 + overallRating, 79 + text: text || undefined 80 + } 81 + 82 + const updatedReview = { ...review, ...updates } 83 + 84 + // Calculate weighted score for Stage 3 85 + if (stage === 'stage3') { 86 + const score = calculateWeightedScore(updatedReview) 87 + updatedReview.weightedScore = encodeWeightedScore(score) 88 + } 89 + 90 + await atp.social.drydown.review.put( 91 + { repo: session.sub, rkey }, 92 + updatedReview 93 + ) 94 + 95 + onSuccess() 96 + } catch (e) { 97 + console.error('Failed to update review', e) 98 + alert('Failed to update review') 99 + } finally { 100 + setIsSubmitting(false) 101 + } 102 + } 103 + 104 + if (isLoading) return <div>Loading...</div> 105 + 106 + const isStage2Valid = drydownRating >= 1 && drydownRating <= 5 && 107 + midProjection >= 1 && midProjection <= 5 && 108 + sillage >= 1 && sillage <= 5 109 + 110 + const isStage3Valid = endRating >= 1 && endRating <= 5 && 111 + complexity >= 1 && complexity <= 5 && 112 + longevity >= 1 && longevity <= 5 && 113 + overallRating >= 1 && overallRating <= 5 114 + 115 + return ( 116 + <div> 117 + <h2>Update Review: {fragranceName}</h2> 118 + <button onClick={onCancel} style={{ marginBottom: '1rem' }}>Back</button> 119 + 120 + {/* Show previous stage ratings (read-only) */} 121 + <div style={{ marginBottom: '2rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px' }}> 122 + <h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Previous Ratings</h3> 123 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Rating: {review.openingRating} / 5</p> 124 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Projection: {review.openingProjection} / 5</p> 125 + {stage === 'stage3' && review.drydownRating && ( 126 + <> 127 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Drydown Rating: {review.drydownRating} / 5</p> 128 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Mid Projection: {review.midProjection} / 5</p> 129 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Sillage: {review.sillage} / 5</p> 130 + </> 131 + )} 132 + </div> 133 + 134 + <form onSubmit={handleSubmit}> 135 + <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem' }}> 136 + {stage === 'stage2' ? 'Stage 2: Heart Notes' : 'Stage 3: Final Review'} 137 + </h3> 138 + 139 + {stage === 'stage2' ? ( 140 + <> 141 + <div style={{ marginBottom: '1rem' }}> 142 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 143 + Drydown Rating (1-5) 144 + </label> 145 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 146 + How it smells after settling 147 + </p> 148 + <input 149 + type="number" 150 + min="1" 151 + max="5" 152 + value={drydownRating || ''} 153 + onInput={(e) => setDrydownRating(parseInt((e.target as HTMLInputElement).value) || 0)} 154 + style={{ width: '100%', padding: '0.5rem' }} 155 + /> 156 + </div> 157 + 158 + <div style={{ marginBottom: '1rem' }}> 159 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 160 + Mid Projection (1-5) 161 + </label> 162 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 163 + Scent bubble radius during mid-wear 164 + </p> 165 + <input 166 + type="number" 167 + min="1" 168 + max="5" 169 + value={midProjection || ''} 170 + onInput={(e) => setMidProjection(parseInt((e.target as HTMLInputElement).value) || 0)} 171 + style={{ width: '100%', padding: '0.5rem' }} 172 + /> 173 + </div> 174 + 175 + <div style={{ marginBottom: '1rem' }}> 176 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 177 + Sillage (1-5) 178 + </label> 179 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 180 + Trail left behind 181 + </p> 182 + <input 183 + type="number" 184 + min="1" 185 + max="5" 186 + value={sillage || ''} 187 + onInput={(e) => setSillage(parseInt((e.target as HTMLInputElement).value) || 0)} 188 + style={{ width: '100%', padding: '0.5rem' }} 189 + /> 190 + </div> 191 + </> 192 + ) : ( 193 + <> 194 + <div style={{ marginBottom: '1rem' }}> 195 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 196 + End Rating (1-5) 197 + </label> 198 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 199 + How it smells at the end 200 + </p> 201 + <input 202 + type="number" 203 + min="1" 204 + max="5" 205 + value={endRating || ''} 206 + onInput={(e) => setEndRating(parseInt((e.target as HTMLInputElement).value) || 0)} 207 + style={{ width: '100%', padding: '0.5rem' }} 208 + /> 209 + </div> 210 + 211 + <div style={{ marginBottom: '1rem' }}> 212 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 213 + Complexity (1-5) 214 + </label> 215 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 216 + Depth and evolution 217 + </p> 218 + <input 219 + type="number" 220 + min="1" 221 + max="5" 222 + value={complexity || ''} 223 + onInput={(e) => setComplexity(parseInt((e.target as HTMLInputElement).value) || 0)} 224 + style={{ width: '100%', padding: '0.5rem' }} 225 + /> 226 + </div> 227 + 228 + <div style={{ marginBottom: '1rem' }}> 229 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 230 + Longevity (1-5) 231 + </label> 232 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 233 + Total duration 234 + </p> 235 + <input 236 + type="number" 237 + min="1" 238 + max="5" 239 + value={longevity || ''} 240 + onInput={(e) => setLongevity(parseInt((e.target as HTMLInputElement).value) || 0)} 241 + style={{ width: '100%', padding: '0.5rem' }} 242 + /> 243 + </div> 244 + 245 + <div style={{ marginBottom: '1rem' }}> 246 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 247 + Overall Rating (1-5) 248 + </label> 249 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 250 + Holistic gut score 251 + </p> 252 + <input 253 + type="number" 254 + min="1" 255 + max="5" 256 + value={overallRating || ''} 257 + onInput={(e) => setOverallRating(parseInt((e.target as HTMLInputElement).value) || 0)} 258 + style={{ width: '100%', padding: '0.5rem' }} 259 + /> 260 + </div> 261 + 262 + <div style={{ marginBottom: '1rem' }}> 263 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 264 + Written Review (Optional) 265 + </label> 266 + <textarea 267 + value={text} 268 + onInput={(e) => setText((e.target as HTMLInputElement).value)} 269 + style={{ width: '100%', padding: '0.5rem', minHeight: '100px' }} 270 + maxLength={255} 271 + placeholder="Share your thoughts..." 272 + /> 273 + <div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}> 274 + {text.length} / 255 characters 275 + </div> 276 + </div> 277 + </> 278 + )} 279 + 280 + <button 281 + type="submit" 282 + disabled={isSubmitting || (stage === 'stage2' ? !isStage2Valid : !isStage3Valid)} 283 + > 284 + {isSubmitting ? 'Saving...' : (stage === 'stage2' ? 'Save Stage 2' : 'Complete Review')} 285 + </button> 286 + </form> 287 + </div> 288 + ) 289 + }
+50
src/components/ReviewCard.tsx
··· 1 + import { getAvailableStage, calculateElapsedHours, decodeWeightedScore } from '../utils/reviewUtils' 2 + 3 + interface ReviewCardProps { 4 + review: { uri: string; value: any } 5 + fragranceName: string 6 + status: 'ready' | 'waiting' | 'completed' 7 + } 8 + 9 + export function ReviewCard({ review, fragranceName, status }: ReviewCardProps) { 10 + const { value } = review 11 + 12 + return ( 13 + <div style={{ 14 + border: '1px solid #ddd', 15 + borderRadius: '8px', 16 + padding: '1rem', 17 + marginBottom: '1rem' 18 + }}> 19 + <h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1.1rem' }}> 20 + {fragranceName} 21 + </h4> 22 + 23 + {status === 'ready' && ( 24 + <div style={{ fontSize: '0.9rem', color: '#0066cc', fontWeight: 'bold' }}> 25 + Ready for {getAvailableStage(value) === 'stage2' ? 'Stage 2: Heart Notes' : 'Stage 3: Final Review'} 26 + </div> 27 + )} 28 + 29 + {status === 'waiting' && ( 30 + <div style={{ fontSize: '0.9rem', color: '#666' }}> 31 + Waiting for Stage 2 (come back in {Math.ceil((2 - calculateElapsedHours(value.createdAt)) * 60)} minutes) 32 + </div> 33 + )} 34 + 35 + {status === 'completed' && ( 36 + <> 37 + <div style={{ fontSize: '1.2rem', color: '#f90' }}> 38 + {'★'.repeat(Math.round(decodeWeightedScore(value.weightedScore || 0)))} 39 + {'☆'.repeat(5 - Math.round(decodeWeightedScore(value.weightedScore || 0)))} 40 + </div> 41 + {value.text && ( 42 + <p style={{ fontSize: '0.9rem', color: '#666', margin: '0.5rem 0 0 0' }}> 43 + {value.text.substring(0, 100)}{value.text.length > 100 ? '...' : ''} 44 + </p> 45 + )} 46 + </> 47 + )} 48 + </div> 49 + ) 50 + }
+71
src/components/ReviewDashboard.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { AtpBaseClient } from '../client/index' 3 + import { ReviewList } from './ReviewList' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 5 + 6 + interface ReviewDashboardProps { 7 + session: OAuthSession 8 + onCreateNew: () => void 9 + } 10 + 11 + export function ReviewDashboard({ session, onCreateNew }: ReviewDashboardProps) { 12 + const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 13 + const [fragrances, setFragrances] = useState<Map<string, { name: string }>>(new Map()) 14 + const [isLoading, setIsLoading] = useState(true) 15 + const [atp, setAtp] = useState<AtpBaseClient | null>(null) 16 + 17 + useEffect(() => { 18 + async function initClient() { 19 + const baseClient = new AtpBaseClient({ 20 + did: session.did, 21 + fetchHandler: (url, init) => session.fetchHandler(url, init) 22 + }) 23 + setAtp(baseClient) 24 + await loadData(baseClient) 25 + } 26 + initClient() 27 + 28 + // Refresh every minute to update countdown timers 29 + const interval = setInterval(() => { 30 + setReviews(reviews => [...reviews]) // Force re-render 31 + }, 60000) 32 + 33 + return () => clearInterval(interval) 34 + }, [session]) 35 + 36 + async function loadData(client: AtpBaseClient) { 37 + try { 38 + setIsLoading(true) 39 + 40 + // Fetch reviews 41 + const reviewRecords = await client.social.drydown.review.list({ repo: session.sub }) 42 + setReviews(reviewRecords.records) 43 + 44 + // Fetch fragrances for names 45 + const fragranceRecords = await client.social.drydown.fragrance.list({ repo: session.sub }) 46 + const fragranceMap = new Map( 47 + fragranceRecords.records.map(f => [f.uri, { name: f.value.name }]) 48 + ) 49 + setFragrances(fragranceMap) 50 + } catch (e) { 51 + console.error('Failed to load data', e) 52 + } finally { 53 + setIsLoading(false) 54 + } 55 + } 56 + 57 + return ( 58 + <div> 59 + <h2>Your Reviews</h2> 60 + <button onClick={onCreateNew} style={{ marginBottom: '2rem' }}> 61 + Create New Review 62 + </button> 63 + 64 + {isLoading ? ( 65 + <div>Loading reviews...</div> 66 + ) : ( 67 + <ReviewList reviews={reviews} fragrances={fragrances} /> 68 + )} 69 + </div> 70 + ) 71 + }
+73
src/components/ReviewList.tsx
··· 1 + import { ReviewCard } from './ReviewCard' 2 + import { categorizeReviews } from '../utils/reviewUtils' 3 + 4 + interface ReviewListProps { 5 + reviews: Array<{ uri: string; value: any }> 6 + fragrances: Map<string, { name: string }> 7 + } 8 + 9 + export function ReviewList({ reviews, fragrances }: ReviewListProps) { 10 + const { readyToUpdate, waiting, completed } = categorizeReviews(reviews) 11 + 12 + const getFragranceName = (fragranceUri: string) => { 13 + return fragrances.get(fragranceUri)?.name || 'Unknown Fragrance' 14 + } 15 + 16 + return ( 17 + <div> 18 + {readyToUpdate.length > 0 && ( 19 + <section style={{ marginBottom: '2rem' }}> 20 + <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem', color: '#0066cc' }}> 21 + Ready to Update ({readyToUpdate.length}) 22 + </h3> 23 + {readyToUpdate.map(review => ( 24 + <ReviewCard 25 + key={review.uri} 26 + review={review} 27 + fragranceName={getFragranceName(review.value.fragrance)} 28 + status="ready" 29 + /> 30 + ))} 31 + </section> 32 + )} 33 + 34 + {waiting.length > 0 && ( 35 + <section style={{ marginBottom: '2rem' }}> 36 + <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem', color: '#666' }}> 37 + Waiting ({waiting.length}) 38 + </h3> 39 + {waiting.map(review => ( 40 + <ReviewCard 41 + key={review.uri} 42 + review={review} 43 + fragranceName={getFragranceName(review.value.fragrance)} 44 + status="waiting" 45 + /> 46 + ))} 47 + </section> 48 + )} 49 + 50 + {completed.length > 0 && ( 51 + <section style={{ marginBottom: '2rem' }}> 52 + <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem', color: '#0a0' }}> 53 + Completed ({completed.length}) 54 + </h3> 55 + {completed.map(review => ( 56 + <ReviewCard 57 + key={review.uri} 58 + review={review} 59 + fragranceName={getFragranceName(review.value.fragrance)} 60 + status="completed" 61 + /> 62 + ))} 63 + </section> 64 + )} 65 + 66 + {reviews.length === 0 && ( 67 + <div style={{ textAlign: 'center', padding: '2rem', color: '#666' }}> 68 + <p>No reviews yet. Create your first review!</p> 69 + </div> 70 + )} 71 + </div> 72 + ) 73 + }
+129
src/utils/reviewUtils.ts
··· 1 + // Timing utility functions 2 + export 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 + 8 + export 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: 2-4 hours AND no drydownRating yet 17 + if (elapsed >= 2 && 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 + 29 + export function isReviewCompleted(review: any): boolean { 30 + return !!(review.endRating && review.complexity && review.longevity && review.overallRating) 31 + } 32 + 33 + // Weighted score calculation 34 + const DEFAULT_WEIGHTS = { 35 + openingRating: 1.0, 36 + openingProjection: 1.0, 37 + drydownRating: 1.0, 38 + midProjection: 1.0, 39 + sillage: 1.0, 40 + endRating: 1.0, 41 + complexity: 1.0, 42 + longevity: 1.0, 43 + overallRating: 1.0 44 + } 45 + 46 + export function calculateWeightedScore(review: any, weights = DEFAULT_WEIGHTS): number { 47 + let totalWeighted = 0 48 + let totalWeights = 0 49 + 50 + // Stage 1 ratings 51 + if (review.openingRating) { 52 + totalWeighted += review.openingRating * weights.openingRating 53 + totalWeights += weights.openingRating 54 + } 55 + if (review.openingProjection) { 56 + totalWeighted += review.openingProjection * weights.openingProjection 57 + totalWeights += weights.openingProjection 58 + } 59 + 60 + // Stage 2 ratings (optional) 61 + if (review.drydownRating) { 62 + totalWeighted += review.drydownRating * weights.drydownRating 63 + totalWeights += weights.drydownRating 64 + } 65 + if (review.midProjection) { 66 + totalWeighted += review.midProjection * weights.midProjection 67 + totalWeights += weights.midProjection 68 + } 69 + if (review.sillage) { 70 + totalWeighted += review.sillage * weights.sillage 71 + totalWeights += weights.sillage 72 + } 73 + 74 + // Stage 3 ratings 75 + if (review.endRating) { 76 + totalWeighted += review.endRating * weights.endRating 77 + totalWeights += weights.endRating 78 + } 79 + if (review.complexity) { 80 + totalWeighted += review.complexity * weights.complexity 81 + totalWeights += weights.complexity 82 + } 83 + if (review.longevity) { 84 + totalWeighted += review.longevity * weights.longevity 85 + totalWeights += weights.longevity 86 + } 87 + if (review.overallRating) { 88 + totalWeighted += review.overallRating * weights.overallRating 89 + totalWeights += weights.overallRating 90 + } 91 + 92 + if (totalWeights === 0) return 0 93 + 94 + return Math.round((totalWeighted / totalWeights) * 1000) / 1000 95 + } 96 + 97 + export function encodeWeightedScore(score: number): number { 98 + return Math.round(score * 1000) 99 + } 100 + 101 + export function decodeWeightedScore(encoded: number): number { 102 + return encoded / 1000 103 + } 104 + 105 + // Review categorization 106 + export function categorizeReviews(reviews: Array<{ uri: string; value: any }>) { 107 + const readyToUpdate: typeof reviews = [] 108 + const waiting: typeof reviews = [] 109 + const completed: typeof reviews = [] 110 + 111 + for (const review of reviews) { 112 + if (isReviewCompleted(review.value)) { 113 + completed.push(review) 114 + } else { 115 + const stage = getAvailableStage(review.value) 116 + if (stage) { 117 + readyToUpdate.push(review) 118 + } else { 119 + const elapsed = calculateElapsedHours(review.value.createdAt) 120 + // Only show in "waiting" if created in last hour 121 + if (elapsed < 1) { 122 + waiting.push(review) 123 + } 124 + } 125 + } 126 + } 127 + 128 + return { readyToUpdate, waiting, completed } 129 + }