diff --git a/CHANGELOG.md b/CHANGELOG.md index 2478a42..fc5389c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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). +- **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. +- 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. +- 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 ### 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", diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index 4c8b4ad..bfe2aa6 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++ @@ -331,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 } @@ -351,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') 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/app/v/[vaultId]/page.tsx b/src/app/v/[vaultId]/page.tsx index ad69d16..625c8b2 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 @@ -58,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 @@ -252,6 +259,7 @@ export default async function VaultDashboard({ sortBy={sortBy} sortOrder={sortOrder} visibleColumns={visibleCols} + useUserFlightNumbers={useUserFlightNumbers} /> @@ -298,6 +306,7 @@ export default async function VaultDashboard({ visibleColumns={visibleCols} categoryColors={categoryColors} bagPaths={bagPaths} + useUserFlightNumbers={useUserFlightNumbers} /> ) : ( @@ -312,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 bc1aaa8..0809a98 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 @@ -53,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 @@ -257,6 +264,7 @@ export default async function AllVaultsDashboard({ sortBy={sortBy} sortOrder={sortOrder} visibleColumns={visibleCols} + useUserFlightNumbers={useUserFlightNumbers} /> {totalCount === 0 ? ( @@ -291,6 +299,7 @@ export default async function AllVaultsDashboard({ totalCount={totalCount} visibleColumns={visibleCols} bagPaths={bagPaths} + useUserFlightNumbers={useUserFlightNumbers} /> ) : ( )} 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} />
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(' · ')} +
+
+
+ ) + })} +
+
+ ) + })} +
+ ) +} 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] } diff --git a/src/components/DashboardToolbar.tsx b/src/components/DashboardToolbar.tsx index ca349c1..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]) @@ -438,9 +449,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) }} @@ -495,6 +515,22 @@ export default function DashboardToolbar({ ) })} +
+ +
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 || '—'}
diff --git a/src/components/InventoryInfiniteList.tsx b/src/components/InventoryInfiniteList.tsx index bc83e31..b0a4e16 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 @@ -230,14 +235,14 @@ export default function InventoryInfiniteList({
Flight Numbers
{[ - { 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: '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 73d9f72..bdb1fb8 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 || "—"}}