feat(wishlist): redesign stats bar and filter layout

- Replace 4-card stats grid with single horizontal bar showing
  total items, high priority count, submissions, and total budget
- Add search input for filtering by name/description
- Add priority filter dropdown
- Move Add Item button to header row next to Export/Refresh
- Widen filter dropdowns to prevent text truncation
This commit is contained in:
Nicholai Vogel 2026-01-22 03:57:42 -07:00
parent 186c1c21b8
commit f9e7dbd464
4 changed files with 137 additions and 136 deletions

View File

@ -476,45 +476,32 @@ export async function getItemWithComments(itemId: string, userId: string) {
} }
} }
export async function getWishlistStats(userId: string) { export interface WishlistStats {
totalItems: number
highPriority: number
yourSubmissions: number
totalBudget: number
}
export async function getWishlistStats(userId: string): Promise<WishlistStats> {
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
const allItems = await db.select().from(wishlistItems) const allItems = await db.select().from(wishlistItems)
const allVotes = await db.select().from(wishlistVotes)
const totalItems = allItems.length const totalItems = allItems.length
const yourItems = allItems.filter((i) => i.submittedBy === userId).length const highPriority = allItems.filter((i) => i.priority === "high").length
const yourSubmissions = allItems.filter((i) => i.submittedBy === userId).length
let mostWanted = null const totalBudget = allItems.reduce(
if (allItems.length > 0) { (sum, item) => sum + (item.estimatedCost ?? 0),
const itemScores = allItems.map((item) => { 0
const itemVotes = allVotes.filter((v) => v.itemId === item.id) )
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
return {
item,
score: upvotes - downvotes,
}
})
const topItem = itemScores.sort((a, b) => b.score - a.score)[0]
if (topItem.score > 0) {
mostWanted = { name: topItem.item.name, score: topItem.score }
}
}
const recentItems = allItems
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
.slice(0, 5).length
return { return {
totalItems, totalItems,
yourItems, highPriority,
mostWanted, yourSubmissions,
recentItems, totalBudget,
} }
} }

View File

@ -1,7 +1,6 @@
"use client" "use client"
import { IconPlus } from "@tabler/icons-react" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -11,54 +10,73 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
interface WishlistFiltersProps { interface WishlistFiltersProps {
search: string
category: string category: string
priority: string
sortBy: string sortBy: string
onSearchChange: (search: string) => void
onCategoryChange: (category: string) => void onCategoryChange: (category: string) => void
onPriorityChange: (priority: string) => void
onSortChange: (sort: string) => void onSortChange: (sort: string) => void
onAddClick: () => void
} }
export function WishlistFilters({ export function WishlistFilters({
search,
category, category,
priority,
sortBy, sortBy,
onSearchChange,
onCategoryChange, onCategoryChange,
onPriorityChange,
onSortChange, onSortChange,
onAddClick,
}: WishlistFiltersProps) { }: WishlistFiltersProps) {
return ( return (
<div className="flex items-center justify-between gap-4 px-4 lg:px-6"> <div className="flex flex-wrap items-center gap-4 px-4 lg:px-6">
<div className="flex items-center gap-2"> <Input
<Select value={category} onValueChange={onCategoryChange}> placeholder="Search items..."
<SelectTrigger className="w-[140px]" size="sm"> value={search}
<SelectValue placeholder="Category" /> onChange={(e) => onSearchChange(e.target.value)}
</SelectTrigger> className="h-9 w-56"
<SelectContent> />
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="hardware">Hardware</SelectItem>
<SelectItem value="software">Software</SelectItem>
<SelectItem value="network">Network</SelectItem>
<SelectItem value="storage">Storage</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={onSortChange}> <Select value={category} onValueChange={onCategoryChange}>
<SelectTrigger className="w-[130px]" size="sm"> <SelectTrigger className="w-40">
<SelectValue placeholder="Sort by" /> <SelectValue placeholder="Category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="score">Top Score</SelectItem> <SelectItem value="all">All Categories</SelectItem>
<SelectItem value="newest">Newest</SelectItem> <SelectItem value="hardware">Hardware</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem> <SelectItem value="software">Software</SelectItem>
<SelectItem value="priority">Priority</SelectItem> <SelectItem value="network">Network</SelectItem>
</SelectContent> <SelectItem value="storage">Storage</SelectItem>
</Select> <SelectItem value="other">Other</SelectItem>
</div> </SelectContent>
</Select>
<Button size="sm" onClick={onAddClick}> <Select value={priority} onValueChange={onPriorityChange}>
<IconPlus className="size-4" /> <SelectTrigger className="w-36">
<span className="hidden sm:inline">Add Item</span> <SelectValue placeholder="Priority" />
</Button> </SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="score">Top Score</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
</div> </div>
) )
} }

View File

@ -1,74 +1,42 @@
"use client" "use client"
import { IconList, IconStar, IconUser, IconClock } from "@tabler/icons-react" import { Card } from "@/components/ui/card"
import { import type { WishlistStats as WishlistStatsType } from "@/app/actions/wishlist"
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
interface WishlistStatsProps { interface WishlistStatsProps {
stats: { stats: WishlistStatsType
totalItems: number
yourItems: number
mostWanted: { name: string; score: number } | null
recentItems: number
}
} }
export function WishlistStats({ stats }: WishlistStatsProps) { export function WishlistStats({ stats }: WishlistStatsProps) {
return ( return (
<div className="grid grid-cols-1 gap-4 px-4 lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="px-4 lg:px-6">
<Card> <Card className="px-4 py-3">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<CardDescription>Total Items</CardDescription> <span>
<IconList className="text-muted-foreground size-4" /> <span className="text-muted-foreground">Total Items:</span>{" "}
</CardHeader> <span className="font-medium">{stats.totalItems}</span>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums"> </span>
{stats.totalItems} <span className="text-muted-foreground">|</span>
</CardTitle> <span>
</Card> <span className="text-muted-foreground">High Priority:</span>{" "}
<span className="font-medium">{stats.highPriority}</span>
<Card> </span>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <span className="text-muted-foreground">|</span>
<CardDescription>Most Wanted</CardDescription> <span>
<IconStar className="text-muted-foreground size-4" /> <span className="text-muted-foreground">Your Submissions:</span>{" "}
</CardHeader> <span className="font-medium">{stats.yourSubmissions}</span>
<CardTitle className="px-6 pb-6 text-2xl font-semibold"> </span>
{stats.mostWanted ? ( <span className="text-muted-foreground">|</span>
<span className="flex items-center gap-2"> <span>
<span className="truncate">{stats.mostWanted.name}</span> <span className="text-muted-foreground">Est. Total Budget:</span>{" "}
<span className="text-muted-foreground text-sm font-normal"> <span className="font-medium">
(score: {stats.mostWanted.score}) ${stats.totalBudget.toLocaleString(undefined, {
</span> minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span> </span>
) : ( </span>
<span className="text-muted-foreground text-base font-normal"> </div>
No votes yet
</span>
)}
</CardTitle>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Your Submissions</CardDescription>
<IconUser className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
{stats.yourItems}
</CardTitle>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Recent (Last 5)</CardDescription>
<IconClock className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
{stats.recentItems}
</CardTitle>
</Card> </Card>
</div> </div>
) )

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useTransition, useCallback } from "react" import { useState, useEffect, useTransition, useCallback, useMemo } from "react"
import { import {
IconArrowBigDown, IconArrowBigDown,
IconArrowBigDownFilled, IconArrowBigDownFilled,
@ -13,6 +13,7 @@ import {
IconFileSpreadsheet, IconFileSpreadsheet,
IconFileTypePdf, IconFileTypePdf,
IconLoader2, IconLoader2,
IconPlus,
IconRefresh, IconRefresh,
IconTrash, IconTrash,
} from "@tabler/icons-react" } from "@tabler/icons-react"
@ -47,6 +48,7 @@ import {
toggleItemVote, toggleItemVote,
deleteWishlistItem, deleteWishlistItem,
type WishlistItemWithMeta, type WishlistItemWithMeta,
type WishlistStats as WishlistStatsType,
type SortOption, type SortOption,
type VoteType, type VoteType,
} from "@/app/actions/wishlist" } from "@/app/actions/wishlist"
@ -66,18 +68,31 @@ interface WishlistTableProps {
export function WishlistTable({ userId, userName }: WishlistTableProps) { export function WishlistTable({ userId, userName }: WishlistTableProps) {
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [items, setItems] = useState<WishlistItemWithMeta[]>([]) const [items, setItems] = useState<WishlistItemWithMeta[]>([])
const [stats, setStats] = useState({ const [stats, setStats] = useState<WishlistStatsType>({
totalItems: 0, totalItems: 0,
yourItems: 0, highPriority: 0,
mostWanted: null as { name: string; score: number } | null, yourSubmissions: 0,
recentItems: 0, totalBudget: 0,
}) })
const [search, setSearch] = useState("")
const [category, setCategory] = useState("all") const [category, setCategory] = useState("all")
const [priority, setPriority] = useState("all")
const [sortBy, setSortBy] = useState<SortOption>("score") const [sortBy, setSortBy] = useState<SortOption>("score")
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null) const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const filteredItems = useMemo(() => {
return items.filter((item) => {
const matchesSearch =
search === "" ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
const matchesPriority = priority === "all" || item.priority === priority
return matchesSearch && matchesPriority
})
}, [items, search, priority])
const fetchData = useCallback(() => { const fetchData = useCallback(() => {
startTransition(async () => { startTransition(async () => {
const [itemsData, statsData] = await Promise.all([ const [itemsData, statsData] = await Promise.all([
@ -100,7 +115,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
const getExportData = () => { const getExportData = () => {
const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"] const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"]
const rows = items.map((item) => [ const rows = filteredItems.map((item) => [
item.name, item.name,
item.description, item.description,
item.category, item.category,
@ -178,7 +193,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={items.length === 0} disabled={filteredItems.length === 0}
className="gap-2" className="gap-2"
> >
<IconDownload className="size-4" /> <IconDownload className="size-4" />
@ -206,17 +221,24 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
<IconRefresh className={cn("size-4", isPending && "animate-spin")} /> <IconRefresh className={cn("size-4", isPending && "animate-spin")} />
<span className="hidden sm:inline">Refresh</span> <span className="hidden sm:inline">Refresh</span>
</Button> </Button>
<Button size="sm" onClick={() => setAddDialogOpen(true)} className="gap-2">
<IconPlus className="size-4" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</div> </div>
</div> </div>
<WishlistStats stats={stats} /> <WishlistStats stats={stats} />
<WishlistFilters <WishlistFilters
search={search}
category={category} category={category}
priority={priority}
sortBy={sortBy} sortBy={sortBy}
onSearchChange={setSearch}
onCategoryChange={setCategory} onCategoryChange={setCategory}
onPriorityChange={setPriority}
onSortChange={(sort) => setSortBy(sort as SortOption)} onSortChange={(sort) => setSortBy(sort as SortOption)}
onAddClick={() => setAddDialogOpen(true)}
/> />
<div className="px-4 lg:px-6"> <div className="px-4 lg:px-6">
@ -233,6 +255,12 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
Be the first to add something! Be the first to add something!
</p> </p>
</div> </div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<p className="text-muted-foreground text-center">
No items match your filters.
</p>
</div>
) : ( ) : (
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>
@ -248,7 +276,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((item) => ( {filteredItems.map((item) => (
<WishlistTableRow <WishlistTableRow
key={item.id} key={item.id}
item={item} item={item}