feat: Support decimal ratings (e.g., 9.2)

- Rating/Review models now use float instead of int
- Star rating component shows partial fills
- Numeric value displayed while rating
- Supports precision: 0.1 increments
This commit is contained in:
fullsizemalt 2025-12-21 17:53:56 -08:00
parent ee311c0bc4
commit 835299fab5
3 changed files with 110 additions and 32 deletions

View file

@ -148,7 +148,7 @@ class Comment(SQLModel, table=True):
class Rating(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
score: int = Field(ge=1, le=10, description="Rating from 1 to 10")
score: float = Field(ge=1.0, le=10.0, description="Rating from 1.0 to 10.0")
created_at: datetime = Field(default_factory=datetime.utcnow)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
@ -222,7 +222,7 @@ class Review(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id")
blurb: str = Field(description="One-liner/pullquote")
content: str = Field(description="Full review text")
score: int = Field(ge=1, le=10)
score: float = Field(ge=1.0, le=10.0)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")

View file

@ -219,7 +219,7 @@ class CommentRead(CommentBase):
# We might want to include the username here later
class RatingBase(SQLModel):
score: int
score: float
show_id: Optional[int] = None
song_id: Optional[int] = None
performance_id: Optional[int] = None
@ -237,7 +237,7 @@ class RatingRead(RatingBase):
class ReviewBase(SQLModel):
blurb: str
content: str
score: int
score: float
show_id: Optional[int] = None
venue_id: Optional[int] = None
song_id: Optional[int] = None

View file

@ -1,47 +1,125 @@
import { useState } from "react"
"use client"
import { useState, useRef } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingProps {
value: number
onChange?: (value: number) => void
readonly?: boolean
className?: string
size?: "sm" | "md"
size?: "sm" | "md" | "lg"
precision?: "full" | "half" | "decimal"
}
export function StarRating({ value, onChange, readonly = false, className, size = "md" }: RatingProps) {
export function StarRating({
value,
onChange,
readonly = false,
className,
size = "md",
precision = "decimal"
}: RatingProps) {
const [hoverValue, setHoverValue] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const stars = Array.from({ length: 10 }, (_, i) => i + 1)
const starSize = size === "sm" ? "h-3 w-3" : "h-4 w-4"
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const starSize = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5"
}[size]
const handleMouseMove = (e: React.MouseEvent, starIndex: number) => {
if (readonly) return
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const width = rect.width
const fraction = x / width
let newValue: number
if (precision === "decimal") {
// Allow 0.1 precision (e.g., 9.2)
newValue = starIndex + Math.round(fraction * 10) / 10
newValue = Math.max(starIndex, Math.min(starIndex + 1, newValue))
} else if (precision === "half") {
// Half-star precision
newValue = fraction < 0.5 ? starIndex + 0.5 : starIndex + 1
} else {
// Full star only
newValue = starIndex + 1
}
setHoverValue(newValue)
}
const handleClick = () => {
if (!readonly && hoverValue !== null && onChange) {
// Round to 1 decimal place
onChange(Math.round(hoverValue * 10) / 10)
}
}
const displayValue = hoverValue !== null ? hoverValue : value
// Calculate fill percentage for each star
const getStarFill = (starIndex: number): number => {
const starStart = starIndex
const starEnd = starIndex + 1
if (displayValue >= starEnd) return 100
if (displayValue <= starStart) return 0
return (displayValue - starStart) * 100
}
return (
<div
ref={containerRef}
className={cn("flex gap-0.5 items-center", className)}
onMouseLeave={() => !readonly && setHoverValue(null)}
>
{stars.map((star, index) => {
const fillPercent = getStarFill(index)
return (
<div className={cn("flex gap-0.5", className)}>
{stars.map((star) => (
<button
key={star}
type="button"
disabled={readonly}
className={cn(
"p-0.5 transition-colors",
"p-0.5 transition-transform relative",
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
)}
onMouseEnter={() => !readonly && setHoverValue(star)}
onMouseLeave={() => !readonly && setHoverValue(null)}
onClick={() => !readonly && onChange?.(star)}
onMouseMove={(e) => handleMouseMove(e, index)}
onClick={handleClick}
>
{/* Background star (empty) */}
<Star
className={cn(starSize, "fill-muted text-muted-foreground")}
/>
{/* Foreground star (filled) - uses clip-path for partial fill */}
<div
className="absolute inset-0 p-0.5 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - fillPercent}% 0 0)`
}}
>
<Star
className={cn(
starSize,
(hoverValue !== null ? star <= hoverValue : star <= value)
? "fill-primary text-primary"
: "fill-muted text-muted-foreground"
)}
className={cn(starSize, "fill-primary text-primary")}
/>
</div>
</button>
))}
)
})}
{/* Display numeric value */}
{!readonly && (
<span className="ml-2 text-sm font-mono text-muted-foreground min-w-[2.5rem]">
{displayValue > 0 ? displayValue.toFixed(1) : "—"}
</span>
)}
</div>
)
}