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

creating a settings page + factoring in preferences for weighted scores

+1449 -121
+1 -1
public/client-metadata.json
··· 8 8 "redirect_uris": [ 9 9 "https://drydown.social/" 10 10 ], 11 - "scope": "atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*", 11 + "scope": "atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review repo:social.drydown.settings rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*", 12 12 "grant_types": [ 13 13 "authorization_code", 14 14 "refresh_token"
+270 -16
src/app.css
··· 5 5 --text-color: #000000; 6 6 --text-secondary: #666666; 7 7 --border-color: #dddddd; 8 - 8 + 9 9 --card-border: #dddddd; 10 10 --card-border-dashed: #cccccc; 11 - 11 + 12 12 --card-bg: #f5f5f5; 13 13 --card-bg-interactive: #ffffff; 14 14 --card-bg-hover: #f0f8ff; 15 - 15 + 16 16 --status-ready: #0066cc; 17 17 --status-completed: #00aa00; 18 18 --status-inprogress: #666666; 19 - 19 + 20 20 --star-color: #ff9900; 21 + 22 + --site-max-width: 720px; 21 23 } 22 24 23 25 @media (prefers-color-scheme: dark) { ··· 51 53 52 54 #app { 53 55 width: 100%; 54 - max-width: 600px; /* Enforce global max-width */ 56 + max-width: var(--site-max-width); 55 57 margin: 0 auto; 56 58 padding: 0; 57 - text-align: left; /* Default alignment */ 59 + text-align: left; 58 60 } 59 61 60 62 /* Page container can just control padding/box-sizing now */ ··· 74 76 padding: 2em; 75 77 } 76 78 77 - header { 79 + /* Site Header */ 80 + .site-header { 78 81 display: flex; 79 82 justify-content: space-between; 80 83 align-items: center; ··· 83 86 width: 100%; 84 87 } 85 88 86 - header h1 { 87 - margin: 0; 88 - font-size: 2.2rem; 89 + .site-logo { 90 + font-size: 1.5rem; 91 + font-weight: 800; 89 92 letter-spacing: -0.02em; 90 - font-weight: 800; 91 93 color: var(--text-color); 94 + text-decoration: none; 92 95 } 93 96 94 - header .user-info { 97 + .site-nav { 95 98 display: flex; 96 99 gap: 1rem; 97 100 align-items: center; 101 + } 102 + 103 + .nav-link { 98 104 font-size: 0.9rem; 99 105 color: var(--text-secondary); 106 + text-decoration: none; 107 + transition: color 0.15s ease; 100 108 } 101 109 102 - header .user-info .btn-primary { 110 + .nav-link:hover { 111 + color: var(--text-color); 112 + } 113 + 114 + .nav-link-username { 115 + color: var(--text-color); 116 + } 117 + 118 + .btn-small { 103 119 font-size: 0.85rem; 104 120 padding: 0.5rem 0.75rem; 121 + } 122 + 123 + /* Legacy header styles for page-specific headers */ 124 + header { 125 + display: flex; 126 + justify-content: space-between; 127 + align-items: center; 128 + margin-bottom: 1.5rem; 129 + padding: 0; 130 + width: 100%; 131 + } 132 + 133 + header h1 { 134 + margin: 0; 135 + font-size: 2.2rem; 136 + letter-spacing: -0.02em; 137 + font-weight: 800; 138 + color: var(--text-color); 105 139 } 106 140 107 141 /* Profile Avatar */ ··· 587 621 588 622 /* Login Form Styles */ 589 623 .login-form-card { 590 - max-width: 560px; 624 + max-width: var(--site-max-width); 591 625 margin: 0 auto; 592 626 padding: 2rem 2rem 1.5rem; 593 627 border: 1px solid var(--border-color); ··· 610 644 display: flex; 611 645 flex-direction: column; 612 646 gap: 1rem; 613 - max-width: 560px; 647 + max-width: var(--site-max-width); 614 648 margin: 0 auto; 615 649 } 616 650 ··· 760 794 } 761 795 762 796 .app-disclaimer.detailed { 763 - max-width: 500px; 797 + max-width: var(--site-max-width); 764 798 margin-left: auto; 765 799 margin-right: auto; 766 800 } ··· 922 956 .rubric-display-value.full { 923 957 max-width: 300px; 924 958 } 959 + 960 + /* Settings Page Styles */ 961 + .settings-page { 962 + width: 100%; 963 + } 964 + 965 + .settings-header { 966 + margin-bottom: 2rem; 967 + } 968 + 969 + .settings-header h1 { 970 + font-size: 2rem; 971 + font-weight: 800; 972 + letter-spacing: -0.02em; 973 + margin: 0 0 0.75rem 0; 974 + color: var(--text-color); 975 + } 976 + 977 + .settings-intro { 978 + font-size: 0.8rem; 979 + color: var(--text-secondary); 980 + line-height: 1.5; 981 + margin: 1rem 0 0 0; 982 + width: 54ch; 983 + padding-inline-start: 4em; 984 + } 985 + 986 + .settings-preferences { 987 + display: flex; 988 + flex-direction: column; 989 + gap: 2.5rem; 990 + margin-bottom: 2rem; 991 + } 992 + 993 + .settings-section { 994 + margin-bottom: 2rem; 995 + padding-top: 1.5rem; 996 + border-top: 1px solid var(--border-color); 997 + } 998 + 999 + .settings-section-title { 1000 + font-size: 1.25rem; 1001 + font-weight: 600; 1002 + margin: 0 0 0.5rem 0; 1003 + color: var(--text-color); 1004 + } 1005 + 1006 + .settings-section-description { 1007 + font-size: 0.9rem; 1008 + color: var(--text-secondary); 1009 + margin: 0 0 1rem 0; 1010 + line-height: 1.5; 1011 + } 1012 + 1013 + .settings-actions { 1014 + display: flex; 1015 + gap: 1rem; 1016 + justify-content: flex-end; 1017 + padding-top: 1.5rem; 1018 + border-top: 1px solid var(--border-color); 1019 + } 1020 + 1021 + /* Preference Selector Styles */ 1022 + .preference-selector { 1023 + margin-bottom: 0; 1024 + } 1025 + 1026 + .preference-header { 1027 + margin-bottom: 1rem; 1028 + } 1029 + 1030 + .preference-question { 1031 + font-size: 1.1rem; 1032 + font-weight: 600; 1033 + margin-bottom: 0.25rem; 1034 + color: var(--text-color); 1035 + } 1036 + 1037 + .preference-description { 1038 + font-size: 0.85rem; 1039 + color: var(--text-secondary); 1040 + line-height: 1.4; 1041 + } 1042 + 1043 + .preference-options { 1044 + display: flex; 1045 + flex-direction: column; 1046 + gap: 0.5rem; 1047 + } 1048 + 1049 + .preference-option { 1050 + display: flex; 1051 + align-items: center; 1052 + padding: 0.875rem 1rem; 1053 + border: 2px solid var(--card-border); 1054 + border-radius: 8px; 1055 + background: var(--card-bg); 1056 + cursor: pointer; 1057 + transition: border-color 0.15s ease, background-color 0.15s ease; 1058 + text-align: left; 1059 + width: 100%; 1060 + font-size: inherit; 1061 + font-family: inherit; 1062 + color: inherit; 1063 + } 1064 + 1065 + .preference-option:hover:not(.disabled) { 1066 + border-color: var(--status-ready); 1067 + background: var(--card-bg-hover); 1068 + } 1069 + 1070 + .preference-option.selected { 1071 + border-color: var(--status-ready); 1072 + background: var(--card-bg-hover); 1073 + } 1074 + 1075 + .preference-option:focus-visible { 1076 + outline: 2px solid var(--status-ready); 1077 + outline-offset: 2px; 1078 + } 1079 + 1080 + .preference-option.disabled { 1081 + opacity: 0.5; 1082 + cursor: not-allowed; 1083 + } 1084 + 1085 + .preference-option-text { 1086 + line-height: 1.4; 1087 + color: var(--text-color); 1088 + } 1089 + 1090 + /* Score Lens Toggle */ 1091 + .score-lens-options { 1092 + display: flex; 1093 + flex-direction: column; 1094 + gap: 0.5rem; 1095 + } 1096 + 1097 + .score-lens-option { 1098 + display: flex; 1099 + flex-direction: column; 1100 + align-items: flex-start; 1101 + padding: 1rem; 1102 + border: 2px solid var(--card-border); 1103 + border-radius: 8px; 1104 + background: var(--card-bg); 1105 + cursor: pointer; 1106 + transition: border-color 0.15s ease, background-color 0.15s ease; 1107 + text-align: left; 1108 + width: 100%; 1109 + font-size: inherit; 1110 + font-family: inherit; 1111 + color: inherit; 1112 + } 1113 + 1114 + .score-lens-option:hover:not(:disabled) { 1115 + border-color: var(--status-ready); 1116 + background: var(--card-bg-hover); 1117 + } 1118 + 1119 + .score-lens-option.selected { 1120 + border-color: var(--status-ready); 1121 + background: var(--card-bg-hover); 1122 + } 1123 + 1124 + .score-lens-option:focus-visible { 1125 + outline: 2px solid var(--status-ready); 1126 + outline-offset: 2px; 1127 + } 1128 + 1129 + .score-lens-option:disabled { 1130 + opacity: 0.5; 1131 + cursor: not-allowed; 1132 + } 1133 + 1134 + .score-lens-label { 1135 + font-weight: 600; 1136 + color: var(--text-color); 1137 + margin-bottom: 0.25rem; 1138 + } 1139 + 1140 + .score-lens-description { 1141 + font-size: 0.85rem; 1142 + color: var(--text-secondary); 1143 + line-height: 1.4; 1144 + } 1145 + 1146 + /* Success Message */ 1147 + .success-message { 1148 + background: #d4edda; 1149 + color: #155724; 1150 + padding: 0.75rem 1rem; 1151 + border-radius: 8px; 1152 + margin-bottom: 1.5rem; 1153 + font-size: 0.9rem; 1154 + } 1155 + 1156 + @media (prefers-color-scheme: dark) { 1157 + .success-message { 1158 + background: #1e3a1e; 1159 + color: #75d675; 1160 + } 1161 + } 1162 + 1163 + /* Touch targets for preference options */ 1164 + @media (pointer: coarse) { 1165 + .preference-option, 1166 + .score-lens-option { 1167 + min-height: 56px; 1168 + padding: 1rem; 1169 + } 1170 + } 1171 + 1172 + /* Personalized score indicator */ 1173 + .personalized-score-indicator { 1174 + font-size: 0.75rem; 1175 + color: var(--text-secondary); 1176 + margin-top: 0.25rem; 1177 + font-style: italic; 1178 + }
+13 -23
src/app.tsx
··· 11 11 import { HousePage } from './components/HousePage' 12 12 import { EditHousePage } from './components/EditHousePage' 13 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 + import { SettingsPage } from './components/SettingsPage' 15 + import { Header } from './components/Header' 14 16 import { Footer } from './components/Footer' 15 17 import type { OAuthSession } from '@atproto/oauth-client-browser' 16 18 import { AtpBaseClient } from './client/index' 17 - import { Route, Switch, Link } from 'wouter' 19 + import { Route, Switch } from 'wouter' 18 20 import { cache, TTL } from './services/cache' 19 21 20 22 interface HomeProps { ··· 57 59 <LandingPage /> 58 60 ) : ( 59 61 <> 60 - <header> 61 - <Link href="/" onClick={handleBackToDashboard} style={{ textDecoration: 'none', color: 'inherit' }}> 62 - <h1>Drydown</h1> 63 - </Link> 64 - <div class="user-info"> 65 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8 }}> 66 - Explore 67 - </Link> 68 - <Link href={`/profile/${userProfile?.handle || session.sub}/reviews`} style={{ textDecoration: 'none', color: 'inherit' }}> 69 - {userProfile?.displayName || userProfile?.handle || session.sub} 70 - </Link> 71 - <button onClick={onLogout} class="btn-primary"> 72 - sign out 73 - </button> 74 - </div> 75 - </header> 62 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 76 63 77 64 <div class="card"> 78 65 {view === 'home' ? ( ··· 202 189 {() => <Home session={session} userProfile={userProfile} onLogout={handleLogout} />} 203 190 </Route> 204 191 <Route path="/explore"> 205 - {() => <ExplorePage />} 192 + {() => <ExplorePage session={session} userProfile={userProfile} onLogout={handleLogout} />} 193 + </Route> 194 + <Route path="/preferences"> 195 + {() => <SettingsPage session={session} userProfile={userProfile} onLogout={handleLogout} />} 206 196 </Route> 207 197 <Route path="/profile/:handle/reviews"> 208 - {(params) => <ProfilePage handle={params.handle} />} 198 + {(params) => <ProfilePage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 209 199 </Route> 210 200 <Route path="/profile/:handle/houses"> 211 - {(params) => <ProfileHousesPage handle={params.handle} />} 201 + {(params) => <ProfileHousesPage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 212 202 </Route> 213 203 <Route path="/profile/:handle/review/:rkey"> 214 - {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} />} 204 + {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 215 205 </Route> 216 206 <Route path="/profile/:handle/house/:rkey"> 217 - {(params) => <HousePage handle={params.handle} rkey={params.rkey} session={session} />} 207 + {(params) => <HousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 218 208 </Route> 219 209 <Route path="/profile/:handle/house/:rkey/edit"> 220 - {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} />} 210 + {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 221 211 </Route> 222 212 {/* Fallback to Home for now, or 404 */} 223 213 <Route path="/:rest*">
+90 -4
src/client/index.ts
··· 6 6 type FetchHandler, 7 7 type FetchHandlerOptions, 8 8 } from '@atproto/xrpc' 9 - import { schemas } from './lexicons.js' 10 - import { type OmitKey, type Un$Typed } from './util.js' 11 9 import { 12 10 ComAtprotoRepoListRecords, 13 11 ComAtprotoRepoGetRecord, ··· 15 13 ComAtprotoRepoPutRecord, 16 14 ComAtprotoRepoDeleteRecord, 17 15 } from '@atproto/api' 16 + import { schemas as bskySchemas } from '@atproto/api' 17 + import { schemas } from './lexicons.js' 18 + import { type OmitKey, type Un$Typed } from './util.js' 18 19 import * as SocialDrydownFragrance from './types/social/drydown/fragrance.js' 19 20 import * as SocialDrydownHouse from './types/social/drydown/house.js' 20 21 import * as SocialDrydownReview from './types/social/drydown/review.js' 22 + import * as SocialDrydownSettings from './types/social/drydown/settings.js' 21 23 22 24 export * as SocialDrydownFragrance from './types/social/drydown/fragrance.js' 23 25 export * as SocialDrydownHouse from './types/social/drydown/house.js' 24 26 export * as SocialDrydownReview from './types/social/drydown/review.js' 25 - 26 - import { schemas as bskySchemas } from '@atproto/api' 27 + export * as SocialDrydownSettings from './types/social/drydown/settings.js' 27 28 28 29 export class AtpBaseClient extends XrpcClient { 29 30 social: SocialNS ··· 54 55 fragrance: SocialDrydownFragranceRecord 55 56 house: SocialDrydownHouseRecord 56 57 review: SocialDrydownReviewRecord 58 + settings: SocialDrydownSettingsRecord 57 59 58 60 constructor(client: XrpcClient) { 59 61 this._client = client 60 62 this.fragrance = new SocialDrydownFragranceRecord(client) 61 63 this.house = new SocialDrydownHouseRecord(client) 62 64 this.review = new SocialDrydownReviewRecord(client) 65 + this.settings = new SocialDrydownSettingsRecord(client) 63 66 } 64 67 } 65 68 ··· 303 306 ) 304 307 } 305 308 } 309 + 310 + export class SocialDrydownSettingsRecord { 311 + _client: XrpcClient 312 + 313 + constructor(client: XrpcClient) { 314 + this._client = client 315 + } 316 + 317 + async list( 318 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 319 + ): Promise<{ 320 + cursor?: string 321 + records: { uri: string; value: SocialDrydownSettings.Record }[] 322 + }> { 323 + const res = await this._client.call('com.atproto.repo.listRecords', { 324 + collection: 'social.drydown.settings', 325 + ...params, 326 + }) 327 + return res.data 328 + } 329 + 330 + async get( 331 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 332 + ): Promise<{ 333 + uri: string 334 + cid: string 335 + value: SocialDrydownSettings.Record 336 + }> { 337 + const res = await this._client.call('com.atproto.repo.getRecord', { 338 + collection: 'social.drydown.settings', 339 + ...params, 340 + }) 341 + return res.data 342 + } 343 + 344 + async create( 345 + params: OmitKey< 346 + ComAtprotoRepoCreateRecord.InputSchema, 347 + 'collection' | 'record' 348 + >, 349 + record: Un$Typed<SocialDrydownSettings.Record>, 350 + headers?: Record<string, string>, 351 + ): Promise<{ uri: string; cid: string }> { 352 + const collection = 'social.drydown.settings' 353 + const res = await this._client.call( 354 + 'com.atproto.repo.createRecord', 355 + undefined, 356 + { collection, ...params, record: { ...record, $type: collection } }, 357 + { encoding: 'application/json', headers }, 358 + ) 359 + return res.data 360 + } 361 + 362 + async put( 363 + params: OmitKey< 364 + ComAtprotoRepoPutRecord.InputSchema, 365 + 'collection' | 'record' 366 + >, 367 + record: Un$Typed<SocialDrydownSettings.Record>, 368 + headers?: Record<string, string>, 369 + ): Promise<{ uri: string; cid: string }> { 370 + const collection = 'social.drydown.settings' 371 + const res = await this._client.call( 372 + 'com.atproto.repo.putRecord', 373 + undefined, 374 + { collection, ...params, record: { ...record, $type: collection } }, 375 + { encoding: 'application/json', headers }, 376 + ) 377 + return res.data 378 + } 379 + 380 + async delete( 381 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 382 + headers?: Record<string, string>, 383 + ): Promise<void> { 384 + await this._client.call( 385 + 'com.atproto.repo.deleteRecord', 386 + undefined, 387 + { collection: 'social.drydown.settings', ...params }, 388 + { headers }, 389 + ) 390 + } 391 + }
+62
src/client/lexicons.ts
··· 216 216 }, 217 217 }, 218 218 }, 219 + SocialDrydownSettings: { 220 + lexicon: 1, 221 + id: 'social.drydown.settings', 222 + defs: { 223 + main: { 224 + type: 'record', 225 + description: 'User preferences for fragrance review scoring', 226 + key: 'self', 227 + record: { 228 + type: 'object', 229 + required: ['createdAt'], 230 + properties: { 231 + presenceStyle: { 232 + type: 'integer', 233 + minimum: 1, 234 + maximum: 5, 235 + description: 236 + 'How the user prefers to be noticed (1=skin scent, 5=bold presence)', 237 + }, 238 + longevityPriority: { 239 + type: 'integer', 240 + minimum: 1, 241 + maximum: 5, 242 + description: 243 + 'How important all-day longevity is (1=not important, 5=essential)', 244 + }, 245 + complexityPreference: { 246 + type: 'integer', 247 + minimum: 1, 248 + maximum: 5, 249 + description: 250 + 'Preference for fragrance complexity (1=simple, 5=intricate)', 251 + }, 252 + scoringApproach: { 253 + type: 'integer', 254 + minimum: 1, 255 + maximum: 5, 256 + description: 257 + 'How user evaluates fragrances (1=instinct, 5=analytical)', 258 + }, 259 + scoreLens: { 260 + type: 'string', 261 + knownValues: ['theirs', 'mine'], 262 + description: 263 + "When viewing others' reviews: show their score or recalculate with your preferences", 264 + }, 265 + createdAt: { 266 + type: 'string', 267 + format: 'datetime', 268 + description: 'Timestamp when settings were first created', 269 + }, 270 + updatedAt: { 271 + type: 'string', 272 + format: 'datetime', 273 + description: 'Timestamp when settings were last updated', 274 + }, 275 + }, 276 + }, 277 + }, 278 + }, 279 + }, 219 280 } as const satisfies Record<string, LexiconDoc> 220 281 export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 221 282 export const lexicons: Lexicons = new Lexicons(schemas) ··· 252 313 SocialDrydownFragrance: 'social.drydown.fragrance', 253 314 SocialDrydownHouse: 'social.drydown.house', 254 315 SocialDrydownReview: 'social.drydown.review', 316 + SocialDrydownSettings: 'social.drydown.settings', 255 317 } as const
+44
src/client/types/social/drydown/settings.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from '../../../lexicons' 5 + import { is$typed as _is$typed } from '../../../util' 6 + 7 + const is$typed = _is$typed, 8 + validate = _validate 9 + const id = 'social.drydown.settings' 10 + 11 + export interface Main { 12 + $type: 'social.drydown.settings' 13 + /** How the user prefers to be noticed (1=skin scent, 5=bold presence) */ 14 + presenceStyle?: number 15 + /** How important all-day longevity is (1=not important, 5=essential) */ 16 + longevityPriority?: number 17 + /** Preference for fragrance complexity (1=simple, 5=intricate) */ 18 + complexityPreference?: number 19 + /** How user evaluates fragrances (1=instinct, 5=analytical) */ 20 + scoringApproach?: number 21 + /** When viewing others' reviews: show their score or recalculate with your preferences */ 22 + scoreLens?: 'theirs' | 'mine' | (string & {}) 23 + /** Timestamp when settings were first created */ 24 + createdAt: string 25 + /** Timestamp when settings were last updated */ 26 + updatedAt?: string 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + }
+7 -6
src/components/EditHousePage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 - import { useLocation, Link } from 'wouter' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { AtpBaseClient } from '../client/index' 4 5 import { AppDisclaimer } from './AppDisclaimer' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 - import type { OAuthSession } from '@atproto/oauth-client-browser' 7 8 import { deleteHouse } from '../api/houses' 8 9 import { bulkUpdateFragranceHouse } from '../api/fragrances' 9 10 import { Combobox } from './Combobox' ··· 15 16 handle: string 16 17 rkey: string 17 18 session: OAuthSession | null 19 + userProfile?: { displayName?: string; handle: string } | null 20 + onLogout?: () => void 18 21 } 19 22 20 - export function EditHousePage({ handle, rkey, session }: EditHousePageProps) { 23 + export function EditHousePage({ handle, rkey, session, userProfile, onLogout }: EditHousePageProps) { 21 24 const [, setLocation] = useLocation() 22 25 const [houseName, setHouseName] = useState('') 23 26 const [isLoading, setIsLoading] = useState(true) ··· 216 219 217 220 return ( 218 221 <div className="page-container"> 219 - <nav className="main-nav" style={{ marginBottom: '2rem' }}> 220 - <Link href="/" className="nav-logo">Drydown</Link> 221 - </nav> 222 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 222 223 223 224 <h2>Edit House</h2> 224 225
+17 -10
src/components/ExplorePage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 - import { Link, useLocation } from 'wouter' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { SEO } from './SEO' 4 5 import { AppDisclaimer } from './AppDisclaimer' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 8 import { ReviewCard, type AuthorInfo } from './ReviewCard' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' 8 10 import { batchResolveAtUris } from '../utils/atUriUtils' 11 + import { useUserPreferences } from '../hooks/useUserPreferences' 9 12 10 13 interface HydratedReview { 11 14 uri: string ··· 15 18 author: AuthorInfo 16 19 } 17 20 21 + interface ExplorePageProps { 22 + session: OAuthSession | null 23 + userProfile?: { displayName?: string; handle: string } | null 24 + onLogout?: () => void 25 + } 26 + 18 27 const MICROCOSM_API = "https://ufos-api.microcosm.blue" 19 28 20 - export function ExplorePage() { 29 + export function ExplorePage({ session, userProfile, onLogout }: ExplorePageProps) { 21 30 const [, setLocation] = useLocation() 22 31 const [reviews, setReviews] = useState<HydratedReview[]>([]) 23 32 const [isLoading, setIsLoading] = useState(true) 24 33 const [error, setError] = useState<string | null>(null) 34 + const { preferences } = useUserPreferences(session) 25 35 26 36 useEffect(() => { 27 37 async function fetchExploreFeed() { ··· 111 121 112 122 return ( 113 123 <div className="explore-page page-container"> 114 - <SEO 124 + <SEO 115 125 title="Explore - Drydown" 116 126 description="See the latest fragrance reviews from the Drydown community in real-time." 117 127 url={window.location.href} 118 128 /> 119 - <nav className="main-nav"> 120 - <Link href="/" className="nav-logo">Drydown</Link> 121 - <div style={{ flex: 1 }} /> 122 - </nav> 129 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 123 130 124 - <header> 125 - <h1>Most Recent Reviews</h1> 126 - </header> 131 + <h1 style={{ marginBottom: '1.5rem' }}>Most Recent Reviews</h1> 127 132 128 133 {isLoading ? ( 129 134 <div>Loading the latest reviews...</div> ··· 147 152 setLocation(`/profile/${review.author.handle}/review/${rkey}`) 148 153 } 149 154 }} 155 + viewerPreferences={preferences || undefined} 156 + viewerDid={session?.sub} 150 157 /> 151 158 ))} 152 159 </div>
+47
src/components/Header.tsx
··· 1 + import { Link } from 'wouter' 2 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 + 4 + interface HeaderProps { 5 + session: OAuthSession | null 6 + userProfile?: { displayName?: string; handle: string } | null 7 + onLogout?: () => void 8 + } 9 + 10 + /** 11 + * Shared header component for consistent navigation across the site. 12 + * 13 + * Signed in: Explore, Settings, Username, Sign Out 14 + * Signed out: Explore 15 + */ 16 + export function Header({ session, userProfile, onLogout }: HeaderProps) { 17 + return ( 18 + <header className="site-header"> 19 + <Link href="/" className="site-logo"> 20 + Drydown 21 + </Link> 22 + <nav className="site-nav"> 23 + <Link href="/explore" className="nav-link"> 24 + Explore 25 + </Link> 26 + {session && ( 27 + <> 28 + <Link href="/preferences" className="nav-link"> 29 + Preferences 30 + </Link> 31 + <Link 32 + href={`/profile/${userProfile?.handle || session.sub}/reviews`} 33 + className="nav-link nav-link-username" 34 + > 35 + {userProfile?.displayName || userProfile?.handle || session.sub} 36 + </Link> 37 + {onLogout && ( 38 + <button onClick={onLogout} className="btn-primary btn-small"> 39 + sign out 40 + </button> 41 + )} 42 + </> 43 + )} 44 + </nav> 45 + </header> 46 + ) 47 + }
+10 -9
src/components/HousePage.tsx
··· 2 2 import { useLocation, Link } from 'wouter' 3 3 import type { OAuthSession } from '@atproto/oauth-client-browser' 4 4 import { SEO } from './SEO' 5 + import { Header } from './Header' 5 6 import { Footer } from './Footer' 6 7 import { ReviewList } from './ReviewList' 7 8 import { resolveIdentity } from '../utils/resolveIdentity' ··· 9 10 import { cache, TTL } from '../services/cache' 10 11 import { calculateWeightedScore, decodeWeightedScore } from '../utils/reviewUtils' 11 12 import type { AuthorInfo } from './ReviewCard' 13 + import { useUserPreferences } from '../hooks/useUserPreferences' 12 14 13 15 const MICROCOSM_API = "https://ufos-api.microcosm.blue" 14 16 15 17 interface HousePageProps { 16 18 handle: string 17 19 rkey: string 18 - session?: OAuthSession | null 20 + session: OAuthSession | null 21 + userProfile?: { displayName?: string; handle: string } | null 22 + onLogout?: () => void 19 23 } 20 24 21 - export function HousePage({ handle, rkey, session }: HousePageProps) { 25 + export function HousePage({ handle, rkey, session, userProfile, onLogout }: HousePageProps) { 22 26 const [, setLocation] = useLocation() 23 27 const [houseName, setHouseName] = useState<string>('Loading House...') 24 28 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) ··· 28 32 const [error, setError] = useState<string | null>(null) 29 33 const [manager, setManager] = useState<{ handle: string, displayName?: string, avatar?: string } | null>(null) 30 34 const [houseDid, setHouseDid] = useState<string | null>(null) 35 + const { preferences } = useUserPreferences(session) 31 36 32 37 // Analytics 33 38 const [totalRating, setTotalRating] = useState<number>(0) ··· 195 200 url={window.location.href} 196 201 /> 197 202 198 - <nav className="main-nav"> 199 - <Link href="/" className="nav-logo">Drydown</Link> 200 - <div style={{ flex: 1 }} /> 201 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 202 - Explore 203 - </Link> 204 - </nav> 203 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 205 204 206 205 <header style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}> 207 206 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', width: '100%' }}> ··· 307 306 setLocation(`/profile/${authorHandle}/review/${rkey}`) 308 307 } 309 308 }} 309 + viewerPreferences={preferences || undefined} 310 + viewerDid={session?.sub} 310 311 /> 311 312 )} 312 313
+3
src/components/LandingPage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { useLocation } from 'wouter' 3 3 import { SEO } from './SEO' 4 + import { Header } from './Header' 4 5 import { LoginForm } from './LoginForm' 5 6 import { AppDisclaimer } from './AppDisclaimer' 6 7 import { Footer } from './Footer' ··· 168 169 description="Join the Drydown community to create time-based fragrance reviews and discover new perfumes reviewed by real people." 169 170 url="https://drydown.social" 170 171 /> 172 + 173 + <Header session={null} /> 171 174 172 175 {/* Hero Section */} 173 176 <section class="landing-hero">
+120
src/components/PreferenceSelector.tsx
··· 1 + import { useRef, useCallback } from 'preact/hooks' 2 + import { PREFERENCE_DEFINITIONS, type PreferenceKey, type PreferenceOption } from '../data/preferenceDefinitions' 3 + 4 + interface PreferenceSelectorProps { 5 + /** The preference key */ 6 + preferenceKey: PreferenceKey 7 + /** Currently selected value (1-5) or null if none selected */ 8 + value: number | null 9 + /** Callback when user selects an option */ 10 + onChange: (value: number) => void 11 + /** Disable interaction */ 12 + disabled?: boolean 13 + } 14 + 15 + /** 16 + * A statement-selection preference component for user settings. 17 + * 18 + * Users select from 5 statement options that capture their fragrance preferences. 19 + * The component stores integer values (1-5) but presents meaningful choices. 20 + * 21 + * Accessibility features: 22 + * - Radio button semantics (role="radiogroup", role="radio") 23 + * - Arrow key navigation between options 24 + * - Focus management and visible focus indicators 25 + * - Screen reader announcements for selection 26 + */ 27 + export function PreferenceSelector({ 28 + preferenceKey, 29 + value, 30 + onChange, 31 + disabled = false, 32 + }: PreferenceSelectorProps) { 33 + const preference = PREFERENCE_DEFINITIONS[preferenceKey] 34 + const containerRef = useRef<HTMLDivElement>(null) 35 + 36 + const handleKeyDown = useCallback( 37 + (e: KeyboardEvent, currentIndex: number) => { 38 + if (disabled) return 39 + 40 + let newIndex = currentIndex 41 + 42 + switch (e.key) { 43 + case 'ArrowDown': 44 + case 'ArrowRight': 45 + e.preventDefault() 46 + newIndex = Math.min(currentIndex + 1, preference.options.length - 1) 47 + break 48 + case 'ArrowUp': 49 + case 'ArrowLeft': 50 + e.preventDefault() 51 + newIndex = Math.max(currentIndex - 1, 0) 52 + break 53 + case ' ': 54 + case 'Enter': 55 + e.preventDefault() 56 + onChange(preference.options[currentIndex].value) 57 + return 58 + default: 59 + return 60 + } 61 + 62 + // Focus the new option 63 + const container = containerRef.current 64 + if (container && newIndex !== currentIndex) { 65 + const buttons = container.querySelectorAll<HTMLButtonElement>('.preference-option') 66 + buttons[newIndex]?.focus() 67 + } 68 + }, 69 + [preference.options, disabled, onChange] 70 + ) 71 + 72 + const handleSelect = useCallback( 73 + (option: PreferenceOption) => { 74 + if (!disabled) { 75 + onChange(option.value) 76 + } 77 + }, 78 + [disabled, onChange] 79 + ) 80 + 81 + return ( 82 + <div className="preference-selector"> 83 + <div className="preference-header"> 84 + <div className="preference-question" id={`preference-question-${preferenceKey}`}> 85 + {preference.question} 86 + </div> 87 + <div className="preference-description"> 88 + {preference.description} 89 + </div> 90 + </div> 91 + 92 + <div 93 + ref={containerRef} 94 + className="preference-options" 95 + role="radiogroup" 96 + aria-labelledby={`preference-question-${preferenceKey}`} 97 + > 98 + {preference.options.map((option, index) => { 99 + const isSelected = value === option.value 100 + 101 + return ( 102 + <button 103 + key={option.value} 104 + type="button" 105 + role="radio" 106 + aria-checked={isSelected} 107 + className={`preference-option ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''}`} 108 + onClick={() => handleSelect(option)} 109 + onKeyDown={(e) => handleKeyDown(e as unknown as KeyboardEvent, index)} 110 + disabled={disabled} 111 + tabIndex={isSelected || (value === null && index === 0) ? 0 : -1} 112 + > 113 + <span className="preference-option-text">{option.statement}</span> 114 + </button> 115 + ) 116 + })} 117 + </div> 118 + </div> 119 + ) 120 + }
+7 -8
src/components/ProfileHousesPage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { Link } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { AtpBaseClient } from '../client/index' 4 5 import { SEO } from './SEO' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 8 import { TabBar } from './TabBar' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' ··· 11 13 12 14 interface ProfileHousesPageProps { 13 15 handle: string 16 + session: OAuthSession | null 17 + userProfile?: { displayName?: string; handle: string } | null 18 + onLogout?: () => void 14 19 } 15 20 16 21 interface HouseStat { ··· 23 28 24 29 type HouseFilter = 'all' | 'maintained' 25 30 26 - export function ProfileHousesPage({ handle }: ProfileHousesPageProps) { 31 + export function ProfileHousesPage({ handle, session, userProfile, onLogout }: ProfileHousesPageProps) { 27 32 const [houses, setHouses] = useState<HouseStat[]>([]) 28 33 const [isLoading, setIsLoading] = useState(true) 29 34 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) ··· 142 147 description={`See fragrance houses reviewed by ${profile.displayName || profile.handle} on Drydown.`} 143 148 url={window.location.href} 144 149 /> 145 - <nav className="main-nav"> 146 - <Link href="/" className="nav-logo">Drydown</Link> 147 - <div style={{ flex: 1 }} /> 148 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 149 - Explore 150 - </Link> 151 - </nav> 150 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 152 151 153 152 <header style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> 154 153 {profile.avatar && (
+15 -12
src/components/ProfilePage.tsx
··· 1 1 2 2 import { useState, useEffect } from 'preact/hooks' 3 - import { useLocation, Link } from 'wouter' 3 + import { useLocation } from 'wouter' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 5 import { AtpBaseClient } from '../client/index' 5 6 import { SEO } from './SEO' 7 + import { Header } from './Header' 6 8 import { Footer } from './Footer' 7 9 import { ReviewList } from './ReviewList' 8 10 import { TabBar } from './TabBar' ··· 10 12 import { batchResolveAtUris } from '../utils/atUriUtils' 11 13 import { cache, TTL } from '../services/cache' 12 14 import { getReviewDisplayScore } from '../utils/reviewUtils' 15 + import { useUserPreferences } from '../hooks/useUserPreferences' 13 16 14 17 // Helper function to render visual star ratings 15 18 function renderStars(score: number, maxStars: number = 5): string { ··· 22 25 23 26 interface ProfilePageProps { 24 27 handle: string 28 + session: OAuthSession | null 29 + userProfile?: { displayName?: string; handle: string } | null 30 + onLogout?: () => void 25 31 } 26 32 27 - export function ProfilePage({ handle }: ProfilePageProps) { 33 + export function ProfilePage({ handle, session, userProfile, onLogout }: ProfilePageProps) { 28 34 const [, setLocation] = useLocation() 29 35 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 30 36 const [fragrances, setFragrances] = useState<Map<string, { name: string, houseName?: string }>>(new Map()) 31 37 const [isLoading, setIsLoading] = useState(true) 32 38 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 33 39 const [error, setError] = useState<string | null>(null) 40 + const { preferences } = useUserPreferences(session) 34 41 35 42 useEffect(() => { 36 43 async function loadProfileAndReviews() { ··· 207 214 description={`Read ${reviews.length} fragrance reviews by ${profile.displayName || profile.handle} on Drydown.`} 208 215 url={window.location.href} 209 216 /> 210 - <nav className="main-nav"> 211 - <Link href="/" className="nav-logo">Drydown</Link> 212 - <div style={{ flex: 1 }} /> 213 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 214 - Explore 215 - </Link> 216 - </nav> 217 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 217 218 218 219 <header style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}> 219 220 {profile.avatar && ( ··· 278 279 {reviews.length === 0 ? ( 279 280 <p>No reviews found for this user.</p> 280 281 ) : ( 281 - <ReviewList 282 - reviews={reviews} 283 - fragrances={fragrances} 282 + <ReviewList 283 + reviews={reviews} 284 + fragrances={fragrances} 284 285 onReviewClick={(review) => { 285 286 const rkey = review.uri.split('/').pop() 286 287 if (rkey) { 287 288 setLocation(`/profile/${handle}/review/${rkey}`) 288 289 } 289 290 }} 291 + viewerPreferences={preferences || undefined} 292 + viewerDid={session?.sub} 290 293 /> 291 294 )} 292 295
+28 -12
src/components/ReviewCard.tsx
··· 1 - import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' 1 + import { getReviewActionState, getPersonalizedScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 2 2 3 3 export interface AuthorInfo { 4 4 handle: string ··· 13 13 author?: AuthorInfo 14 14 status: 'active' | 'past' 15 15 onClick?: () => void 16 + viewerPreferences?: UserPreferencesForScoring 17 + viewerDid?: string 16 18 } 17 19 18 - export function ReviewCard({ review, fragranceName, houseName, author, status, onClick }: ReviewCardProps) { 20 + export function ReviewCard({ review, fragranceName, houseName, author, status, onClick, viewerPreferences, viewerDid }: ReviewCardProps) { 19 21 const { value } = review 20 22 const { hint } = getReviewActionState(value) 23 + 24 + // Determine if this is the viewer's own review 25 + const authorDid = review.uri.split('/')[2] 26 + const isOwnReview = viewerDid === authorDid 27 + 28 + // Get personalized score 29 + const { score, isPersonalized } = getPersonalizedScore(value, viewerPreferences, isOwnReview) 21 30 22 31 return ( 23 32 <div ··· 53 62 </div> 54 63 )} 55 64 </div> 56 - <div className="review-card-rating"> 57 - {(() => { 58 - const score = Math.round(getReviewDisplayScore(value)) 59 - return ( 60 - <> 61 - {'★'.repeat(score)} 62 - {'☆'.repeat(5 - score)} 63 - </> 64 - ) 65 - })()} 65 + <div style={{ textAlign: 'right' }}> 66 + <div className="review-card-rating"> 67 + {(() => { 68 + const roundedScore = Math.round(score) 69 + return ( 70 + <> 71 + {'★'.repeat(roundedScore)} 72 + {'☆'.repeat(5 - roundedScore)} 73 + </> 74 + ) 75 + })()} 76 + </div> 77 + {isPersonalized && ( 78 + <div className="personalized-score-indicator"> 79 + Based on your preferences 80 + </div> 81 + )} 66 82 </div> 67 83 </div> 68 84
+8 -2
src/components/ReviewList.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { ReviewCard } from './ReviewCard' 3 - import { categorizeReviews, calculateWeightedScore } from '../utils/reviewUtils' 3 + import { categorizeReviews, calculateWeightedScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 4 4 5 5 interface ReviewListProps { 6 6 reviews: Array<{ uri: string; value: any }> 7 7 fragrances: Map<string, { name: string; houseName?: string }> 8 8 onReviewClick: (review: { uri: string; value: any }) => void 9 + viewerPreferences?: UserPreferencesForScoring 10 + viewerDid?: string 9 11 } 10 12 11 13 type SortBy = 'recent' | 'score' 12 14 type SortOrder = 'desc' | 'asc' 13 15 14 - export function ReviewList({ reviews, fragrances, onReviewClick }: ReviewListProps) { 16 + export function ReviewList({ reviews, fragrances, onReviewClick, viewerPreferences, viewerDid }: ReviewListProps) { 15 17 const { active, past } = categorizeReviews(reviews) 16 18 17 19 const [sortBy, setSortBy] = useState<SortBy>('recent') ··· 65 67 houseName={getHouseName(review.value.fragrance)} 66 68 status="active" 67 69 onClick={() => onReviewClick(review)} 70 + viewerPreferences={viewerPreferences} 71 + viewerDid={viewerDid} 68 72 /> 69 73 ))} 70 74 </section> ··· 94 98 houseName={getHouseName(review.value.fragrance)} 95 99 status="past" 96 100 onClick={() => onReviewClick(review)} 101 + viewerPreferences={viewerPreferences} 102 + viewerDid={viewerDid} 97 103 /> 98 104 ))} 99 105 </section>
+280
src/components/SettingsPage.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 + import { AtpBaseClient } from '../client/index' 5 + import { PreferenceSelector } from './PreferenceSelector' 6 + import { PREFERENCE_KEYS, DEFAULT_PREFERENCES, type PreferenceKey } from '../data/preferenceDefinitions' 7 + import { Header } from './Header' 8 + import { Footer } from './Footer' 9 + 10 + interface SettingsPageProps { 11 + session: OAuthSession | null 12 + userProfile?: { displayName?: string; handle: string } | null 13 + onLogout?: () => void 14 + } 15 + 16 + type ScoreLens = 'theirs' | 'mine' 17 + 18 + interface UserSettings { 19 + presenceStyle: number | null 20 + longevityPriority: number | null 21 + complexityPreference: number | null 22 + scoringApproach: number | null 23 + scoreLens: ScoreLens 24 + } 25 + 26 + export function SettingsPage({ session, userProfile, onLogout }: SettingsPageProps) { 27 + const [, setLocation] = useLocation() 28 + const [settings, setSettings] = useState<UserSettings>({ 29 + presenceStyle: null, 30 + longevityPriority: null, 31 + complexityPreference: null, 32 + scoringApproach: null, 33 + scoreLens: 'theirs', 34 + }) 35 + const [originalSettings, setOriginalSettings] = useState<UserSettings | null>(null) 36 + const [isLoading, setIsLoading] = useState(true) 37 + const [isSaving, setIsSaving] = useState(false) 38 + const [error, setError] = useState<string | null>(null) 39 + const [successMessage, setSuccessMessage] = useState<string | null>(null) 40 + 41 + // Redirect to home if not logged in 42 + useEffect(() => { 43 + if (!session) { 44 + setLocation('/') 45 + } 46 + }, [session, setLocation]) 47 + 48 + // Load existing settings 49 + useEffect(() => { 50 + if (!session) return 51 + 52 + const currentSession = session // Capture for TypeScript 53 + 54 + async function loadSettings() { 55 + try { 56 + setIsLoading(true) 57 + setError(null) 58 + 59 + const client = new AtpBaseClient(currentSession.fetchHandler.bind(currentSession)) 60 + 61 + try { 62 + const result = await client.social.drydown.settings.get({ 63 + repo: currentSession.sub, 64 + rkey: 'self', 65 + }) 66 + 67 + const loadedSettings: UserSettings = { 68 + presenceStyle: result.value.presenceStyle ?? null, 69 + longevityPriority: result.value.longevityPriority ?? null, 70 + complexityPreference: result.value.complexityPreference ?? null, 71 + scoringApproach: result.value.scoringApproach ?? null, 72 + scoreLens: (result.value.scoreLens as ScoreLens) ?? 'theirs', 73 + } 74 + 75 + setSettings(loadedSettings) 76 + setOriginalSettings(loadedSettings) 77 + } catch (e: any) { 78 + // Record not found is expected for new users 79 + if (e?.status === 400 || e?.message?.includes('not found')) { 80 + // Use defaults for new users 81 + const defaultSettings: UserSettings = { 82 + presenceStyle: DEFAULT_PREFERENCES.presenceStyle, 83 + longevityPriority: DEFAULT_PREFERENCES.longevityPriority, 84 + complexityPreference: DEFAULT_PREFERENCES.complexityPreference, 85 + scoringApproach: DEFAULT_PREFERENCES.scoringApproach, 86 + scoreLens: 'theirs', 87 + } 88 + setSettings(defaultSettings) 89 + setOriginalSettings(null) // No existing record 90 + } else { 91 + throw e 92 + } 93 + } 94 + } catch (e) { 95 + console.error('Failed to load settings', e) 96 + setError('Failed to load your preferences. Please try again.') 97 + } finally { 98 + setIsLoading(false) 99 + } 100 + } 101 + 102 + loadSettings() 103 + }, [session]) 104 + 105 + const handlePreferenceChange = (key: PreferenceKey, value: number) => { 106 + setSettings(prev => ({ ...prev, [key]: value })) 107 + setSuccessMessage(null) 108 + } 109 + 110 + const handleScoreLensChange = (lens: ScoreLens) => { 111 + setSettings(prev => ({ ...prev, scoreLens: lens })) 112 + setSuccessMessage(null) 113 + } 114 + 115 + const hasChanges = () => { 116 + if (!originalSettings) return true // New settings, always has changes 117 + return ( 118 + settings.presenceStyle !== originalSettings.presenceStyle || 119 + settings.longevityPriority !== originalSettings.longevityPriority || 120 + settings.complexityPreference !== originalSettings.complexityPreference || 121 + settings.scoringApproach !== originalSettings.scoringApproach || 122 + settings.scoreLens !== originalSettings.scoreLens 123 + ) 124 + } 125 + 126 + const handleSave = async () => { 127 + if (!session) return 128 + 129 + try { 130 + setIsSaving(true) 131 + setError(null) 132 + setSuccessMessage(null) 133 + 134 + const client = new AtpBaseClient(session.fetchHandler.bind(session)) 135 + const now = new Date().toISOString() 136 + 137 + const record = { 138 + presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle, 139 + longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority, 140 + complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference, 141 + scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach, 142 + scoreLens: settings.scoreLens, 143 + createdAt: originalSettings ? undefined : now, // Only set on first create 144 + updatedAt: now, 145 + } 146 + 147 + // Use put to create or update the record 148 + await client.social.drydown.settings.put( 149 + { repo: session.sub, rkey: 'self' }, 150 + record as any 151 + ) 152 + 153 + setOriginalSettings(settings) 154 + setSuccessMessage('Your preferences have been saved.') 155 + } catch (e) { 156 + console.error('Failed to save settings', e) 157 + setError('Failed to save your preferences. Please try again.') 158 + } finally { 159 + setIsSaving(false) 160 + } 161 + } 162 + 163 + const handleCancel = () => { 164 + if (originalSettings) { 165 + setSettings(originalSettings) 166 + } 167 + setLocation('/') 168 + } 169 + 170 + if (!session) { 171 + return null // Will redirect 172 + } 173 + 174 + if (isLoading) { 175 + return ( 176 + <div className="page-container"> 177 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 178 + <p>Loading your preferences...</p> 179 + </div> 180 + ) 181 + } 182 + 183 + return ( 184 + <div className="page-container"> 185 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 186 + 187 + <div className="settings-page"> 188 + <header className="settings-header"> 189 + <h1>Preferences</h1> 190 + <p className="settings-intro"> 191 + These preferences personalize how fragrance scores are calculated for you. 192 + There are no wrong answers — every preference reflects a valid way to experience fragrance. 193 + </p> 194 + </header> 195 + 196 + <div className="settings-preferences"> 197 + {PREFERENCE_KEYS.map(key => ( 198 + <PreferenceSelector 199 + key={key} 200 + preferenceKey={key} 201 + value={settings[key]} 202 + onChange={(value) => handlePreferenceChange(key, value)} 203 + disabled={isSaving} 204 + /> 205 + ))} 206 + </div> 207 + 208 + <div className="settings-section"> 209 + <h2 className="settings-section-title">Score Display</h2> 210 + <p className="settings-section-description"> 211 + When viewing other people's reviews, you can see scores based on their preferences or yours. 212 + </p> 213 + 214 + <div className="score-lens-options" role="radiogroup" aria-label="Score display preference"> 215 + <button 216 + type="button" 217 + role="radio" 218 + aria-checked={settings.scoreLens === 'theirs'} 219 + className={`score-lens-option ${settings.scoreLens === 'theirs' ? 'selected' : ''}`} 220 + onClick={() => handleScoreLensChange('theirs')} 221 + disabled={isSaving} 222 + > 223 + <span className="score-lens-label">Their preferences</span> 224 + <span className="score-lens-description"> 225 + See scores as the reviewer intended 226 + </span> 227 + </button> 228 + 229 + <button 230 + type="button" 231 + role="radio" 232 + aria-checked={settings.scoreLens === 'mine'} 233 + className={`score-lens-option ${settings.scoreLens === 'mine' ? 'selected' : ''}`} 234 + onClick={() => handleScoreLensChange('mine')} 235 + disabled={isSaving} 236 + > 237 + <span className="score-lens-label">Your preferences</span> 238 + <span className="score-lens-description"> 239 + Recalculate scores based on what matters to you 240 + </span> 241 + </button> 242 + </div> 243 + </div> 244 + 245 + {error && ( 246 + <div className="error-message" role="alert"> 247 + {error} 248 + </div> 249 + )} 250 + 251 + {successMessage && ( 252 + <div className="success-message" role="status"> 253 + {successMessage} 254 + </div> 255 + )} 256 + 257 + <div className="settings-actions"> 258 + <button 259 + type="button" 260 + className="btn-secondary" 261 + onClick={handleCancel} 262 + disabled={isSaving} 263 + > 264 + cancel 265 + </button> 266 + <button 267 + type="button" 268 + className="btn-primary" 269 + onClick={handleSave} 270 + disabled={isSaving || !hasChanges()} 271 + > 272 + {isSaving ? 'saving...' : 'save preferences'} 273 + </button> 274 + </div> 275 + </div> 276 + 277 + <Footer session={session} /> 278 + </div> 279 + ) 280 + }
+33 -17
src/components/SingleReviewPage.tsx
··· 1 1 2 2 import { useState, useEffect } from 'preact/hooks' 3 3 import { Link, useLocation } from 'wouter' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 5 import { AtpBaseClient } from '../client/index' 5 6 import { SEO } from '../components/SEO' 7 + import { Header } from '../components/Header' 6 8 import { Footer } from '../components/Footer' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' 8 10 import { resolveAtUri } from '../utils/atUriUtils' 9 - import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' 10 - import type { OAuthSession } from '@atproto/oauth-client-browser' 11 + import { getReviewActionState, getPersonalizedScore } from '../utils/reviewUtils' 12 + import { useUserPreferences } from '../hooks/useUserPreferences' 11 13 12 14 /** 13 15 * Calculate average temperature from review's weather data ··· 29 31 interface SingleReviewPageProps { 30 32 handle: string 31 33 rkey: string 32 - session?: OAuthSession | null 34 + session: OAuthSession | null 35 + userProfile?: { displayName?: string; handle: string } | null 36 + onLogout?: () => void 33 37 } 34 38 35 - export function SingleReviewPage({ handle, rkey, session }: SingleReviewPageProps) { 39 + export function SingleReviewPage({ handle, rkey, session, userProfile, onLogout }: SingleReviewPageProps) { 36 40 const [review, setReview] = useState<any | null>(null) 37 41 const [fragrance, setFragrance] = useState<any | null>(null) 38 42 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string } | null>(null) 39 43 const [isLoading, setIsLoading] = useState(true) 40 44 const [error, setError] = useState<string | null>(null) 41 - 45 + 42 46 const [, setLocation] = useLocation() 47 + const { preferences } = useUserPreferences(session) 43 48 44 49 useEffect(() => { 45 50 async function loadReviewData() { ··· 135 140 const handleShare = () => { 136 141 if (!review || !fragrance) return 137 142 138 - const displayScore = Math.round(getReviewDisplayScore(review)) 143 + // Determine if viewing own review 144 + const isOwnReview = !!(profile && session?.sub === profile.did) 145 + const { score } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 146 + const displayScore = Math.round(score) 139 147 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 140 148 const link = window.location.href 141 149 const houseName = fragrance?.houseName || 'Unknown House' ··· 198 206 const handleShareOther = () => { 199 207 if (!review || !fragrance || !profile) return 200 208 201 - const displayScore = Math.round(getReviewDisplayScore(review)) 209 + // Determine if viewing own review 210 + const isOwnReview = !!(session?.sub === profile.did) 211 + const { score } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 212 + const displayScore = Math.round(score) 202 213 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 203 214 const via = ` — via @${profile.handle}` 204 215 const starsLine = stars + via ··· 284 295 if (!review) return <div class="page-container">Review not found</div> 285 296 286 297 298 + // Calculate personalized score for display 299 + const isOwnReview = !!(profile && session?.sub === profile.did) 300 + const { score: personalizedScore, isPersonalized } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 301 + 287 302 const fragName = fragrance?.name || 'Unknown Fragrance' 288 - const displayScore = Math.round(getReviewDisplayScore(review)) 303 + const displayScore = Math.round(personalizedScore) 289 304 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 290 305 const metaDescription = `${stars} ${review.text ? review.text.slice(0, 150) + (review.text.length > 150 ? '...' : '') : 'Read this review on Drydown.'}` 291 306 ··· 298 313 description={metaDescription} 299 314 url={window.location.href} 300 315 /> 301 - <nav className="main-nav"> 302 - <Link href="/" className="nav-logo">Drydown</Link> 303 - <div style={{ flex: 1 }} /> 304 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 305 - Explore 306 - </Link> 307 - </nav> 316 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 308 317 309 318 <Link href={`/profile/${handle}/reviews`} className="back-link"> 310 319 &larr; Back to {profile?.displayName || handle}'s reviews ··· 372 381 373 382 <div class="review-content"> 374 383 <div className="review-meta"> 375 - <div class="rating-large"> 376 - {Math.round(getReviewDisplayScore(review))}/5 384 + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}> 385 + <div class="rating-large"> 386 + {displayScore}/5 387 + </div> 388 + {isPersonalized && ( 389 + <div className="personalized-score-indicator" style={{ marginTop: '0' }}> 390 + Based on your preferences 391 + </div> 392 + )} 377 393 </div> 378 394 <div className="review-date"> 379 395 Rated on {new Date(review.createdAt).toLocaleDateString()}
+1 -1
src/config.ts
··· 3 3 4 4 export const oauthConfig = isDev 5 5 ? { 6 - clientId: `http://localhost?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}`)}&scope=${encodeURIComponent('atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*')}`, 6 + clientId: `http://localhost?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}`)}&scope=${encodeURIComponent('atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review repo:social.drydown.settings rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*')}`, 7 7 redirectUri: `http://127.0.0.1:${port}`, 8 8 } 9 9 : {
+96
src/data/preferenceDefinitions.ts
··· 1 + /** 2 + * Preference definitions for user fragrance settings. 3 + * 4 + * Each preference has 5 statement-based options that map to integer values 1-5. 5 + * These preferences translate into scoring weights that personalize how reviews 6 + * are evaluated. The underlying data model stores integers; these statements 7 + * provide meaningful context for users. 8 + */ 9 + 10 + export interface PreferenceOption { 11 + value: 1 | 2 | 3 | 4 | 5 12 + statement: string 13 + } 14 + 15 + export interface PreferenceDefinition { 16 + key: PreferenceKey 17 + question: string 18 + description: string 19 + options: readonly [PreferenceOption, PreferenceOption, PreferenceOption, PreferenceOption, PreferenceOption] 20 + } 21 + 22 + export type PreferenceKey = 23 + | 'presenceStyle' 24 + | 'longevityPriority' 25 + | 'complexityPreference' 26 + | 'scoringApproach' 27 + 28 + export const PREFERENCE_DEFINITIONS: Record<PreferenceKey, PreferenceDefinition> = { 29 + presenceStyle: { 30 + key: 'presenceStyle', 31 + question: 'How do you like to be noticed?', 32 + description: 'This affects how much weight projection and sillage have in your scores.', 33 + options: [ 34 + { value: 1, statement: 'Skin scent only — just for me' }, 35 + { value: 2, statement: 'Close company — noticeable only up close' }, 36 + { value: 3, statement: 'Balanced — depends on the occasion' }, 37 + { value: 4, statement: 'Room presence — I enjoy being noticed' }, 38 + { value: 5, statement: 'Announce myself — I like to make an entrance' }, 39 + ], 40 + }, 41 + 42 + longevityPriority: { 43 + key: 'longevityPriority', 44 + question: 'How important is all-day longevity?', 45 + description: 'This affects how much weight longevity and late-stage ratings have in your scores.', 46 + options: [ 47 + { value: 1, statement: 'Not important — I enjoy reapplying throughout the day' }, 48 + { value: 2, statement: 'Nice to have — but not a dealbreaker' }, 49 + { value: 3, statement: 'Moderately important — I appreciate good lasting power' }, 50 + { value: 4, statement: 'Very important — I want it to last my workday' }, 51 + { value: 5, statement: 'Essential — all-day performance is a must' }, 52 + ], 53 + }, 54 + 55 + complexityPreference: { 56 + key: 'complexityPreference', 57 + question: 'What kind of compositions appeal to you?', 58 + description: 'This affects how much weight complexity has in your scores.', 59 + options: [ 60 + { value: 1, statement: 'Simple and focused — I prefer clean, straightforward scents' }, 61 + { value: 2, statement: 'Mostly simple — with maybe a twist or two' }, 62 + { value: 3, statement: 'Balanced — I enjoy both simple and complex fragrances' }, 63 + { value: 4, statement: 'Layered — I like discovering new notes over time' }, 64 + { value: 5, statement: 'Intricate — the more depth and evolution, the better' }, 65 + ], 66 + }, 67 + 68 + scoringApproach: { 69 + key: 'scoringApproach', 70 + question: 'How do you evaluate fragrances overall?', 71 + description: 'This affects whether your gut feeling or technical ratings carry more weight.', 72 + options: [ 73 + { value: 1, statement: 'Pure instinct — my gut reaction is what matters most' }, 74 + { value: 2, statement: 'Mostly instinct — but I consider the details too' }, 75 + { value: 3, statement: 'Balanced — both feeling and analysis matter equally' }, 76 + { value: 4, statement: 'Mostly analytical — I weigh each aspect carefully' }, 77 + { value: 5, statement: 'Fully analytical — the numbers tell the real story' }, 78 + ], 79 + }, 80 + } as const 81 + 82 + /** All preference keys in order */ 83 + export const PREFERENCE_KEYS: readonly PreferenceKey[] = [ 84 + 'presenceStyle', 85 + 'longevityPriority', 86 + 'complexityPreference', 87 + 'scoringApproach', 88 + ] as const 89 + 90 + /** Default preference values (balanced/neutral) */ 91 + export const DEFAULT_PREFERENCES: Record<PreferenceKey, number> = { 92 + presenceStyle: 3, 93 + longevityPriority: 3, 94 + complexityPreference: 3, 95 + scoringApproach: 3, 96 + }
+60
src/hooks/useUserPreferences.ts
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 + import { AtpBaseClient } from '../client/index' 4 + import type { UserPreferencesForScoring } from '../utils/reviewUtils' 5 + 6 + /** 7 + * Hook to load and cache the current user's preferences. 8 + * Returns null if not signed in or no preferences set yet. 9 + */ 10 + export function useUserPreferences(session: OAuthSession | null): { 11 + preferences: UserPreferencesForScoring | null 12 + isLoading: boolean 13 + } { 14 + const [preferences, setPreferences] = useState<UserPreferencesForScoring | null>(null) 15 + const [isLoading, setIsLoading] = useState(true) 16 + 17 + useEffect(() => { 18 + if (!session) { 19 + setPreferences(null) 20 + setIsLoading(false) 21 + return 22 + } 23 + 24 + async function loadPreferences() { 25 + if (!session) return 26 + 27 + try { 28 + setIsLoading(true) 29 + const client = new AtpBaseClient(session.fetchHandler.bind(session)) 30 + 31 + const result = await client.social.drydown.settings.get({ 32 + repo: session.sub, 33 + rkey: 'self', 34 + }) 35 + 36 + setPreferences({ 37 + presenceStyle: result.value.presenceStyle, 38 + longevityPriority: result.value.longevityPriority, 39 + complexityPreference: result.value.complexityPreference, 40 + scoringApproach: result.value.scoringApproach, 41 + scoreLens: result.value.scoreLens as 'theirs' | 'mine' | undefined, 42 + }) 43 + } catch (e: any) { 44 + // No preferences set yet is expected for new users 45 + if (e?.status === 400 || e?.message?.includes('not found')) { 46 + setPreferences(null) 47 + } else { 48 + console.error('Failed to load user preferences', e) 49 + setPreferences(null) 50 + } 51 + } finally { 52 + setIsLoading(false) 53 + } 54 + } 55 + 56 + loadPreferences() 57 + }, [session]) 58 + 59 + return { preferences, isLoading } 60 + }
+56
src/lexicons/social.drydown.settings.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.drydown.settings", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "User preferences for fragrance review scoring", 8 + "key": "self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "presenceStyle": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "maximum": 5, 17 + "description": "How the user prefers to be noticed (1=skin scent, 5=bold presence)" 18 + }, 19 + "longevityPriority": { 20 + "type": "integer", 21 + "minimum": 1, 22 + "maximum": 5, 23 + "description": "How important all-day longevity is (1=not important, 5=essential)" 24 + }, 25 + "complexityPreference": { 26 + "type": "integer", 27 + "minimum": 1, 28 + "maximum": 5, 29 + "description": "Preference for fragrance complexity (1=simple, 5=intricate)" 30 + }, 31 + "scoringApproach": { 32 + "type": "integer", 33 + "minimum": 1, 34 + "maximum": 5, 35 + "description": "How user evaluates fragrances (1=instinct, 5=analytical)" 36 + }, 37 + "scoreLens": { 38 + "type": "string", 39 + "knownValues": ["theirs", "mine"], 40 + "description": "When viewing others' reviews: show their score or recalculate with your preferences" 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime", 45 + "description": "Timestamp when settings were first created" 46 + }, 47 + "updatedAt": { 48 + "type": "string", 49 + "format": "datetime", 50 + "description": "Timestamp when settings were last updated" 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }
+80
src/utils/reviewUtils.ts
··· 170 170 return calculateWeightedScore(review) 171 171 } 172 172 173 + export type ScoreLens = 'theirs' | 'mine' 174 + 175 + export interface UserPreferencesForScoring { 176 + presenceStyle?: number 177 + longevityPriority?: number 178 + complexityPreference?: number 179 + scoringApproach?: number 180 + scoreLens?: ScoreLens 181 + } 182 + 183 + /** 184 + * Get the display score for a review with optional personalized scoring. 185 + * 186 + * @param review - The review record 187 + * @param userPreferences - Optional user preferences for personalized scoring 188 + * @param isOwnReview - Whether this review belongs to the current user 189 + * @returns Object with score and whether it's personalized 190 + */ 191 + export function getPersonalizedScore( 192 + review: any, 193 + userPreferences?: UserPreferencesForScoring, 194 + isOwnReview: boolean = false 195 + ): { score: number; isPersonalized: boolean } { 196 + // If no preferences or viewing own review or lens is "theirs", use original score 197 + if (!userPreferences || isOwnReview || userPreferences.scoreLens !== 'mine') { 198 + return { 199 + score: getReviewDisplayScore(review), 200 + isPersonalized: false, 201 + } 202 + } 203 + 204 + // Recalculate with user's preferences 205 + const weights = mapPreferencesToWeights(userPreferences) 206 + return { 207 + score: calculateWeightedScore(review, weights), 208 + isPersonalized: true, 209 + } 210 + } 211 + 212 + /** 213 + * Maps user preferences to scoring weights. 214 + * This is a simplified version for use in reviewUtils. 215 + */ 216 + function mapPreferencesToWeights(preferences: UserPreferencesForScoring) { 217 + const WEIGHT_SCALE: Record<number, number> = { 218 + 1: 0.5, 219 + 2: 0.75, 220 + 3: 1.0, 221 + 4: 1.25, 222 + 5: 1.5, 223 + } 224 + 225 + const SCORING_APPROACH_OVERALL_SCALE: Record<number, number> = { 226 + 1: 1.5, 227 + 2: 1.25, 228 + 3: 1.0, 229 + 4: 0.75, 230 + 5: 0.5, 231 + } 232 + 233 + const presenceMultiplier = WEIGHT_SCALE[preferences.presenceStyle ?? 3] ?? 1.0 234 + const longevityMultiplier = WEIGHT_SCALE[preferences.longevityPriority ?? 3] ?? 1.0 235 + const complexityMultiplier = WEIGHT_SCALE[preferences.complexityPreference ?? 3] ?? 1.0 236 + const scoringApproach = preferences.scoringApproach ?? 3 237 + const overallMultiplier = SCORING_APPROACH_OVERALL_SCALE[scoringApproach] ?? 1.0 238 + const technicalBoost = scoringApproach >= 4 ? 1.1 : 1.0 239 + 240 + return { 241 + openingRating: 1.0 * technicalBoost, 242 + openingProjection: presenceMultiplier * technicalBoost, 243 + drydownRating: 1.0 * technicalBoost, 244 + midProjection: presenceMultiplier * technicalBoost, 245 + sillage: presenceMultiplier * technicalBoost, 246 + endRating: longevityMultiplier * technicalBoost, 247 + complexity: complexityMultiplier * technicalBoost, 248 + longevity: longevityMultiplier * technicalBoost, 249 + overallRating: overallMultiplier, 250 + } 251 + } 252 + 173 253 /** 174 254 * Format notification time window for a specific stage 175 255 * Returns formatted text like "in 1.5-4 hours" or specific times
+101
src/utils/weightMapping.ts
··· 1 + /** 2 + * Maps user preferences to scoring weights. 3 + * 4 + * Each preference (1-5) affects specific rating criteria weights. 5 + * The default weight is 1.0. Preferences adjust weights up or down 6 + * based on what matters more or less to the user. 7 + */ 8 + 9 + import type { PreferenceKey } from '../data/preferenceDefinitions' 10 + 11 + export interface ScoringWeights { 12 + openingRating: number 13 + openingProjection: number 14 + drydownRating: number 15 + midProjection: number 16 + sillage: number 17 + endRating: number 18 + complexity: number 19 + longevity: number 20 + overallRating: number 21 + } 22 + 23 + export type UserPreferences = Record<PreferenceKey, number> 24 + 25 + /** 26 + * Weight multipliers for each preference level. 27 + * Level 3 (balanced) = 1.0, levels below decrease, levels above increase. 28 + */ 29 + const WEIGHT_SCALE = { 30 + 1: 0.5, 31 + 2: 0.75, 32 + 3: 1.0, 33 + 4: 1.25, 34 + 5: 1.5, 35 + } as const 36 + 37 + /** 38 + * For scoring approach, we invert the scale for overallRating. 39 + * Level 1 (pure instinct) means overall rating matters MORE. 40 + * Level 5 (fully analytical) means overall rating matters LESS (technical ratings matter more). 41 + */ 42 + const SCORING_APPROACH_OVERALL_SCALE = { 43 + 1: 1.5, // Instinct: overall rating weighs heavily 44 + 2: 1.25, 45 + 3: 1.0, 46 + 4: 0.75, 47 + 5: 0.5, // Analytical: overall rating weighs less 48 + } as const 49 + 50 + /** 51 + * Maps user preferences to scoring weights. 52 + * 53 + * @param preferences - User's preference values (1-5 for each) 54 + * @returns Weights to use in calculateWeightedScore 55 + */ 56 + export function mapPreferencesToWeights(preferences: UserPreferences): ScoringWeights { 57 + const presenceMultiplier = WEIGHT_SCALE[preferences.presenceStyle as keyof typeof WEIGHT_SCALE] ?? 1.0 58 + const longevityMultiplier = WEIGHT_SCALE[preferences.longevityPriority as keyof typeof WEIGHT_SCALE] ?? 1.0 59 + const complexityMultiplier = WEIGHT_SCALE[preferences.complexityPreference as keyof typeof WEIGHT_SCALE] ?? 1.0 60 + const scoringApproach = preferences.scoringApproach as keyof typeof SCORING_APPROACH_OVERALL_SCALE 61 + const overallMultiplier = SCORING_APPROACH_OVERALL_SCALE[scoringApproach] ?? 1.0 62 + 63 + // For analytical approach, boost all technical ratings slightly 64 + // This creates the balance: instinct = overall matters more, analytical = details matter more 65 + const technicalBoost = scoringApproach >= 4 ? 1.1 : 1.0 66 + 67 + return { 68 + // Opening ratings - presence affects projection 69 + openingRating: 1.0 * technicalBoost, 70 + openingProjection: presenceMultiplier * technicalBoost, 71 + 72 + // Mid-wear ratings - presence affects projection and sillage 73 + drydownRating: 1.0 * technicalBoost, 74 + midProjection: presenceMultiplier * technicalBoost, 75 + sillage: presenceMultiplier * technicalBoost, 76 + 77 + // Final ratings - longevity and complexity preferences 78 + endRating: longevityMultiplier * technicalBoost, 79 + complexity: complexityMultiplier * technicalBoost, 80 + longevity: longevityMultiplier * technicalBoost, 81 + 82 + // Overall gut rating - affected by scoring approach 83 + overallRating: overallMultiplier, 84 + } 85 + } 86 + 87 + /** 88 + * Default weights when no preferences are set. 89 + * All weights are 1.0 (equal importance). 90 + */ 91 + export const DEFAULT_WEIGHTS: ScoringWeights = { 92 + openingRating: 1.0, 93 + openingProjection: 1.0, 94 + drydownRating: 1.0, 95 + midProjection: 1.0, 96 + sillage: 1.0, 97 + endRating: 1.0, 98 + complexity: 1.0, 99 + longevity: 1.0, 100 + overallRating: 1.0, 101 + }