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

Data Models#

Related Documents:

Overview#

TypeScript interfaces and types that map to AT Protocol lexicons and application state.

Status: 🔴 Planned


Core Types#

FragranceReview#

Maps to social.drydown.review lexicon:

interface FragranceReview {
  // Metadata (not in AT Protocol record)
  id?: string // Local ID for in-progress reviews
  uri?: string // AT Protocol record URI: at://did/collection/rkey

  // Fragrance info
  fragrance: {
    name: string // 1-200 chars, required
    brand?: string // 0-100 chars, optional
    year?: number // 1000-2100, optional
  }

  // Timestamps (ISO 8601 format)
  createdAt: string // When Stage 1 started
  stage2CompletedAt?: string // When Stage 2 completed
  stage3CompletedAt?: string // When Stage 3 completed
  completedAt?: string // When all stages done

  // Ratings
  stage1: Stage1Ratings
  stage2?: Stage2Ratings
  stage3?: Stage3Ratings

  // Review content
  textReview?: string // Max 275 graphemes
  calculatedRating?: number // 0-5, to 3 decimal places

  // Sharing
  sharedToBluesky: boolean
  sharedAt?: string // ISO 8601, when last shared
}

Stage Ratings#

interface Stage1Ratings {
  openingAppeal: number // 1-5, integer
  projection: number // 1-5, integer
  firstImpression: number // 1-5, integer
}

interface Stage2Ratings {
  heartAppeal: number // 1-5, integer
  heartProjection: number // 1-5, integer
  skipped: boolean
}

interface Stage3Ratings {
  stillDetectable: number // 1-5, integer (longevity)
  sillage: number // 1-5, integer
  skinChemistry: number // 1-5, integer
  complexity: number // 1-5, integer
  gutScore: number // 1-5, integer
}

UserSettings#

Maps to social.drydown.settings lexicon:

interface UserSettings {
  // Metadata
  createdAt: string // ISO 8601
  updatedAt: string // ISO 8601

  // Rating weights
  ratingWeights: RatingWeights

  // App preferences
  blueskyClient: 'default' | 'blacksky' | 'graysky'
}

interface RatingWeights {
  openingAppeal: number // 0-10, default 1.0
  projection: number // 0-10, default 1.0
  firstImpression: number // 0-10, default 1.0
  heartAppeal: number // 0-10, default 1.0
  heartProjection: number // 0-10, default 1.0
  stillDetectable: number // 0-10, default 1.0
  sillage: number // 0-10, default 1.0
  skinChemistry: number // 0-10, default 1.0
  complexity: number // 0-10, default 1.0
  gutScore: number // 0-10, default 1.0
}

const DEFAULT_WEIGHTS: RatingWeights = {
  openingAppeal: 1.0,
  projection: 1.0,
  firstImpression: 1.0,
  heartAppeal: 1.0,
  heartProjection: 1.0,
  stillDetectable: 1.0,
  sillage: 1.0,
  skinChemistry: 1.0,
  complexity: 1.0,
  gutScore: 1.0
}

Utility Functions#

Calculate Final Rating#

function calculateFinalRating(
  review: FragranceReview,
  weights: RatingWeights
): number {
  let totalWeightedScore = 0
  let totalWeights = 0

  // Stage 1 (always present)
  if (review.stage1) {
    totalWeightedScore += review.stage1.openingAppeal * weights.openingAppeal
    totalWeights += weights.openingAppeal

    totalWeightedScore += review.stage1.projection * weights.projection
    totalWeights += weights.projection

    totalWeightedScore += review.stage1.firstImpression * weights.firstImpression
    totalWeights += weights.firstImpression
  }

  // Stage 2 (if not skipped)
  if (review.stage2 && !review.stage2.skipped) {
    totalWeightedScore += review.stage2.heartAppeal * weights.heartAppeal
    totalWeights += weights.heartAppeal

    totalWeightedScore += review.stage2.heartProjection * weights.heartProjection
    totalWeights += weights.heartProjection
  }

  // Stage 3 (always present if review completed)
  if (review.stage3) {
    totalWeightedScore += review.stage3.stillDetectable * weights.stillDetectable
    totalWeights += weights.stillDetectable

    totalWeightedScore += review.stage3.sillage * weights.sillage
    totalWeights += weights.sillage

    totalWeightedScore += review.stage3.skinChemistry * weights.skinChemistry
    totalWeights += weights.skinChemistry

    totalWeightedScore += review.stage3.complexity * weights.complexity
    totalWeights += weights.complexity

    totalWeightedScore += review.stage3.gutScore * weights.gutScore
    totalWeights += weights.gutScore
  }

  // Handle edge case: all weights are 0
  if (totalWeights === 0) {
    return 0
  }

  const rating = totalWeightedScore / totalWeights

  // Round to 3 decimal places
  return Math.round(rating * 1000) / 1000
}

Format Rating for Display#

function formatRatingPrecise(rating: number): string {
  return `${rating.toFixed(3)} / 5.000`
}

function formatRatingStars(rating: number): string {
  const rounded = Math.round(rating)
  const filled = '★'.repeat(rounded)
  const empty = '☆'.repeat(5 - rounded)
  return filled + empty
}

Count Graphemes#

function countGraphemes(text: string): number {
  const segmenter = new Intl.Segmenter()
  return Array.from(segmenter.segment(text)).length
}

function validateTextLength(text: string, maxGraphemes: number): boolean {
  return countGraphemes(text) <= maxGraphemes
}

State Management Types#

App State#

interface AppState {
  // Auth
  session: OAuthSession | null
  isInitializing: boolean

  // Reviews
  inProgressReviews: FragranceReview[]
  completedReviews: FragranceReview[]

  // Settings
  userSettings: UserSettings

  // UI
  loading: boolean
  error: string | null
}

Related Documents: