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

fixing issue with not being able to edit stage 1

+383 -95
+4 -10
src/app.tsx
··· 25 25 function Home({ session, userProfile, onLogout }: HomeProps) { 26 26 const [view, setView] = useState<'home' | 'create-review' | 'edit-review'>('home') 27 27 const [editReviewUri, setEditReviewUri] = useState<string | null>(null) 28 - const [editReviewStage, setEditReviewStage] = useState<'stage2' | 'stage3' | null>(null) 29 28 30 29 useEffect(() => { 31 30 // Check for edit intent in URL 32 31 const params = new URLSearchParams(window.location.search) 33 32 const editRkey = params.get('edit') 34 - const stage = params.get('stage') 35 - 36 - if (editRkey && stage && session) { 33 + 34 + if (editRkey && session) { 37 35 setEditReviewUri(`at://${session.sub}/social.drydown.review/${editRkey}`) 38 - setEditReviewStage(stage as 'stage2' | 'stage3') 39 36 setView('edit-review') 40 - 37 + 41 38 // Clean up URL without triggering navigation 42 39 const newUrl = window.location.pathname 43 40 window.history.replaceState({}, '', newUrl) ··· 51 48 const handleBackToDashboard = () => { 52 49 setView('home') 53 50 setEditReviewUri(null) 54 - setEditReviewStage(null) 55 51 } 56 52 57 53 return ( ··· 83 79 <ReviewDashboard 84 80 session={session} 85 81 onCreateNew={handleCreateNew} 86 - onEditReview={(uri, stage) => { 82 + onEditReview={(uri) => { 87 83 setEditReviewUri(uri) 88 - setEditReviewStage(stage) 89 84 setView('edit-review') 90 85 }} 91 86 /> ··· 101 96 <EditReview 102 97 session={session} 103 98 reviewUri={editReviewUri!} 104 - stage={editReviewStage!} 105 99 onCancel={handleBackToDashboard} 106 100 onSuccess={handleBackToDashboard} 107 101 />
+16 -6
src/components/CreateReview.tsx
··· 3 3 import { Combobox } from './Combobox' 4 4 import type { OAuthSession } from '@atproto/oauth-client-browser' 5 5 import { DiscoveryService } from '@/services/discovery' 6 + import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 6 7 7 8 // Define local interfaces based on the lexicon types for easier usage 8 9 interface House { ··· 197 198 198 199 setIsSubmitting(true) 199 200 try { 201 + const reviewData = { 202 + fragrance: selectedFragranceUri, 203 + openingRating: openingRating, 204 + openingProjection: openingProjection, 205 + createdAt: new Date().toISOString() 206 + } 207 + 208 + // Calculate initial weighted score from Stage 1 ratings 209 + const initialScore = calculateWeightedScore(reviewData) 210 + const reviewWithScore = { 211 + ...reviewData, 212 + weightedScore: encodeWeightedScore(initialScore) 213 + } 214 + 200 215 await atp.social.drydown.review.create( 201 216 { repo: session.sub }, 202 - { 203 - fragrance: selectedFragranceUri, 204 - openingRating: openingRating, 205 - openingProjection: openingProjection, 206 - createdAt: new Date().toISOString() 207 - } 217 + reviewWithScore 208 218 ) 209 219 if ('Notification' in window && Notification.permission === 'default') { 210 220 await Notification.requestPermission()
+140 -30
src/components/EditReview.tsx
··· 3 3 import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 4 4 import type { OAuthSession } from '@atproto/oauth-client-browser' 5 5 import { WeatherService, type WeatherData } from '../services/weatherService' 6 + import { validateEditPermissions, validateStageUpdate } from '../utils/reviewValidation' 6 7 7 8 interface EditReviewProps { 8 9 session: OAuthSession 9 10 reviewUri: string 10 - stage: 'stage2' | 'stage3' 11 11 onCancel: () => void 12 12 onSuccess: () => void 13 13 } 14 14 15 - export function EditReview({ session, reviewUri, stage, onCancel, onSuccess }: EditReviewProps) { 15 + export function EditReview({ session, reviewUri, onCancel, onSuccess }: EditReviewProps) { 16 16 const [review, setReview] = useState<any>(null) 17 + const [editStage, setEditStage] = useState<'stage1' | 'stage2' | 'stage3'>('stage2') 17 18 const [fragranceName, setFragranceName] = useState<string>('') 18 19 const [houseName, setHouseName] = useState<string>('') 19 20 const [isLoading, setIsLoading] = useState(true) 20 21 const [isSubmitting, setIsSubmitting] = useState(false) 22 + 23 + // Stage 1 state 24 + const [openingRating, setOpeningRating] = useState<number>(0) 25 + const [openingProjection, setOpeningProjection] = useState<number>(0) 21 26 22 27 // Stage 2 state 23 28 const [drydownRating, setDrydownRating] = useState<number>(0) ··· 50 55 repo: session.sub, 51 56 rkey 52 57 }) 53 - setReview(reviewData.value) 58 + const reviewValue = reviewData.value 59 + setReview(reviewValue) 60 + 61 + // COMPUTE WHICH STAGE TO EDIT BASED ON PERMISSIONS 62 + const permissions = validateEditPermissions(reviewValue) 63 + 64 + // After 24 hours, redirect back 65 + if (permissions.error) { 66 + alert(permissions.error) 67 + onCancel() 68 + return 69 + } 70 + 71 + // Determine edit stage: prefer adding new stage over editing existing, fall back to Stage 1 72 + if (permissions.canAddStage3 || permissions.canEditStage3) { 73 + setEditStage('stage3') 74 + } else if (permissions.canAddStage2 || permissions.canEditStage2) { 75 + setEditStage('stage2') 76 + } else if (permissions.canEditStage1) { 77 + setEditStage('stage1') 78 + } else { 79 + // Shouldn't happen within 24 hours, but just in case 80 + setEditStage('stage1') 81 + } 82 + 83 + // POPULATE FORM FIELDS FROM EXISTING REVIEW DATA 84 + if (reviewValue.openingRating) setOpeningRating(reviewValue.openingRating) 85 + if (reviewValue.openingProjection) setOpeningProjection(reviewValue.openingProjection) 86 + if (reviewValue.drydownRating) setDrydownRating(reviewValue.drydownRating) 87 + if (reviewValue.midProjection) setMidProjection(reviewValue.midProjection) 88 + if (reviewValue.sillage) setSillage(reviewValue.sillage) 89 + if (reviewValue.endRating) setEndRating(reviewValue.endRating) 90 + if (reviewValue.complexity) setComplexity(reviewValue.complexity) 91 + if (reviewValue.longevity) setLongevity(reviewValue.longevity) 92 + if (reviewValue.overallRating) setOverallRating(reviewValue.overallRating) 93 + if (reviewValue.text) setText(reviewValue.text) 54 94 55 95 // Fetch fragrance name 56 - const fragranceRkey = reviewData.value.fragrance.split('/').pop()! 96 + const fragranceRkey = reviewValue.fragrance.split('/').pop()! 57 97 const fragranceData = await baseClient.social.drydown.fragrance.get({ 58 98 repo: session.sub, 59 99 rkey: fragranceRkey ··· 88 128 89 129 // Show weather opt-in banner on Stage 3 90 130 useEffect(() => { 91 - if (stage === 'stage3' && review && !review.weatherOptIn && !weatherOptInShown) { 131 + if (editStage === 'stage3' && review && !review.weatherOptIn && !weatherOptInShown) { 92 132 setWeatherOptInShown(true) 93 133 } 94 - }, [stage, review, weatherOptInShown]) 134 + }, [editStage, review, weatherOptInShown]) 95 135 96 136 async function saveReview() { 97 137 if (!atp || !review) return false 98 138 139 + // VALIDATE BEFORE SAVING 140 + const validation = validateStageUpdate(review, editStage) 141 + if (!validation.valid) { 142 + alert(validation.error || 'Cannot update review at this time') 143 + return false 144 + } 145 + 99 146 setIsSubmitting(true) 100 147 try { 101 148 const rkey = reviewUri.split('/').pop()! 102 149 103 - const updates: any = stage === 'stage2' ? { 150 + const updates: any = editStage === 'stage1' ? { 151 + openingRating, 152 + openingProjection 153 + } : editStage === 'stage2' ? { 104 154 drydownRating, 105 155 midProjection, 106 156 sillage, ··· 121 171 updates.stage1Temp = weatherData.stage1Temp 122 172 updates.stage2Temp = weatherData.stage2Temp 123 173 updates.stage3Temp = weatherData.stage3Temp 124 - } else if (stage === 'stage3' && weatherOptInShown) { 174 + } else if (editStage === 'stage3' && weatherOptInShown) { 125 175 // User skipped weather collection 126 176 updates.weatherOptIn = false 127 177 } 128 178 129 179 const updatedReview = { ...review, ...updates } 130 180 131 - // Calculate weighted score for Stage 3 132 - if (stage === 'stage3') { 181 + // Calculate weighted score for all stages 182 + if (editStage === 'stage1' || editStage === 'stage2' || editStage === 'stage3') { 133 183 const score = calculateWeightedScore(updatedReview) 134 184 updatedReview.weightedScore = encodeWeightedScore(score) 135 185 } ··· 244 294 245 295 if (isLoading) return <div>Loading...</div> 246 296 297 + const isStage1Valid = openingRating >= 1 && openingRating <= 5 && 298 + openingProjection >= 1 && openingProjection <= 5 299 + 247 300 const isStage2Valid = drydownRating >= 1 && drydownRating <= 5 && 248 301 midProjection >= 1 && midProjection <= 5 && 249 302 sillage >= 1 && sillage <= 5 ··· 258 311 <h2>Update Review: {fragranceName}</h2> 259 312 <button onClick={onCancel} style={{ marginBottom: '1rem' }}>Back</button> 260 313 261 - {/* Show previous stage ratings (read-only) */} 262 - <div style={{ marginBottom: '2rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px' }}> 263 - <h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Previous Ratings</h3> 264 - <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Rating: {review.openingRating} / 5</p> 265 - <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Projection: {review.openingProjection} / 5</p> 266 - {stage === 'stage3' && review.drydownRating && ( 267 - <> 268 - <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Drydown Rating: {review.drydownRating} / 5</p> 269 - <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Mid Projection: {review.midProjection} / 5</p> 270 - <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Sillage: {review.sillage} / 5</p> 271 - </> 272 - )} 273 - </div> 314 + {/* Show previous stage ratings (read-only) - only if not editing Stage 1 */} 315 + {editStage !== 'stage1' && ( 316 + <div style={{ marginBottom: '2rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px' }}> 317 + <h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Previous Ratings</h3> 318 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Rating: {Math.round(review.openingRating)} / 5</p> 319 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Opening Projection: {Math.round(review.openingProjection)} / 5</p> 320 + {editStage === 'stage3' && review.drydownRating && ( 321 + <> 322 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Drydown Rating: {Math.round(review.drydownRating)} / 5</p> 323 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Mid Projection: {Math.round(review.midProjection)} / 5</p> 324 + <p style={{ margin: '0.25rem 0', fontSize: '0.9rem' }}>Sillage: {Math.round(review.sillage)} / 5</p> 325 + </> 326 + )} 327 + </div> 328 + )} 274 329 275 330 <form onSubmit={handleSubmit}> 276 331 <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem' }}> 277 - {stage === 'stage2' ? 'Stage 2: Heart Notes' : 'Stage 3: Final Review'} 332 + {editStage === 'stage1' ? 'Stage 1: Initial Impressions' : editStage === 'stage2' ? 'Stage 2: Heart Notes' : 'Stage 3: Final Review'} 278 333 </h3> 279 334 335 + {/* Show what's editable */} 336 + {review && (() => { 337 + const permissions = validateEditPermissions(review) 338 + return ( 339 + <div style={{ marginBottom: '1.5rem', padding: '1rem', background: '#e3f2fd', borderRadius: '8px' }}> 340 + <div style={{ fontSize: '0.9rem', fontWeight: 'bold', marginBottom: '0.5rem' }}> 341 + What you can edit: 342 + </div> 343 + <ul style={{ margin: 0, paddingLeft: '1.5rem', fontSize: '0.85rem', lineHeight: '1.6' }}> 344 + {permissions.canEditStage1 && <li>Stage 1 fields (from previous ratings card above)</li>} 345 + {permissions.canEditStage2 && <li>Stage 2 fields (Heart notes)</li>} 346 + {permissions.canAddStage2 && <li>Add Stage 2 fields (Heart notes - now available)</li>} 347 + {permissions.canEditStage3 && <li>Stage 3 fields (Final review)</li>} 348 + {permissions.canAddStage3 && <li>Add Stage 3 fields (Final review - now available)</li>} 349 + </ul> 350 + </div> 351 + ) 352 + })()} 353 + 280 354 {/* Weather Opt-In Banner (Stage 3 only) */} 281 - {weatherOptInShown && stage === 'stage3' && !review.weatherOptIn && ( 355 + {weatherOptInShown && editStage === 'stage3' && !review.weatherOptIn && ( 282 356 <div className="weather-opt-in-banner"> 283 357 <div className="banner-icon">🌤️</div> 284 358 <div className="banner-content"> ··· 299 373 )} 300 374 301 375 {/* Success message after weather data collected */} 302 - {weatherData && stage === 'stage3' && ( 376 + {weatherData && editStage === 'stage3' && ( 303 377 <div style={{ 304 378 background: '#d1fae5', 305 379 border: '1px solid #34d399', ··· 317 391 </div> 318 392 )} 319 393 320 - {stage === 'stage2' ? ( 394 + {editStage === 'stage1' ? ( 395 + <> 396 + <div style={{ marginBottom: '1rem' }}> 397 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 398 + Opening Rating (1-5) 399 + </label> 400 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 401 + First impression appeal 402 + </p> 403 + <input 404 + type="number" 405 + min="1" 406 + max="5" 407 + value={openingRating || ''} 408 + onInput={(e) => setOpeningRating(parseInt((e.target as HTMLInputElement).value) || 0)} 409 + style={{ width: '100%', padding: '0.5rem' }} 410 + /> 411 + </div> 412 + 413 + <div style={{ marginBottom: '1rem' }}> 414 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 415 + Opening Projection (1-5) 416 + </label> 417 + <p style={{ fontSize: '0.85rem', color: '#666', margin: '0 0 0.5rem 0' }}> 418 + Initial scent bubble radius 419 + </p> 420 + <input 421 + type="number" 422 + min="1" 423 + max="5" 424 + value={openingProjection || ''} 425 + onInput={(e) => setOpeningProjection(parseInt((e.target as HTMLInputElement).value) || 0)} 426 + style={{ width: '100%', padding: '0.5rem' }} 427 + /> 428 + </div> 429 + </> 430 + ) : editStage === 'stage2' ? ( 321 431 <> 322 432 <div style={{ marginBottom: '1rem' }}> 323 433 <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> ··· 460 570 461 571 <button 462 572 type="submit" 463 - disabled={isSubmitting || (stage === 'stage2' ? !isStage2Valid : !isStage3Valid)} 573 + disabled={isSubmitting || (editStage === 'stage1' ? !isStage1Valid : editStage === 'stage2' ? !isStage2Valid : !isStage3Valid)} 464 574 > 465 - {isSubmitting ? 'Saving...' : (stage === 'stage2' ? 'Save Stage 2' : 'Complete Review')} 575 + {isSubmitting ? 'Saving...' : (editStage === 'stage1' ? 'Save Stage 1' : editStage === 'stage2' ? 'Save Stage 2' : 'Complete Review')} 466 576 </button> 467 577 468 - {stage === 'stage3' && ( 578 + {editStage === 'stage3' && ( 469 579 <button 470 580 type="button" 471 581 onClick={handleSaveAndShare}
+4 -4
src/components/HousePage.tsx
··· 291 291 </div> 292 292 <div className="score-item"> 293 293 <div className="score-label">Avg Rating</div> 294 - <div className="score-value">{totalReviews > 0 ? `${totalRating} ★` : 'N/A'}</div> 294 + <div className="score-value">{totalReviews > 0 ? `${totalRating.toFixed(1)} ★` : 'N/A'}</div> 295 295 </div> 296 296 <div className="score-item"> 297 297 <div className="score-label">Avg Projection</div> 298 - <div className="score-value">{avgProjection > 0 ? `${avgProjection}/5` : 'N/A'}</div> 298 + <div className="score-value">{avgProjection > 0 ? `${avgProjection.toFixed(1)}/5` : 'N/A'}</div> 299 299 </div> 300 300 <div className="score-item"> 301 301 <div className="score-label">Avg Sillage</div> 302 - <div className="score-value">{avgSillage > 0 ? `${avgSillage}/5` : 'N/A'}</div> 302 + <div className="score-value">{avgSillage > 0 ? `${avgSillage.toFixed(1)}/5` : 'N/A'}</div> 303 303 </div> 304 304 <div className="score-item"> 305 305 <div className="score-label">Avg Complexity</div> 306 - <div className="score-value">{avgComplexity > 0 ? `${avgComplexity}/5` : 'N/A'}</div> 306 + <div className="score-value">{avgComplexity > 0 ? `${avgComplexity.toFixed(1)}/5` : 'N/A'}</div> 307 307 </div> 308 308 </div> 309 309
+79 -26
src/components/ProfilePage.tsx
··· 6 6 import { ReviewList } from './ReviewList' 7 7 import { resolveIdentity } from '../utils/resolveIdentity' 8 8 import { cache, TTL } from '../services/cache' 9 - import { calculateWeightedScore, decodeWeightedScore } from '../utils/reviewUtils' 9 + import { getReviewDisplayScore } from '../utils/reviewUtils' 10 10 11 11 interface ProfilePageProps { 12 12 handle: string ··· 122 122 123 123 // Calculate Stats 124 124 const numReviews = reviews.length 125 - 125 + 126 126 let avgRating = 0 127 127 let favoriteFragrance = 'N/A' 128 128 let favoriteHouse = 'N/A' 129 129 130 130 if (numReviews > 0) { 131 + interface FragranceStats { 132 + name: string 133 + totalScore: number 134 + reviewCount: number 135 + mostRecentDate: string 136 + } 137 + 138 + interface HouseStats { 139 + name: string 140 + totalScore: number 141 + reviewCount: number 142 + mostRecentDate: string 143 + } 144 + 131 145 let totalRating = 0 132 - const fragranceCounts = new Map<string, { count: number, name: string }>() 133 - const houseCounts = new Map<string, { count: number, name: string }>() 146 + const fragranceStats = new Map<string, FragranceStats>() 147 + const houseStats = new Map<string, HouseStats>() 134 148 135 149 for (const review of reviews) { 136 - // Average Rating 137 - const score = review.value.weightedScore 138 - ? decodeWeightedScore(review.value.weightedScore) 139 - : calculateWeightedScore(review.value) 150 + const score = getReviewDisplayScore(review.value) 140 151 totalRating += score 141 152 142 - // Counts 143 153 const fragUri = review.value.fragrance 144 154 const fragInfo = fragrances.get(fragUri) 145 - 155 + 146 156 if (fragInfo) { 147 - const currentFragCount = fragranceCounts.get(fragUri)?.count || 0 148 - fragranceCounts.set(fragUri, { count: currentFragCount + 1, name: fragInfo.name }) 157 + // Aggregate fragrance stats 158 + const current = fragranceStats.get(fragUri) 159 + if (current) { 160 + fragranceStats.set(fragUri, { 161 + name: fragInfo.name, 162 + totalScore: current.totalScore + score, 163 + reviewCount: current.reviewCount + 1, 164 + mostRecentDate: review.value.createdAt > current.mostRecentDate 165 + ? review.value.createdAt 166 + : current.mostRecentDate 167 + }) 168 + } else { 169 + fragranceStats.set(fragUri, { 170 + name: fragInfo.name, 171 + totalScore: score, 172 + reviewCount: 1, 173 + mostRecentDate: review.value.createdAt 174 + }) 175 + } 149 176 177 + // Aggregate house stats 150 178 if (fragInfo.houseName && fragInfo.houseName !== 'Unknown House') { 151 - const currentHouseCount = houseCounts.get(fragInfo.houseName)?.count || 0 152 - houseCounts.set(fragInfo.houseName, { count: currentHouseCount + 1, name: fragInfo.houseName }) 179 + const currentHouse = houseStats.get(fragInfo.houseName) 180 + if (currentHouse) { 181 + houseStats.set(fragInfo.houseName, { 182 + name: fragInfo.houseName, 183 + totalScore: currentHouse.totalScore + score, 184 + reviewCount: currentHouse.reviewCount + 1, 185 + mostRecentDate: review.value.createdAt > currentHouse.mostRecentDate 186 + ? review.value.createdAt 187 + : currentHouse.mostRecentDate 188 + }) 189 + } else { 190 + houseStats.set(fragInfo.houseName, { 191 + name: fragInfo.houseName, 192 + totalScore: score, 193 + reviewCount: 1, 194 + mostRecentDate: review.value.createdAt 195 + }) 196 + } 153 197 } 154 198 } 155 199 } 156 200 157 201 avgRating = Math.round((totalRating / numReviews) * 10) / 10 158 202 159 - // Find top instances 160 - let topFragCount = 0 161 - for (const [_, info] of fragranceCounts.entries()) { 162 - if (info.count > topFragCount) { 163 - topFragCount = info.count 164 - favoriteFragrance = info.name 203 + // Find favorite fragrance by highest average score (with recency tiebreaker) 204 + let bestAvgScore = 0 205 + let bestDate = '' 206 + for (const [_, stats] of fragranceStats.entries()) { 207 + const avgScore = stats.totalScore / stats.reviewCount 208 + if (avgScore > bestAvgScore || 209 + (avgScore === bestAvgScore && stats.mostRecentDate > bestDate)) { 210 + bestAvgScore = avgScore 211 + bestDate = stats.mostRecentDate 212 + favoriteFragrance = stats.name 165 213 } 166 214 } 167 215 168 - let topHouseCount = 0 169 - for (const [_, info] of houseCounts.entries()) { 170 - if (info.count > topHouseCount) { 171 - topHouseCount = info.count 172 - favoriteHouse = info.name 216 + // Find favorite house by highest average score (with recency tiebreaker) 217 + let bestHouseScore = 0 218 + let bestHouseDate = '' 219 + for (const [_, stats] of houseStats.entries()) { 220 + const avgScore = stats.totalScore / stats.reviewCount 221 + if (avgScore > bestHouseScore || 222 + (avgScore === bestHouseScore && stats.mostRecentDate > bestHouseDate)) { 223 + bestHouseScore = avgScore 224 + bestHouseDate = stats.mostRecentDate 225 + favoriteHouse = stats.name 173 226 } 174 227 } 175 228 } ··· 220 273 </div> 221 274 <div class="score-item"> 222 275 <div className="score-label">Avg Rating</div> 223 - <div className="score-value">{numReviews > 0 ? `${avgRating} ★` : 'N/A'}</div> 276 + <div className="score-value">{numReviews > 0 ? `${avgRating.toFixed(1)} ★` : 'N/A'}</div> 224 277 </div> 225 278 <div class="score-item" style={{ gridColumn: 'span 2' }}> 226 279 <div className="score-label">Favorite Fragrance</div>
+6 -6
src/components/ReviewDashboard.tsx
··· 9 9 interface ReviewDashboardProps { 10 10 session: OAuthSession 11 11 onCreateNew: () => void 12 - onEditReview: (uri: string, stage: 'stage2' | 'stage3') => void 12 + onEditReview: (uri: string) => void 13 13 } 14 14 15 15 export function ReviewDashboard({ session, onCreateNew, onEditReview }: ReviewDashboardProps) { ··· 111 111 {isLoading ? ( 112 112 <div>Loading reviews...</div> 113 113 ) : ( 114 - <ReviewList 115 - reviews={reviews} 116 - fragrances={fragrances} 114 + <ReviewList 115 + reviews={reviews} 116 + fragrances={fragrances} 117 117 onReviewClick={(review) => { 118 118 const { action } = getReviewActionState(review.value) 119 - 119 + 120 120 if (action) { 121 - onEditReview(review.uri, action) 121 + onEditReview(review.uri) 122 122 } else { 123 123 // If there's no immediate action available (e.g. completed, past, or waiting on timer) 124 124 // Open the read-only view. The read-only view will have an "Edit" button if they manually want to trigger a bypass/wait anyway.
+12 -13
src/components/SingleReviewPage.tsx
··· 5 5 import { SEO } from '../components/SEO' 6 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 7 import { cache, TTL } from '../services/cache' 8 - import { getReviewActionState } from '../utils/reviewUtils' 8 + import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' 9 9 import type { OAuthSession } from '@atproto/oauth-client-browser' 10 10 11 11 /** ··· 164 164 const handleShare = () => { 165 165 if (!review || !fragrance) return 166 166 167 - const stars = '★'.repeat(review.overallRating) + '☆'.repeat(5 - review.overallRating) 167 + const displayScore = Math.round(getReviewDisplayScore(review)) 168 + const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 168 169 const link = window.location.href 169 170 const houseName = fragrance?.houseName || 'Unknown House' 170 171 ··· 225 226 226 227 const handleEdit = () => { 227 228 if (!review) return 228 - const { action } = getReviewActionState(review) 229 - // If an action implies an edit stage, use it. 230 - // If there's no action (or it's complete, but we want to let them edit the text/ratings manually within 24hr), default to stage3. 231 - const editStage = action || 'stage3' 232 - setLocation(`/?edit=${rkey}&stage=${editStage}`) 229 + const rkey = review.uri || window.location.pathname.split('/').pop() 230 + setLocation(`/?edit=${rkey}`) 233 231 } 234 232 235 233 const handleDelete = async () => { ··· 263 261 264 262 265 263 const fragName = fragrance?.name || 'Unknown Fragrance' 266 - const stars = '★'.repeat(review.overallRating || review.drydownRating || review.openingRating || 0) + '☆'.repeat(5 - (review.overallRating || review.drydownRating || review.openingRating || 0)) 264 + const displayScore = Math.round(getReviewDisplayScore(review)) 265 + const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 267 266 const metaDescription = `${stars} ${review.text ? review.text.slice(0, 150) + (review.text.length > 150 ? '...' : '') : 'Read this review on Drydown.'}` 268 267 269 268 const isOwnActiveReview = profile && session?.sub === profile.did && getReviewActionState(review).hint !== null ··· 339 338 <div class="review-content"> 340 339 <div className="review-meta"> 341 340 <div class="rating-large"> 342 - {review.overallRating}/5 341 + {Math.round(getReviewDisplayScore(review))}/5 343 342 </div> 344 343 <div className="review-date"> 345 344 Rated on {new Date(review.createdAt).toLocaleDateString()} ··· 350 349 {review.longevity && ( 351 350 <div class="score-item"> 352 351 <div className="score-label">Longevity</div> 353 - <div className="score-value">{review.longevity}/5</div> 352 + <div className="score-value">{Math.round(review.longevity)}/5</div> 354 353 </div> 355 354 )} 356 355 {review.sillage && ( 357 356 <div class="score-item"> 358 357 <div className="score-label">Sillage</div> 359 - <div className="score-value">{review.sillage}/5</div> 358 + <div className="score-value">{Math.round(review.sillage)}/5</div> 360 359 </div> 361 360 )} 362 361 {review.complexity && ( 363 362 <div class="score-item"> 364 363 <div className="score-label">Complexity</div> 365 - <div className="score-value">{review.complexity}/5</div> 364 + <div className="score-value">{Math.round(review.complexity)}/5</div> 366 365 </div> 367 366 )} 368 367 {review.subjectiveValue && ( 369 368 <div class="score-item"> 370 369 <div className="score-label">Value</div> 371 - <div className="score-value">{review.subjectiveValue}/5</div> 370 + <div className="score-value">{Math.round(review.subjectiveValue)}/5</div> 372 371 </div> 373 372 )} 374 373 {review.elevation !== undefined && (
+12
src/utils/reviewUtils.ts
··· 157 157 // If both are completed, no action, but still editable 158 158 return { action: null, hint: 'Review complete' } 159 159 } 160 + 161 + /** 162 + * Get the display score for a review, handling all cases 163 + * Returns the calculated weighted score whether it's stored or needs to be computed 164 + */ 165 + export function getReviewDisplayScore(review: any): number { 166 + if (review.weightedScore) { 167 + return decodeWeightedScore(review.weightedScore) 168 + } 169 + // Fallback: calculate from available ratings 170 + return calculateWeightedScore(review) 171 + }
+110
src/utils/reviewValidation.ts
··· 1 + import { calculateElapsedHours } from './reviewUtils' 2 + 3 + export interface EditValidationResult { 4 + canEditStage1: boolean 5 + canEditStage2: boolean 6 + canEditStage3: boolean 7 + canAddStage2: boolean 8 + canAddStage3: boolean 9 + error?: string 10 + } 11 + 12 + /** 13 + * Determines what stages can be edited based on review state and elapsed time 14 + */ 15 + export function validateEditPermissions(review: any): EditValidationResult { 16 + const elapsed = calculateElapsedHours(review.createdAt) 17 + 18 + // After 24 hours, nothing can be edited 19 + if (elapsed >= 24) { 20 + return { 21 + canEditStage1: false, 22 + canEditStage2: false, 23 + canEditStage3: false, 24 + canAddStage2: false, 25 + canAddStage3: false, 26 + error: 'Reviews cannot be edited after 24 hours' 27 + } 28 + } 29 + 30 + const hasStage1 = !!(review.openingRating && review.openingProjection) 31 + const hasStage2 = !!(review.drydownRating && review.midProjection && review.sillage) 32 + const hasStage3 = !!(review.endRating && review.complexity && review.longevity && review.overallRating) 33 + 34 + // Stage 1 can always be edited within 24 hours 35 + const canEditStage1 = hasStage1 36 + 37 + // Stage 2 can be edited if it exists, or added after 1.5 hours 38 + const canEditStage2 = hasStage2 39 + const canAddStage2 = !hasStage2 && elapsed >= 1.5 40 + 41 + // Stage 3 can be edited if it exists, or added after 4 hours 42 + const canEditStage3 = hasStage3 43 + const canAddStage3 = !hasStage3 && elapsed >= 4 44 + 45 + return { 46 + canEditStage1, 47 + canEditStage2, 48 + canEditStage3, 49 + canAddStage2, 50 + canAddStage3 51 + } 52 + } 53 + 54 + /** 55 + * Validates that a stage update is allowed based on timing and current state 56 + */ 57 + export function validateStageUpdate( 58 + review: any, 59 + stage: 'stage1' | 'stage2' | 'stage3' 60 + ): { valid: boolean; error?: string } { 61 + const permissions = validateEditPermissions(review) 62 + 63 + if (permissions.error) { 64 + return { valid: false, error: permissions.error } 65 + } 66 + 67 + if (stage === 'stage1') { 68 + // Stage 1 can always be edited within 24 hours 69 + if (!permissions.canEditStage1) { 70 + return { valid: false, error: 'Stage 1 cannot be edited after 24 hours' } 71 + } 72 + } 73 + 74 + if (stage === 'stage2') { 75 + if (!permissions.canEditStage2 && !permissions.canAddStage2) { 76 + const elapsed = calculateElapsedHours(review.createdAt) 77 + if (elapsed < 1.5) { 78 + const minsRemaining = Math.ceil((1.5 - elapsed) * 60) 79 + return { 80 + valid: false, 81 + error: `Heart notes are available in ${minsRemaining} minutes` 82 + } 83 + } 84 + return { valid: false, error: 'Stage 2 cannot be updated at this time' } 85 + } 86 + } 87 + 88 + if (stage === 'stage3') { 89 + if (!permissions.canEditStage3 && !permissions.canAddStage3) { 90 + const elapsed = calculateElapsedHours(review.createdAt) 91 + if (elapsed < 4) { 92 + const minsRemaining = Math.ceil((4 - elapsed) * 60) 93 + return { 94 + valid: false, 95 + error: `Final notes are available in ${minsRemaining} minutes` 96 + } 97 + } 98 + const hasStage2 = !!(review.drydownRating && review.midProjection && review.sillage) 99 + if (!hasStage2) { 100 + return { 101 + valid: false, 102 + error: 'Stage 2 must be completed before adding Stage 3' 103 + } 104 + } 105 + return { valid: false, error: 'Stage 3 cannot be updated at this time' } 106 + } 107 + } 108 + 109 + return { valid: true } 110 + }