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