tangled
alpha
login
or
join now
taurean.bryant.land
/
drydown
1
fork
atom
a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere.
drydown.social
1
fork
atom
overview
issues
pulls
pipelines
fixing "change all reviews" button functionality
taurean.bryant.land
2 weeks ago
842e1621
cdcefa3c
+348
-6
7 changed files
expand all
collapse all
unified
split
src
api
reviews.ts
components
ConfirmDialog.css
ConfirmDialog.tsx
ReviewDashboard.tsx
SettingsPage.tsx
services
cache.ts
utils
reviewUtils.ts
+57
src/api/reviews.ts
···
1
import type { AtUri } from '@/types/lexicon-types'
2
import type { AtpBaseClient } from '../client/index'
0
3
4
/**
5
* Update review's fragrance connection (migration)
···
50
value: r.value
51
}))
52
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
import type { AtUri } from '@/types/lexicon-types'
2
import type { AtpBaseClient } from '../client/index'
3
+
import { encodeWeightedScore, calculateIdealBasedScore } from '../utils/reviewUtils'
4
5
/**
6
* Update review's fragrance connection (migration)
···
51
value: r.value
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
import { getReviewActionState } from '../utils/reviewUtils'
8
import { NotificationService } from '../services/notificationService'
9
import { Button } from './Button'
0
10
11
interface ReviewDashboardProps {
12
session: OAuthSession
···
22
23
const notifiedStages = useRef<Record<string, boolean>>({})
24
const isFirstRender = useRef(true)
0
0
0
25
26
27
···
133
setLocation(`/profile/${session.sub}/review/${rkey}`)
134
}
135
}}
0
0
136
/>
137
)}
138
</div>
···
7
import { getReviewActionState } from '../utils/reviewUtils'
8
import { NotificationService } from '../services/notificationService'
9
import { Button } from './Button'
10
+
import { useUserPreferences } from '../hooks/useUserPreferences'
11
12
interface ReviewDashboardProps {
13
session: OAuthSession
···
23
24
const notifiedStages = useRef<Record<string, boolean>>({})
25
const isFirstRender = useRef(true)
26
+
27
+
// Load user preferences for personalized scoring
28
+
const { preferences } = useUserPreferences(session)
29
30
31
···
137
setLocation(`/profile/${session.sub}/review/${rkey}`)
138
}
139
}}
140
+
viewerPreferences={preferences || undefined}
141
+
viewerDid={session.sub}
142
/>
143
)}
144
</div>
+99
-2
src/components/SettingsPage.tsx
···
8
import { Footer } from './Footer'
9
import { refreshUserPreferences } from '../hooks/useUserPreferences'
10
import { Button } from './Button'
0
0
0
0
11
12
interface SettingsPageProps {
13
session: OAuthSession | null
···
39
const [isSaving, setIsSaving] = useState(false)
40
const [error, setError] = useState<string | null>(null)
41
const [successMessage, setSuccessMessage] = useState<string | null>(null)
0
0
42
43
// Redirect to home if not logged in
44
useEffect(() => {
···
136
const client = new AtpBaseClient(session.fetchHandler.bind(session))
137
const now = new Date().toISOString()
138
139
-
const record = {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
140
presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle,
141
longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority,
142
complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference,
143
scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach,
144
scoreLens: settings.scoreLens,
145
-
createdAt: originalSettings ? undefined : now, // Only set on first create
146
updatedAt: now,
147
}
148
···
172
setLocation('/')
173
}
174
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
175
if (!session) {
176
return null // Will redirect
177
}
···
247
</div>
248
</div>
249
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
250
{error && (
251
<div className="error-message" role="alert">
252
{error}
···
281
</div>
282
283
<Footer session={session} />
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
284
</div>
285
)
286
}
···
8
import { Footer } from './Footer'
9
import { refreshUserPreferences } from '../hooks/useUserPreferences'
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'
15
16
interface SettingsPageProps {
17
session: OAuthSession | null
···
43
const [isSaving, setIsSaving] = useState(false)
44
const [error, setError] = useState<string | null>(null)
45
const [successMessage, setSuccessMessage] = useState<string | null>(null)
46
+
const [showRecalculateConfirm, setShowRecalculateConfirm] = useState(false)
47
+
const [isRecalculating, setIsRecalculating] = useState(false)
48
49
// Redirect to home if not logged in
50
useEffect(() => {
···
142
const client = new AtpBaseClient(session.fetchHandler.bind(session))
143
const now = new Date().toISOString()
144
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 = {
161
presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle,
162
longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority,
163
complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference,
164
scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach,
165
scoreLens: settings.scoreLens,
166
+
createdAt: existingCreatedAt, // Always include createdAt (required field)
167
updatedAt: now,
168
}
169
···
193
setLocation('/')
194
}
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
+
237
if (!session) {
238
return null // Will redirect
239
}
···
309
</div>
310
</div>
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
+
330
{error && (
331
<div className="error-message" role="alert">
332
{error}
···
361
</div>
362
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
+
/>
381
</div>
382
)
383
}
+8
src/services/cache.ts
···
20
this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
21
}
22
0
0
0
0
0
0
0
0
23
async getOrFetch<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
24
const cached = this.get<T>(key)
25
if (cached !== null) return cached
···
20
this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
21
}
22
23
+
delete(key: string): void {
24
+
this.store.delete(key)
25
+
}
26
+
27
+
clear(): void {
28
+
this.store.clear()
29
+
}
30
+
31
async getOrFetch<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
32
const cached = this.get<T>(key)
33
if (cached !== null) return cached
+13
-4
src/utils/reviewUtils.ts
···
285
export function getPersonalizedScore(
286
review: any,
287
userPreferences?: UserPreferencesForScoring,
288
-
isOwnReview: boolean = false
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') {
0
0
0
0
0
0
0
0
292
return {
293
score: getReviewDisplayScore(review),
294
isPersonalized: false,
295
}
296
}
297
298
-
// Recalculate with user's ideal scores
0
299
return {
300
score: calculateIdealBasedScore(review, userPreferences),
301
isPersonalized: true,
···
285
export function getPersonalizedScore(
286
review: any,
287
userPreferences?: UserPreferencesForScoring,
288
+
_isOwnReview: boolean = false
289
): { score: number; isPersonalized: boolean } {
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') {
300
return {
301
score: getReviewDisplayScore(review),
302
isPersonalized: false,
303
}
304
}
305
306
+
// User wants scores through their current preference lens
307
+
// This applies to BOTH own reviews AND others' reviews
308
return {
309
score: calculateIdealBasedScore(review, userPreferences),
310
isPersonalized: true,