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

fixing "change all reviews" button functionality

+348 -6
+57
src/api/reviews.ts
··· 1 1 import type { AtUri } from '@/types/lexicon-types' 2 2 import type { AtpBaseClient } from '../client/index' 3 + import { encodeWeightedScore, calculateIdealBasedScore } from '../utils/reviewUtils' 3 4 4 5 /** 5 6 * Update review's fragrance connection (migration) ··· 50 51 value: r.value 51 52 })) 52 53 } 54 + 55 + /** 56 + * Recalculate scores for all of a user's reviews. 57 + * Updates the weightedScore field for each review using user's current preferences. 58 + * 59 + * @param client - AT Protocol client 60 + * @param did - User's DID 61 + * @param userPreferences - User's current preferences for scoring 62 + * @returns Object with total count and updated count 63 + */ 64 + export async function recalculateAllReviewScores( 65 + client: AtpBaseClient, 66 + did: string, 67 + userPreferences: import('../utils/reviewUtils').UserPreferencesForScoring 68 + ): Promise<{ total: number; updated: number }> { 69 + // Fetch all reviews 70 + const reviews = await listReviews(client, did) 71 + 72 + console.log(`[Recalculate] Found ${reviews.length} reviews to process`) 73 + 74 + let updatedCount = 0 75 + 76 + // Process each review 77 + for (const review of reviews) { 78 + const rkey = review.uri.split('/').pop()! 79 + 80 + // Recalculate score with user's CURRENT preferences 81 + const oldScore = review.value.weightedScore 82 + const newScore = calculateIdealBasedScore(review.value, userPreferences) 83 + const encodedScore = encodeWeightedScore(newScore) 84 + 85 + console.log(`[Recalculate] Review ${rkey}: old=${oldScore}, new=${encodedScore}, calculated=${newScore}`) 86 + 87 + // Always update to ensure new algorithm is applied 88 + // (Don't skip even if score is same, as algorithm may have changed) 89 + const updated = { 90 + ...review.value, 91 + weightedScore: encodedScore, 92 + updatedAt: new Date().toISOString() 93 + } 94 + 95 + await client.social.drydown.review.put({ 96 + repo: did, 97 + rkey 98 + }, updated) 99 + 100 + updatedCount++ 101 + } 102 + 103 + console.log(`[Recalculate] Updated ${updatedCount} of ${reviews.length} reviews`) 104 + 105 + return { 106 + total: reviews.length, 107 + updated: updatedCount 108 + } 109 + }
+70
src/components/ConfirmDialog.css
··· 1 + /* Confirm Dialog Overlay */ 2 + .confirm-dialog-overlay { 3 + position: fixed; 4 + inset: 0; 5 + z-index: 1000; 6 + background-color: rgba(0, 0, 0, 0.5); 7 + backdrop-filter: blur(4px); 8 + display: flex; 9 + align-items: center; 10 + justify-content: center; 11 + padding: var(--space-4); 12 + } 13 + 14 + /* Dialog Container */ 15 + .confirm-dialog { 16 + background: var(--bg-page); 17 + border: 1px solid var(--border-primary); 18 + border-radius: var(--radius-lg); 19 + padding: var(--space-6); 20 + max-width: 28rem; 21 + width: 100%; 22 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 23 + 0 10px 10px -5px rgba(0, 0, 0, 0.04); 24 + } 25 + 26 + /* Dialog Title */ 27 + .confirm-dialog-title { 28 + margin: 0 0 var(--space-3); 29 + font-size: var(--text-lg); 30 + font-weight: 600; 31 + color: var(--text-primary); 32 + } 33 + 34 + /* Dialog Message */ 35 + .confirm-dialog-message { 36 + margin-bottom: var(--space-6); 37 + font-size: var(--text-base); 38 + color: var(--text-primary); 39 + line-height: 1.5; 40 + } 41 + 42 + .confirm-dialog-message p { 43 + margin: 0 0 var(--space-3); 44 + } 45 + 46 + .confirm-dialog-message p:last-child { 47 + margin-bottom: 0; 48 + } 49 + 50 + /* Dialog Actions */ 51 + .confirm-dialog-actions { 52 + display: flex; 53 + gap: var(--space-3); 54 + justify-content: flex-end; 55 + } 56 + 57 + /* Responsive adjustments */ 58 + @media (max-width: 640px) { 59 + .confirm-dialog { 60 + padding: var(--space-5); 61 + } 62 + 63 + .confirm-dialog-actions { 64 + flex-direction: column-reverse; 65 + } 66 + 67 + .confirm-dialog-actions .btn { 68 + width: 100%; 69 + } 70 + }
+95
src/components/ConfirmDialog.tsx
··· 1 + import type { ComponentChildren } from 'preact' 2 + import { useEffect } from 'preact/hooks' 3 + import { Button } from './Button' 4 + import './ConfirmDialog.css' 5 + 6 + interface ConfirmDialogProps { 7 + isOpen: boolean 8 + title: string 9 + message: ComponentChildren 10 + confirmText?: string 11 + cancelText?: string 12 + onConfirm: () => void 13 + onCancel: () => void 14 + isDestructive?: boolean 15 + } 16 + 17 + export function ConfirmDialog({ 18 + isOpen, 19 + title, 20 + message, 21 + confirmText = 'Confirm', 22 + cancelText = 'Cancel', 23 + onConfirm, 24 + onCancel, 25 + isDestructive = false, 26 + }: ConfirmDialogProps) { 27 + // Close on escape key 28 + useEffect(() => { 29 + if (!isOpen) return 30 + 31 + const handleEscape = (e: KeyboardEvent) => { 32 + if (e.key === 'Escape') { 33 + onCancel() 34 + } 35 + } 36 + 37 + window.addEventListener('keydown', handleEscape) 38 + return () => window.removeEventListener('keydown', handleEscape) 39 + }, [isOpen, onCancel]) 40 + 41 + // Prevent body scroll when open 42 + useEffect(() => { 43 + if (isOpen) { 44 + document.body.style.overflow = 'hidden' 45 + } else { 46 + document.body.style.overflow = '' 47 + } 48 + 49 + return () => { 50 + document.body.style.overflow = '' 51 + } 52 + }, [isOpen]) 53 + 54 + if (!isOpen) return null 55 + 56 + return ( 57 + <div className="confirm-dialog-overlay" onClick={onCancel}> 58 + <div 59 + className="confirm-dialog" 60 + onClick={(e) => e.stopPropagation()} 61 + role="dialog" 62 + aria-modal="true" 63 + aria-labelledby="confirm-dialog-title" 64 + aria-describedby="confirm-dialog-message" 65 + > 66 + <h2 id="confirm-dialog-title" className="confirm-dialog-title"> 67 + {title} 68 + </h2> 69 + 70 + <div id="confirm-dialog-message" className="confirm-dialog-message"> 71 + {message} 72 + </div> 73 + 74 + <div className="confirm-dialog-actions"> 75 + <Button 76 + emphasis="muted" 77 + size="sm" 78 + onClick={onCancel} 79 + context="cancel" 80 + > 81 + {cancelText} 82 + </Button> 83 + <Button 84 + emphasis={isDestructive ? 'strong' : 'brand'} 85 + size="sm" 86 + onClick={onConfirm} 87 + context={isDestructive ? 'destructive' : 'save'} 88 + > 89 + {confirmText} 90 + </Button> 91 + </div> 92 + </div> 93 + </div> 94 + ) 95 + }
+6
src/components/ReviewDashboard.tsx
··· 7 7 import { getReviewActionState } from '../utils/reviewUtils' 8 8 import { NotificationService } from '../services/notificationService' 9 9 import { Button } from './Button' 10 + import { useUserPreferences } from '../hooks/useUserPreferences' 10 11 11 12 interface ReviewDashboardProps { 12 13 session: OAuthSession ··· 22 23 23 24 const notifiedStages = useRef<Record<string, boolean>>({}) 24 25 const isFirstRender = useRef(true) 26 + 27 + // Load user preferences for personalized scoring 28 + const { preferences } = useUserPreferences(session) 25 29 26 30 27 31 ··· 133 137 setLocation(`/profile/${session.sub}/review/${rkey}`) 134 138 } 135 139 }} 140 + viewerPreferences={preferences || undefined} 141 + viewerDid={session.sub} 136 142 /> 137 143 )} 138 144 </div>
+99 -2
src/components/SettingsPage.tsx
··· 8 8 import { Footer } from './Footer' 9 9 import { refreshUserPreferences } from '../hooks/useUserPreferences' 10 10 import { Button } from './Button' 11 + import { ConfirmDialog } from './ConfirmDialog' 12 + import { recalculateAllReviewScores } from '../api/reviews' 13 + import type { UserPreferencesForScoring } from '../utils/reviewUtils' 14 + import { cache } from '../services/cache' 11 15 12 16 interface SettingsPageProps { 13 17 session: OAuthSession | null ··· 39 43 const [isSaving, setIsSaving] = useState(false) 40 44 const [error, setError] = useState<string | null>(null) 41 45 const [successMessage, setSuccessMessage] = useState<string | null>(null) 46 + const [showRecalculateConfirm, setShowRecalculateConfirm] = useState(false) 47 + const [isRecalculating, setIsRecalculating] = useState(false) 42 48 43 49 // Redirect to home if not logged in 44 50 useEffect(() => { ··· 136 142 const client = new AtpBaseClient(session.fetchHandler.bind(session)) 137 143 const now = new Date().toISOString() 138 144 139 - const record = { 145 + // Fetch existing settings to get createdAt if this is an update 146 + let existingCreatedAt: string = now 147 + if (originalSettings) { 148 + try { 149 + const existing = await client.social.drydown.settings.get({ 150 + repo: session.sub, 151 + rkey: 'self', 152 + }) 153 + existingCreatedAt = existing.value.createdAt 154 + } catch (e) { 155 + // If we can't fetch, use now (shouldn't happen since originalSettings exists) 156 + console.warn('Could not fetch existing createdAt, using current time', e) 157 + } 158 + } 159 + 160 + const record: any = { 140 161 presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle, 141 162 longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority, 142 163 complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference, 143 164 scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach, 144 165 scoreLens: settings.scoreLens, 145 - createdAt: originalSettings ? undefined : now, // Only set on first create 166 + createdAt: existingCreatedAt, // Always include createdAt (required field) 146 167 updatedAt: now, 147 168 } 148 169 ··· 172 193 setLocation('/') 173 194 } 174 195 196 + const handleRecalculateScores = async () => { 197 + if (!session) return 198 + 199 + try { 200 + setIsRecalculating(true) 201 + setError(null) 202 + setSuccessMessage(null) 203 + setShowRecalculateConfirm(false) 204 + 205 + const client = new AtpBaseClient(session.fetchHandler.bind(session)) 206 + 207 + // Convert current settings to preferences format 208 + const currentPreferences: UserPreferencesForScoring = { 209 + presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle, 210 + longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority, 211 + complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference, 212 + scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach, 213 + scoreLens: settings.scoreLens, 214 + } 215 + 216 + // Pass user's CURRENT preferences to recalculation 217 + const result = await recalculateAllReviewScores(client, session.sub, currentPreferences) 218 + 219 + // Invalidate reviews cache so updated scores are immediately visible 220 + cache.delete(`reviews:${session.sub}`) 221 + 222 + if (result.updated === 0) { 223 + setSuccessMessage('All review scores are already up to date.') 224 + } else { 225 + setSuccessMessage( 226 + `Successfully recalculated ${result.updated} of ${result.total} review${result.total === 1 ? '' : 's'}. Return to dashboard to see updated scores.` 227 + ) 228 + } 229 + } catch (e) { 230 + console.error('Failed to recalculate review scores', e) 231 + setError('Failed to recalculate review scores. Please try again.') 232 + } finally { 233 + setIsRecalculating(false) 234 + } 235 + } 236 + 175 237 if (!session) { 176 238 return null // Will redirect 177 239 } ··· 247 309 </div> 248 310 </div> 249 311 312 + <div className="settings-section"> 313 + <h2 className="settings-section-title">Review Score Management</h2> 314 + <p className="settings-section-description"> 315 + Recalculate the scores for all your reviews using the current ideal score anchoring algorithm. 316 + This updates the stored scores to match the latest scoring system. 317 + </p> 318 + 319 + <Button 320 + emphasis="muted" 321 + size="sm" 322 + onClick={() => setShowRecalculateConfirm(true)} 323 + disabled={isRecalculating || isSaving} 324 + context="save" 325 + > 326 + {isRecalculating ? 'Recalculating...' : 'Recalculate All Review Scores'} 327 + </Button> 328 + </div> 329 + 250 330 {error && ( 251 331 <div className="error-message" role="alert"> 252 332 {error} ··· 281 361 </div> 282 362 283 363 <Footer session={session} /> 364 + 365 + <ConfirmDialog 366 + isOpen={showRecalculateConfirm} 367 + title="Recalculate Review Scores?" 368 + message={ 369 + <> 370 + <p>This will update the scores for all your reviews using your <strong>current preferences</strong>.</p> 371 + <p>Your ratings won't change — only the calculated scores will be updated to reflect your current ideal preferences.</p> 372 + <p>To see how your reviews match your current preferences without saving, use the "Your preferences" score display option instead.</p> 373 + </> 374 + } 375 + confirmText="Recalculate with My Preferences" 376 + cancelText="Cancel" 377 + onConfirm={handleRecalculateScores} 378 + onCancel={() => setShowRecalculateConfirm(false)} 379 + isDestructive={false} 380 + /> 284 381 </div> 285 382 ) 286 383 }
+8
src/services/cache.ts
··· 20 20 this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) 21 21 } 22 22 23 + delete(key: string): void { 24 + this.store.delete(key) 25 + } 26 + 27 + clear(): void { 28 + this.store.clear() 29 + } 30 + 23 31 async getOrFetch<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> { 24 32 const cached = this.get<T>(key) 25 33 if (cached !== null) return cached
+13 -4
src/utils/reviewUtils.ts
··· 285 285 export function getPersonalizedScore( 286 286 review: any, 287 287 userPreferences?: UserPreferencesForScoring, 288 - isOwnReview: boolean = false 288 + _isOwnReview: boolean = false 289 289 ): { score: number; isPersonalized: boolean } { 290 - // If no preferences or viewing own review or lens is "theirs", use original score 291 - if (!userPreferences || isOwnReview || userPreferences.scoreLens !== 'mine') { 290 + // No preferences available - use stored score 291 + if (!userPreferences) { 292 + return { 293 + score: getReviewDisplayScore(review), 294 + isPersonalized: false, 295 + } 296 + } 297 + 298 + // User wants original scores (theirs or their own original) 299 + if (userPreferences.scoreLens === 'theirs') { 292 300 return { 293 301 score: getReviewDisplayScore(review), 294 302 isPersonalized: false, 295 303 } 296 304 } 297 305 298 - // Recalculate with user's ideal scores 306 + // User wants scores through their current preference lens 307 + // This applies to BOTH own reviews AND others' reviews 299 308 return { 300 309 score: calculateIdealBasedScore(review, userPreferences), 301 310 isPersonalized: true,