From d38b3281d987dc9cc800246a9e699aa39e3d78bd Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:07:21 -0500 Subject: [PATCH 01/12] feat(bagcheck): add Bag Check page for post-practice disc accounting --- src/app/v/[vaultId]/bagcheck/page.tsx | 92 ++++++++ src/app/v/[vaultId]/layout.tsx | 3 +- src/components/BagCheckList.tsx | 327 ++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/app/v/[vaultId]/bagcheck/page.tsx create mode 100644 src/components/BagCheckList.tsx diff --git a/src/app/v/[vaultId]/bagcheck/page.tsx b/src/app/v/[vaultId]/bagcheck/page.tsx new file mode 100644 index 0000000..5aa00a2 --- /dev/null +++ b/src/app/v/[vaultId]/bagcheck/page.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2026 ChaoticGood007 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { db as prisma } from "@/lib/prisma" +import { notFound } from "next/navigation" +import { getLocationTree } from "@/app/actions/settings" +import { flattenTree } from "@/lib/locationTree" +import BagCheckList from "@/components/BagCheckList" + +export const dynamic = 'force-dynamic' + +export default async function BagCheckPage({ + params, +}: { + params: Promise<{ vaultId: string }> +}) { + const { vaultId } = await params + + const vault = await prisma.discCollection.findUnique({ + where: { id: vaultId }, + }) + + if (!vault) notFound() + + // Get the location tree for this vault and find all bag locations + const locationTree = await getLocationTree(vaultId) + const flatLocs = flattenTree(locationTree) + const bagLocations = flatLocs.filter(l => l.node.inBag) + + // Get ALL discs that are in ANY bag location (we'll filter client-side by selected bag) + const bagPaths = bagLocations.map(l => l.value) + + // Build OR conditions for all bag paths (exact match + children) + const locationConditions = bagPaths.flatMap(p => [ + { location: p }, + { location: { startsWith: p + '/' } } + ]) + + const discs = locationConditions.length > 0 + ? await prisma.inventory.findMany({ + where: { + collectionId: vaultId, + OR: locationConditions, + }, + include: { mold: true }, + orderBy: [ + { mold: { category: 'asc' } }, + { mold: { speed: 'desc' } }, + { mold: { name: 'asc' } }, + ], + }) + : [] + + return ( +
+ ({ label: l.path, value: l.value }))} + discs={discs.map(d => ({ + id: d.id, + moldName: d.mold.name, + brand: d.mold.brand, + category: d.mold.category, + speed: d.mold.speed, + glide: d.mold.glide, + turn: d.mold.turn, + fade: d.mold.fade, + plastic: d.plastic, + weight: d.weight, + color: d.color, + secondaryColor: d.secondaryColor, + secondaryPattern: d.secondaryPattern, + stampFoil: d.stampFoil, + location: d.location, + }))} + /> +
+ ) +} diff --git a/src/app/v/[vaultId]/layout.tsx b/src/app/v/[vaultId]/layout.tsx index d095743..781af72 100644 --- a/src/app/v/[vaultId]/layout.tsx +++ b/src/app/v/[vaultId]/layout.tsx @@ -18,7 +18,7 @@ import { db as prisma } from "@/lib/prisma" import Link from "next/link" import { cookies } from "next/headers" import { notFound } from "next/navigation" -import { LayoutDashboard, BarChart3, Plus, Upload, Wind } from "lucide-react" +import { LayoutDashboard, BarChart3, Plus, Upload, Wind, ClipboardCheck } from "lucide-react" import { Header } from "@/components/Header" import VaultSwitcher from "@/components/VaultSwitcher" import ExportButton from "@/components/ExportButton" @@ -59,6 +59,7 @@ export default async function WorkspaceLayout({ { label: 'Inventory', href: inventoryHref, icon: LayoutDashboard }, { label: 'Analytics', href: `/v/${vaultId}/stats`, icon: BarChart3 }, { label: 'Flight Chart', href: `/v/${vaultId}/chart`, icon: Wind }, + { label: 'Bag Check', href: `/v/${vaultId}/bagcheck`, icon: ClipboardCheck }, { label: 'Add Disc', href: `/v/${vaultId}/add`, icon: Plus }, { label: 'Import', href: `/v/${vaultId}/import`, icon: Upload }, ] diff --git a/src/components/BagCheckList.tsx b/src/components/BagCheckList.tsx new file mode 100644 index 0000000..b9b4c6e --- /dev/null +++ b/src/components/BagCheckList.tsx @@ -0,0 +1,327 @@ +/* + * Copyright 2026 ChaoticGood007 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client' + +import { useState, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { RotateCcw, CheckCircle2, Circle, PackageCheck, AlertTriangle, Backpack } from 'lucide-react' +import DiscPreview from '@/components/DiscPreview' + +interface BagCheckDisc { + id: string + moldName: string + brand: string + category: string + speed: number + glide: number + turn: number + fade: number + plastic: string | null + weight: number | null + color: string | null + secondaryColor: string | null + secondaryPattern: string | null + stampFoil: string | null + location: string | null +} + +interface BagOption { + label: string + value: string +} + +interface BagCheckListProps { + vaultName: string + bags: BagOption[] + discs: BagCheckDisc[] +} + +export default function BagCheckList({ vaultName, bags, discs }: BagCheckListProps) { + const [selectedBag, setSelectedBag] = useState(bags.length === 1 ? bags[0].value : null) + const [checked, setChecked] = useState>(new Set()) + + // Filter discs to only those in the selected bag (exact match or child location) + const filteredDiscs = useMemo(() => { + if (!selectedBag) return [] + return discs.filter(d => + d.location === selectedBag || d.location?.startsWith(selectedBag + '/') + ) + }, [discs, selectedBag]) + + const toggle = (id: string) => { + setChecked(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const resetAll = () => setChecked(new Set()) + const checkAll = () => setChecked(new Set(filteredDiscs.map(d => d.id))) + + const handleBagChange = (bagValue: string) => { + setSelectedBag(bagValue) + setChecked(new Set()) + } + + const checkedCount = filteredDiscs.filter(d => checked.has(d.id)).length + const totalCount = filteredDiscs.length + const missingCount = totalCount - checkedCount + const allChecked = checkedCount === totalCount && totalCount > 0 + + // Group discs by sub-location within the selected bag + const grouped = useMemo(() => { + const groups: Record = {} + filteredDiscs.forEach(disc => { + let groupLabel = 'General' + if (disc.location && selectedBag && disc.location.startsWith(selectedBag + '/')) { + // Strip the bag prefix to get the sub-location path + const subPath = disc.location.slice(selectedBag.length + 1) + // Use only the first level of the sub-path as the group name + groupLabel = subPath.split('/')[0] + } + if (!groups[groupLabel]) groups[groupLabel] = [] + groups[groupLabel].push(disc) + }) + // Sort each group by speed (desc), then net stability (turn + fade, desc) + Object.values(groups).forEach(g => + g.sort((a, b) => b.speed - a.speed || (b.turn + b.fade) - (a.turn + a.fade)) + ) + return groups + }, [filteredDiscs, selectedBag]) + + const progressPercent = totalCount > 0 ? (checkedCount / totalCount) * 100 : 0 + + // No bags configured state + if (bags.length === 0) { + return ( +
+
+ +
+

No Bags Configured

+

+ Mark locations as "In Bag" in your vault's location tree under Settings → Location Tree to use the Bag Check feature. +

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ Bag Check +

{vaultName}

+
+ {selectedBag && ( +
+ + +
+ )} +
+ + {/* Bag Selector */} +
+
+ {bags.map(bag => ( + + ))} +
+ + {/* Progress Bar — only show when a bag is selected */} + {selectedBag && ( +
+
+ + {allChecked ? 'All accounted for!' : `${checkedCount} of ${totalCount} found`} + + {!allChecked && missingCount > 0 && checkedCount > 0 && ( + + + {missingCount} missing + + )} +
+
+ +
+
+ )} +
+
+ + {/* No bag selected prompt */} + {!selectedBag && ( +
+ +

Select a bag above to start checking

+
+ )} + + {/* All Clear Banner */} + + {allChecked && totalCount > 0 && ( + +
+ +
+
+

Bag is complete!

+

All {totalCount} discs accounted for. You're good to go.

+
+
+ )} +
+ + {/* Disc Checklist by Category */} + {selectedBag && Object.entries(grouped).map(([category, categoryDiscs]) => { + const catCheckedCount = categoryDiscs.filter(d => checked.has(d.id)).length + const catAllChecked = catCheckedCount === categoryDiscs.length + + return ( +
+ {/* Category Header */} +
+
+ + {category} + + + {catCheckedCount}/{categoryDiscs.length} + +
+
+ + {/* Disc Items */} +
+ {categoryDiscs.map((disc) => { + const isChecked = checked.has(disc.id) + + return ( + toggle(disc.id)} + className={`w-full flex items-center gap-4 px-6 py-4 text-left transition-all active:scale-[0.98] ${ + isChecked + ? 'bg-emerald-50/50' + : 'bg-white hover:bg-slate-50' + }`} + > + {/* Check Icon */} +
+ + {isChecked ? ( + + + + ) : ( + + + + )} + +
+ + {/* Disc Preview */} +
+ +
+ + {/* Disc Info */} +
+
+ + {disc.moldName} + + + {disc.speed}/{disc.glide}/{disc.turn}/{disc.fade} + +
+
+ {[disc.brand, disc.plastic, disc.weight ? `${disc.weight}g` : null].filter(Boolean).join(' · ')} +
+
+
+ ) + })} +
+
+ ) + })} +
+ ) +} From eb0d019e7282d573bdcb90966f24fd65847458df Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:25:39 -0500 Subject: [PATCH 02/12] fix(import): auto-map SecondaryColor/Pattern headers and preserve 0 flight numbers --- src/app/actions/inventory.ts | 23 +++++++++++++++-------- src/components/CSVImporter.tsx | 12 ++++++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index 4c8b4ad..0192690 100644 --- a/src/app/actions/inventory.ts +++ b/src/app/actions/inventory.ts @@ -28,6 +28,13 @@ function parseUserFlight(val: FormDataEntryValue | null): number | null { return isNaN(n) ? null : n } +/** Parse a float from a CSV value, returning a fallback if empty/invalid. */ +function parseSafeFloat(val: any, fallback: number = 0): number { + if (val === null || val === undefined || val === '') return fallback + const n = parseFloat(val) + return isNaN(n) ? fallback : n +} + export async function addDisc(formData: FormData) { const moldId = formData.get('moldId') as string const collectionId = formData.get('collectionId') as string || null @@ -212,10 +219,10 @@ export async function importDiscs(records: any[], targetCollectionId?: string) { category = CATEGORY_SYNONYMS[normalizedCategory] } - const speed = parseFloat(record.speed) || 0 - const glide = parseFloat(record.glide) || 0 - const turn = parseFloat(record.turn) || 0 - const fade = parseFloat(record.fade) || 0 + const speed = parseSafeFloat(record.speed) + const glide = parseSafeFloat(record.glide) + const turn = parseSafeFloat(record.turn) + const fade = parseSafeFloat(record.fade) mold = await prisma.mold.create({ data: { name: moldName, brand: moldBrand, @@ -234,7 +241,7 @@ export async function importDiscs(records: any[], targetCollectionId?: string) { mold: { connect: { id: mold.id } }, collection: targetCollectionId ? { connect: { id: targetCollectionId } } : undefined, plastic: record.plastic || null, - weight: parseFloat(record.weight) || null, + weight: parseSafeFloat(record.weight, NaN) || null, color: record.color || null, secondaryColor: record.secondaryColor || null, secondaryPattern: record.secondaryPattern || null, @@ -244,9 +251,9 @@ export async function importDiscs(records: any[], targetCollectionId?: string) { condition: parseInt(record.condition) || null, ink: record.ink || null, notes: record.notes || null, - userGlide: parseFloat(record.userGlide) || null, - userTurn: parseFloat(record.userTurn) || null, - userFade: parseFloat(record.userFade) || null, + userGlide: parseUserFlight(record.userGlide ?? null), + userTurn: parseUserFlight(record.userTurn ?? null), + userFade: parseUserFlight(record.userFade ?? null), } }) successCount++ diff --git a/src/components/CSVImporter.tsx b/src/components/CSVImporter.tsx index 26075f3..7b08a83 100644 --- a/src/components/CSVImporter.tsx +++ b/src/components/CSVImporter.tsx @@ -83,10 +83,14 @@ export default function CSVImporter({ targetVault, collections }: CSVImporterPro const normalizedHeaders = (results.meta.fields || []).map(h => h.toLowerCase().trim()) SCHEMA_FIELDS.forEach(field => { - const matchIndex = normalizedHeaders.findIndex(h => - h === field.id.toLowerCase() || - h.includes(field.label.toLowerCase().split('/')[0].trim()) - ) + const normalizedId = field.id.toLowerCase() + const normalizedLabel = field.label.toLowerCase().split('/')[0].trim().replace(/\s+/g, '') + const matchIndex = normalizedHeaders.findIndex(h => { + const stripped = h.replace(/\s+/g, '') + return stripped === normalizedId || + stripped.includes(normalizedLabel) || + h.includes(field.label.toLowerCase().split('/')[0].trim()) + }) if (matchIndex !== -1) { initialMap[field.id] = (results.meta.fields || [])[matchIndex] } From 44f2ca6d9d051b2159afb638eb6014753a573c1b Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:33:27 -0500 Subject: [PATCH 03/12] chore: release 0.5.0 --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2478a42..843e891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-04-25 + +### Added +- **Bag Check Feature:** A new tool to help you make sure you haven't left any discs behind after practice or a round. Accessed from the vault header, it lets you select one of your configured bags and gives you an interactive checklist of all discs in that bag. Discs are grouped by their sub-location (e.g., 'Putter Pocket', 'Main Compartment') and sorted by speed (highest first) then net stability (most overstable first). + +### Fixed +- Fixed an issue with CSV imports where columns without spaces (e.g., `SecondaryColor`) were not being automatically mapped correctly. +- Fixed a bug where legitimate `0` flight numbers (e.g., `0` turn, `0` fade) were being ignored and overwritten during CSV imports. + ## [0.4.3] - 2026-04-25 ### Fixed diff --git a/package.json b/package.json index 99da2ac..abcce67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discvault", - "version": "0.4.3", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev -p 3001", From d2895f204ae94b2b778b5a7dedc2a234a7f55ac0 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:38:34 -0500 Subject: [PATCH 04/12] fix(sort): persist sort preferences across sessions globally via cookies --- CHANGELOG.md | 1 + src/app/v/[vaultId]/page.tsx | 8 +++++--- src/app/v/all/page.tsx | 8 +++++--- src/components/DashboardToolbar.tsx | 13 +++++++++++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 843e891..981724b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed an issue with CSV imports where columns without spaces (e.g., `SecondaryColor`) were not being automatically mapped correctly. - Fixed a bug where legitimate `0` flight numbers (e.g., `0` turn, `0` fade) were being ignored and overwritten during CSV imports. +- Fixed an issue where the user's sort preference was only saved temporarily in the URL; sort order now correctly persists across sessions and vaults globally via cookies. ## [0.4.3] - 2026-04-25 diff --git a/src/app/v/[vaultId]/page.tsx b/src/app/v/[vaultId]/page.tsx index ad69d16..996b200 100644 --- a/src/app/v/[vaultId]/page.tsx +++ b/src/app/v/[vaultId]/page.tsx @@ -47,10 +47,12 @@ export default async function VaultDashboard({ const category = typeof searchP.category === 'string' ? searchP.category : undefined const brand = typeof searchP.brand === 'string' ? searchP.brand : undefined const view = typeof searchP.view === 'string' ? searchP.view : 'cards' - const sortBy = (typeof searchP.sortBy === 'string' ? searchP.sortBy : 'createdAt') as SortField - const sortOrder = (typeof searchP.sortOrder === 'string' ? searchP.sortOrder : 'desc') as SortOrder - const cookieStore = await cookies() + const defaultSortBy = cookieStore.get('discVaultSortBy')?.value || 'createdAt' + const defaultSortOrder = cookieStore.get('discVaultSortOrder')?.value || 'desc' + const sortBy = (typeof searchP.sortBy === 'string' ? searchP.sortBy : defaultSortBy) as SortField + const sortOrder = (typeof searchP.sortOrder === 'string' ? searchP.sortOrder : defaultSortOrder) as SortOrder + const savedColsCookie = cookieStore.get('discVaultVisibleCols') const cookieCols = savedColsCookie ? savedColsCookie.value.split(',') : DEFAULT_COLUMNS const colsParam = typeof searchP.cols === 'string' ? searchP.cols.split(',') : cookieCols diff --git a/src/app/v/all/page.tsx b/src/app/v/all/page.tsx index bc1aaa8..00debb2 100644 --- a/src/app/v/all/page.tsx +++ b/src/app/v/all/page.tsx @@ -41,10 +41,12 @@ export default async function AllVaultsDashboard({ const category = typeof searchP.category === 'string' ? searchP.category : undefined const brand = typeof searchP.brand === 'string' ? searchP.brand : undefined const view = typeof searchP.view === 'string' ? searchP.view : 'cards' - const sortBy = (typeof searchP.sortBy === 'string' ? searchP.sortBy : 'createdAt') as SortField - const sortOrder = (typeof searchP.sortOrder === 'string' ? searchP.sortOrder : 'desc') as SortOrder - const cookieStore = await cookies() + const defaultSortBy = cookieStore.get('discVaultSortBy')?.value || 'createdAt' + const defaultSortOrder = cookieStore.get('discVaultSortOrder')?.value || 'desc' + const sortBy = (typeof searchP.sortBy === 'string' ? searchP.sortBy : defaultSortBy) as SortField + const sortOrder = (typeof searchP.sortOrder === 'string' ? searchP.sortOrder : defaultSortOrder) as SortOrder + const savedColsCookie = cookieStore.get('discVaultVisibleCols') const cookieCols = savedColsCookie ? savedColsCookie.value.split(',') : DEFAULT_COLUMNS const colsParam = typeof searchP.cols === 'string' ? searchP.cols.split(',') : cookieCols diff --git a/src/components/DashboardToolbar.tsx b/src/components/DashboardToolbar.tsx index ca349c1..7c1a1a1 100644 --- a/src/components/DashboardToolbar.tsx +++ b/src/components/DashboardToolbar.tsx @@ -438,9 +438,18 @@ export default function DashboardToolbar({ key={opt.id} onClick={() => { if (isActive) { - updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' }) + const newOrder = sortOrder === 'asc' ? 'desc' : 'asc' + if (typeof document !== 'undefined') { + document.cookie = `discVaultSortOrder=${newOrder}; path=/; max-age=31536000` + } + updateParams({ sortOrder: newOrder }) } else { - updateParams({ sortBy: opt.id, sortOrder: opt.id === 'createdAt' || opt.id === 'speed' || opt.id === 'condition' || opt.id === 'weight' ? 'desc' : 'asc' }) + const newOrder = opt.id === 'createdAt' || opt.id === 'speed' || opt.id === 'condition' || opt.id === 'weight' ? 'desc' : 'asc' + if (typeof document !== 'undefined') { + document.cookie = `discVaultSortBy=${opt.id}; path=/; max-age=31536000` + document.cookie = `discVaultSortOrder=${newOrder}; path=/; max-age=31536000` + } + updateParams({ sortBy: opt.id, sortOrder: newOrder }) } setShowSortSelector(false) }} From 90ff391f61f38f2d3e69604c0f4f754781ce6a56 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:46:42 -0500 Subject: [PATCH 05/12] feat: user flight numbers toggle --- CHANGELOG.md | 1 + src/app/v/[vaultId]/page.tsx | 8 +++++++ src/app/v/all/page.tsx | 8 +++++++ src/components/DashboardToolbar.tsx | 27 ++++++++++++++++++++++++ src/components/InventoryInfiniteList.tsx | 13 ++++++++---- src/components/InventoryList.tsx | 13 ++++++++---- 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 981724b..a678287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Bag Check Feature:** A new tool to help you make sure you haven't left any discs behind after practice or a round. Accessed from the vault header, it lets you select one of your configured bags and gives you an interactive checklist of all discs in that bag. Discs are grouped by their sub-location (e.g., 'Putter Pocket', 'Main Compartment') and sorted by speed (highest first) then net stability (most overstable first). +- **Custom Flight Numbers Toggle:** You can now toggle "Use Custom Flight #s" from the Columns dropdown on the inventory dashboards. When enabled, this will replace the stock mold flight numbers with your custom flight numbers (if defined) for that specific disc. ### Fixed - Fixed an issue with CSV imports where columns without spaces (e.g., `SecondaryColor`) were not being automatically mapped correctly. diff --git a/src/app/v/[vaultId]/page.tsx b/src/app/v/[vaultId]/page.tsx index 996b200..625c8b2 100644 --- a/src/app/v/[vaultId]/page.tsx +++ b/src/app/v/[vaultId]/page.tsx @@ -60,6 +60,11 @@ export default async function VaultDashboard({ const searchQuery = typeof searchP.search === 'string' ? searchP.search : undefined const inBagParam = typeof searchP.inBag === 'string' ? searchP.inBag : undefined + const userFlightCookie = cookieStore.get('discVaultUserFlightNum')?.value === 'true' + const useUserFlightNumbers = typeof searchP.useUserFlightNumbers === 'string' + ? searchP.useUserFlightNumbers === 'true' + : userFlightCookie + const minSpeed = searchP.minSpeed ? parseFloat(searchP.minSpeed as string) : undefined const maxSpeed = searchP.maxSpeed ? parseFloat(searchP.maxSpeed as string) : undefined const minGlide = searchP.minGlide ? parseFloat(searchP.minGlide as string) : undefined @@ -254,6 +259,7 @@ export default async function VaultDashboard({ sortBy={sortBy} sortOrder={sortOrder} visibleColumns={visibleCols} + useUserFlightNumbers={useUserFlightNumbers} /> @@ -300,6 +306,7 @@ export default async function VaultDashboard({ visibleColumns={visibleCols} categoryColors={categoryColors} bagPaths={bagPaths} + useUserFlightNumbers={useUserFlightNumbers} /> ) : ( @@ -314,6 +321,7 @@ export default async function VaultDashboard({ pageSize={pageSize} totalCount={totalCount} bagPaths={bagPaths} + useUserFlightNumbers={useUserFlightNumbers} /> )} diff --git a/src/app/v/all/page.tsx b/src/app/v/all/page.tsx index 00debb2..0809a98 100644 --- a/src/app/v/all/page.tsx +++ b/src/app/v/all/page.tsx @@ -55,6 +55,11 @@ export default async function AllVaultsDashboard({ const inBagParam = typeof searchP.inBag === 'string' ? searchP.inBag : undefined const selectedCollectionIds = typeof searchP.collections === 'string' ? searchP.collections.split(',') : [] + const userFlightCookie = cookieStore.get('discVaultUserFlightNum')?.value === 'true' + const useUserFlightNumbers = typeof searchP.useUserFlightNumbers === 'string' + ? searchP.useUserFlightNumbers === 'true' + : userFlightCookie + const minSpeed = searchP.minSpeed ? parseFloat(searchP.minSpeed as string) : undefined const maxSpeed = searchP.maxSpeed ? parseFloat(searchP.maxSpeed as string) : undefined const minGlide = searchP.minGlide ? parseFloat(searchP.minGlide as string) : undefined @@ -259,6 +264,7 @@ export default async function AllVaultsDashboard({ sortBy={sortBy} sortOrder={sortOrder} visibleColumns={visibleCols} + useUserFlightNumbers={useUserFlightNumbers} /> {totalCount === 0 ? ( @@ -293,6 +299,7 @@ export default async function AllVaultsDashboard({ totalCount={totalCount} visibleColumns={visibleCols} bagPaths={bagPaths} + useUserFlightNumbers={useUserFlightNumbers} /> ) : ( )} diff --git a/src/components/DashboardToolbar.tsx b/src/components/DashboardToolbar.tsx index 7c1a1a1..8d88bd7 100644 --- a/src/components/DashboardToolbar.tsx +++ b/src/components/DashboardToolbar.tsx @@ -62,6 +62,7 @@ interface DashboardToolbarProps { sortBy: string sortOrder: string visibleColumns: string[] + useUserFlightNumbers: boolean } const ALL_COLUMNS = [ @@ -101,6 +102,7 @@ export default function DashboardToolbar({ sortBy, sortOrder, visibleColumns, + useUserFlightNumbers, }: DashboardToolbarProps) { const router = useRouter() const searchParams = useSearchParams() @@ -162,6 +164,15 @@ export default function DashboardToolbar({ params.set('page', '1') } + // Explicitly handle boolean toggle to persist to URL + if (updates.useUserFlightNumbers !== undefined) { + if (updates.useUserFlightNumbers === null) { + params.delete('useUserFlightNumbers') + } else { + params.set('useUserFlightNumbers', updates.useUserFlightNumbers) + } + } + router.push(`${pathname}?${params.toString()}`) }, [searchParams, router, pathname]) @@ -504,6 +515,22 @@ export default function DashboardToolbar({ ) })} +
+ +
diff --git a/src/components/InventoryInfiniteList.tsx b/src/components/InventoryInfiniteList.tsx index bc83e31..306d632 100644 --- a/src/components/InventoryInfiniteList.tsx +++ b/src/components/InventoryInfiniteList.tsx @@ -38,6 +38,9 @@ export interface InventoryItem { condition: number | null ink: string | null notes: string | null + userGlide: number | null + userTurn: number | null + userFade: number | null createdAt: Date mold: { name: string @@ -59,6 +62,7 @@ interface InventoryInfiniteListProps { bagPaths?: string[] visibleColumns?: string[] categoryColors?: Record + useUserFlightNumbers?: boolean } export default function InventoryInfiniteList({ @@ -69,7 +73,8 @@ export default function InventoryInfiniteList({ totalCount, visibleColumns = ['brand', 'category', 'flight_numbers', 'plastic', 'weight', 'color', 'stamp', 'inBag', 'createdAt'], categoryColors, - bagPaths = [] + bagPaths = [], + useUserFlightNumbers = false }: InventoryInfiniteListProps) { const isInBag = (loc: string | null) => { if (!loc) return false @@ -231,9 +236,9 @@ export default function InventoryInfiniteList({
{[ { key: 'speed', label: 'S', val: item.mold.speed }, - { key: 'glide', label: 'G', val: item.mold.glide }, - { key: 'turn', label: 'T', val: item.mold.turn }, - { key: 'fade', label: 'F', val: item.mold.fade } + { key: 'glide', label: 'G', val: useUserFlightNumbers && item.userGlide !== null ? item.userGlide : item.mold.glide }, + { key: 'turn', label: 'T', val: useUserFlightNumbers && item.userTurn !== null ? item.userTurn : item.mold.turn }, + { key: 'fade', label: 'F', val: useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade } ].map((stat, idx) => (
diff --git a/src/components/InventoryList.tsx b/src/components/InventoryList.tsx index 73d9f72..6fcf2ac 100644 --- a/src/components/InventoryList.tsx +++ b/src/components/InventoryList.tsx @@ -39,6 +39,9 @@ interface InventoryItem { condition: number | null ink: string | null notes: string | null + userGlide: number | null + userTurn: number | null + userFade: number | null createdAt: Date mold: { name: string @@ -68,6 +71,7 @@ interface InventoryListProps { orderBy: Prisma.InventoryOrderByWithRelationInput pageSize: number totalCount: number + useUserFlightNumbers?: boolean } export default function InventoryList({ @@ -79,7 +83,8 @@ export default function InventoryList({ orderBy, pageSize, totalCount, - bagPaths = [] + bagPaths = [], + useUserFlightNumbers = false }: InventoryListProps) { const isInBag = (loc: string | null) => { if (!loc) return false @@ -320,9 +325,9 @@ export default function InventoryList({ {visibleColumns.includes('flight_numbers') && ( <> {item.mold.speed} - {item.mold.glide} - {item.mold.turn} - {item.mold.fade} + {useUserFlightNumbers && item.userGlide !== null ? item.userGlide : item.mold.glide} + {useUserFlightNumbers && item.userTurn !== null ? item.userTurn : item.mold.turn} + {useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade} )} {visibleColumns.includes('plastic') && {item.plastic || "—"}} From b11903e5851eb423b9d1bdcd51f24a5ebb412e86 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:50:52 -0500 Subject: [PATCH 06/12] fix(ui): add visual indicators for custom flight numbers --- src/components/InventoryInfiniteList.tsx | 12 ++++++------ src/components/InventoryList.tsx | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/InventoryInfiniteList.tsx b/src/components/InventoryInfiniteList.tsx index 306d632..283f7cb 100644 --- a/src/components/InventoryInfiniteList.tsx +++ b/src/components/InventoryInfiniteList.tsx @@ -235,14 +235,14 @@ export default function InventoryInfiniteList({
Flight Numbers
{[ - { key: 'speed', label: 'S', val: item.mold.speed }, - { key: 'glide', label: 'G', val: useUserFlightNumbers && item.userGlide !== null ? item.userGlide : item.mold.glide }, - { key: 'turn', label: 'T', val: useUserFlightNumbers && item.userTurn !== null ? item.userTurn : item.mold.turn }, - { key: 'fade', label: 'F', val: useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade } + { key: 'speed', label: 'S', val: item.mold.speed, isCustom: false }, + { key: 'glide', label: 'G', val: useUserFlightNumbers && item.userGlide !== null ? item.userGlide : item.mold.glide, isCustom: useUserFlightNumbers && item.userGlide !== null }, + { key: 'turn', label: 'T', val: useUserFlightNumbers && item.userTurn !== null ? item.userTurn : item.mold.turn, isCustom: useUserFlightNumbers && item.userTurn !== null }, + { key: 'fade', label: 'F', val: useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade, isCustom: useUserFlightNumbers && item.userFade !== null } ].map((stat, idx) => (
- - {stat.val} + + {stat.val}{stat.isCustom && '*'} {stat.key}
diff --git a/src/components/InventoryList.tsx b/src/components/InventoryList.tsx index 6fcf2ac..71f521c 100644 --- a/src/components/InventoryList.tsx +++ b/src/components/InventoryList.tsx @@ -325,9 +325,9 @@ export default function InventoryList({ {visibleColumns.includes('flight_numbers') && ( <> {item.mold.speed} - {useUserFlightNumbers && item.userGlide !== null ? item.userGlide : item.mold.glide} - {useUserFlightNumbers && item.userTurn !== null ? item.userTurn : item.mold.turn} - {useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade} + {useUserFlightNumbers && item.userGlide !== null ? `${item.userGlide}*` : item.mold.glide} + {useUserFlightNumbers && item.userTurn !== null ? `${item.userTurn}*` : item.mold.turn} + {useUserFlightNumbers && item.userFade !== null ? `${item.userFade}*` : item.mold.fade} )} {visibleColumns.includes('plastic') && {item.plastic || "—"}} From 1f950c53467a898bfc664cc82de92414a603d028 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 12:54:21 -0500 Subject: [PATCH 07/12] style: use amber for custom flight numbers to match detail view --- src/components/InventoryInfiniteList.tsx | 2 +- src/components/InventoryList.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/InventoryInfiniteList.tsx b/src/components/InventoryInfiniteList.tsx index 283f7cb..b0a4e16 100644 --- a/src/components/InventoryInfiniteList.tsx +++ b/src/components/InventoryInfiniteList.tsx @@ -241,7 +241,7 @@ export default function InventoryInfiniteList({ { key: 'fade', label: 'F', val: useUserFlightNumbers && item.userFade !== null ? item.userFade : item.mold.fade, isCustom: useUserFlightNumbers && item.userFade !== null } ].map((stat, idx) => (
- + {stat.val}{stat.isCustom && '*'} {stat.key} diff --git a/src/components/InventoryList.tsx b/src/components/InventoryList.tsx index 71f521c..bdb1fb8 100644 --- a/src/components/InventoryList.tsx +++ b/src/components/InventoryList.tsx @@ -325,9 +325,9 @@ export default function InventoryList({ {visibleColumns.includes('flight_numbers') && ( <> {item.mold.speed} - {useUserFlightNumbers && item.userGlide !== null ? `${item.userGlide}*` : item.mold.glide} - {useUserFlightNumbers && item.userTurn !== null ? `${item.userTurn}*` : item.mold.turn} - {useUserFlightNumbers && item.userFade !== null ? `${item.userFade}*` : item.mold.fade} + {useUserFlightNumbers && item.userGlide !== null ? `${item.userGlide}*` : item.mold.glide} + {useUserFlightNumbers && item.userTurn !== null ? `${item.userTurn}*` : item.mold.turn} + {useUserFlightNumbers && item.userFade !== null ? `${item.userFade}*` : item.mold.fade} )} {visibleColumns.includes('plastic') && {item.plastic || "—"}} From 732fb0c98d7b9d7c87ee572c0f21b48e2394415f Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 17:42:05 -0500 Subject: [PATCH 08/12] fix(search): allow string intermediate values in advanced filters to fix NaN bug --- CHANGELOG.md | 1 + src/components/AdvancedSearch.tsx | 32 +++++++++++++++---------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a678287..7cb84f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue with CSV imports where columns without spaces (e.g., `SecondaryColor`) were not being automatically mapped correctly. - Fixed a bug where legitimate `0` flight numbers (e.g., `0` turn, `0` fade) were being ignored and overwritten during CSV imports. - Fixed an issue where the user's sort preference was only saved temporarily in the URL; sort order now correctly persists across sessions and vaults globally via cookies. +- Fixed a bug where entering negative numbers or decimals in the Advanced Search inputs would instantly clear to `NaN`. ## [0.4.3] - 2026-04-25 diff --git a/src/components/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx index f276429..7860a5e 100644 --- a/src/components/AdvancedSearch.tsx +++ b/src/components/AdvancedSearch.tsx @@ -24,18 +24,18 @@ import MultiSelectDropdown from './MultiSelectDropdown' const LocationTreePicker = dynamic(() => import('./LocationTreePicker'), { ssr: false }) export interface AdvancedFilters { - minSpeed?: number - maxSpeed?: number - minGlide?: number - maxGlide?: number - minTurn?: number - maxTurn?: number - minFade?: number - maxFade?: number - minWeight?: number - maxWeight?: number - minCond?: number - maxCond?: number + minSpeed?: number | string + maxSpeed?: number | string + minGlide?: number | string + maxGlide?: number | string + minTurn?: number | string + maxTurn?: number | string + minFade?: number | string + maxFade?: number | string + minWeight?: number | string + maxWeight?: number | string + minCond?: number | string + maxCond?: number | string ink?: string plastic?: string color?: string @@ -137,12 +137,12 @@ export default function AdvancedSearch({
handleUpdate(minKey, e.target.value ? parseFloat(e.target.value) : undefined)} + onChange={(e) => handleUpdate(minKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} /> handleUpdate(maxKey, e.target.value ? parseFloat(e.target.value) : undefined)} + onChange={(e) => handleUpdate(maxKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} />
@@ -163,12 +163,12 @@ export default function AdvancedSearch({
handleUpdate(minKey, e.target.value ? parseFloat(e.target.value) : undefined)} + onChange={(e) => handleUpdate(minKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} /> handleUpdate(maxKey, e.target.value ? parseFloat(e.target.value) : undefined)} + onChange={(e) => handleUpdate(maxKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} />
From a5aa045b49e9e7c2c534d914f6218fa17539a56b Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 17:44:10 -0500 Subject: [PATCH 09/12] feat(ui): add secondary color and pattern to disc detail view --- src/components/DiscDetailView.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/DiscDetailView.tsx b/src/components/DiscDetailView.tsx index 7ef7518..fd48390 100644 --- a/src/components/DiscDetailView.tsx +++ b/src/components/DiscDetailView.tsx @@ -127,7 +127,7 @@ export default function DiscDetailView({ disc, categoryColors, vaultId, bagPaths {disc.plastic || '—'}
- Color + Primary Color
{disc.color && ( {disc.color || '—'}
+
+ Secondary Color + {disc.secondaryColor || '—'} +
+
+ Secondary Pattern + {disc.secondaryPattern || '—'} +
Condition {disc.condition ? `${disc.condition}/10` : '—'}
-
+
Stamp {disc.stamp || 'Stock'}
-
+
Foil {disc.stampFoil || '—'}
-
+
Ink {disc.ink || 'None'}
-
+
Location {disc.location || '—'}
From 560705876de606c745b0c488d40d4c0e3b827527 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 17:50:00 -0500 Subject: [PATCH 10/12] fix(molds): normalizeDatabaseMolds reporting false updates --- CHANGELOG.md | 1 + src/app/actions/inventory.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb84f2..fbc43db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a bug where legitimate `0` flight numbers (e.g., `0` turn, `0` fade) were being ignored and overwritten during CSV imports. - Fixed an issue where the user's sort preference was only saved temporarily in the URL; sort order now correctly persists across sessions and vaults globally via cookies. - Fixed a bug where entering negative numbers or decimals in the Advanced Search inputs would instantly clear to `NaN`. +- Fixed the "Normalize Legacy Data" button incorrectly reporting that it updated discs even when they were already fully normalized. ## [0.4.3] - 2026-04-25 diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index 0192690..3a23dfe 100644 --- a/src/app/actions/inventory.ts +++ b/src/app/actions/inventory.ts @@ -338,13 +338,13 @@ export async function normalizeDatabaseMolds() { let newBrand = mold.brand const normCat = newCategory.toLowerCase().trim() - if (CATEGORY_SYNONYMS[normCat]) { + if (CATEGORY_SYNONYMS[normCat] && CATEGORY_SYNONYMS[normCat] !== mold.category) { newCategory = CATEGORY_SYNONYMS[normCat] changed = true } const normBrand = newBrand.toLowerCase().trim() - if (BRAND_SYNONYMS[normBrand]) { + if (BRAND_SYNONYMS[normBrand] && BRAND_SYNONYMS[normBrand] !== mold.brand) { newBrand = BRAND_SYNONYMS[normBrand] changed = true } From d3f1a3796d444d2f6f9af4c727b2308a1fb2b5c6 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 17:51:58 -0500 Subject: [PATCH 11/12] feat(molds): merge duplicate molds during database normalization --- CHANGELOG.md | 1 + src/app/actions/inventory.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc43db..511ca11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue where the user's sort preference was only saved temporarily in the URL; sort order now correctly persists across sessions and vaults globally via cookies. - Fixed a bug where entering negative numbers or decimals in the Advanced Search inputs would instantly clear to `NaN`. - Fixed the "Normalize Legacy Data" button incorrectly reporting that it updated discs even when they were already fully normalized. +- Enhanced the "Normalize Legacy Data" function to detect and merge duplicate molds (e.g. from case-sensitivity mismatches during CSV imports) so they no longer appear multiple times in the Add Disc dropdown. ## [0.4.3] - 2026-04-25 diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index 3a23dfe..bfe2aa6 100644 --- a/src/app/actions/inventory.ts +++ b/src/app/actions/inventory.ts @@ -358,6 +358,37 @@ export async function normalizeDatabaseMolds() { } } + // 2. Merge duplicate molds + const allMolds = await prisma.mold.findMany() + const grouped = new Map() + + for (const mold of allMolds) { + const key = `${mold.brand.toLowerCase().trim()}:::${mold.name.toLowerCase().trim()}` + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key)!.push(mold) + } + + for (const group of grouped.values()) { + if (group.length > 1) { + // Pick canonical mold (prefer isCustom = false, otherwise first one) + const canonical = group.find(m => !m.isCustom) || group[0] + const duplicates = group.filter(m => m.id !== canonical.id) + + for (const dup of duplicates) { + // Move inventory + await prisma.inventory.updateMany({ + where: { moldId: dup.id }, + data: { moldId: canonical.id } + }) + // Delete duplicate mold + await prisma.mold.delete({ + where: { id: dup.id } + }) + updatedCount++ + } + } + } + revalidatePath('/settings') revalidatePath('/') revalidatePath('/v/all') From 454b4cee0dfd3f4d9bc287723b262a55e0aa6935 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Sat, 25 Apr 2026 17:54:09 -0500 Subject: [PATCH 12/12] docs: add changelog entry for secondary color and pattern --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 511ca11..fc5389c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Bag Check Feature:** A new tool to help you make sure you haven't left any discs behind after practice or a round. Accessed from the vault header, it lets you select one of your configured bags and gives you an interactive checklist of all discs in that bag. Discs are grouped by their sub-location (e.g., 'Putter Pocket', 'Main Compartment') and sorted by speed (highest first) then net stability (most overstable first). - **Custom Flight Numbers Toggle:** You can now toggle "Use Custom Flight #s" from the Columns dropdown on the inventory dashboards. When enabled, this will replace the stock mold flight numbers with your custom flight numbers (if defined) for that specific disc. +- **Secondary Physical Properties:** Added `Secondary Color` and `Secondary Pattern` text displays to the physical properties grid in the Disc Detail view, bringing it to full feature parity with recent schema additions. ### Fixed - Fixed an issue with CSV imports where columns without spaces (e.g., `SecondaryColor`) were not being automatically mapped correctly.