Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "discvault",
"version": "0.4.3",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
Expand Down
58 changes: 48 additions & 10 deletions src/app/actions/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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++
Expand Down Expand Up @@ -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
}
Expand All @@ -351,6 +358,37 @@ export async function normalizeDatabaseMolds() {
}
}

// 2. Merge duplicate molds
const allMolds = await prisma.mold.findMany()
const grouped = new Map<string, typeof allMolds>()

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')
Expand Down
92 changes: 92 additions & 0 deletions src/app/v/[vaultId]/bagcheck/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-2xl w-full mx-auto">
<BagCheckList
vaultName={vault.name}
bags={bagLocations.map(l => ({ 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,
}))}
/>
</div>
)
}
3 changes: 2 additions & 1 deletion src/app/v/[vaultId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 },
]
Expand Down
16 changes: 13 additions & 3 deletions src/app/v/[vaultId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,24 @@ 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
const visibleCols = colsParam.includes('name') ? colsParam : ['name', ...colsParam]
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
Expand Down Expand Up @@ -252,6 +259,7 @@ export default async function VaultDashboard({
sortBy={sortBy}
sortOrder={sortOrder}
visibleColumns={visibleCols}
useUserFlightNumbers={useUserFlightNumbers}
/>
</div>

Expand Down Expand Up @@ -298,6 +306,7 @@ export default async function VaultDashboard({
visibleColumns={visibleCols}
categoryColors={categoryColors}
bagPaths={bagPaths}
useUserFlightNumbers={useUserFlightNumbers}
/>
</div>
) : (
Expand All @@ -312,6 +321,7 @@ export default async function VaultDashboard({
pageSize={pageSize}
totalCount={totalCount}
bagPaths={bagPaths}
useUserFlightNumbers={useUserFlightNumbers}
/>
</div>
)}
Expand Down
16 changes: 13 additions & 3 deletions src/app/v/all/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -257,6 +264,7 @@ export default async function AllVaultsDashboard({
sortBy={sortBy}
sortOrder={sortOrder}
visibleColumns={visibleCols}
useUserFlightNumbers={useUserFlightNumbers}
/>

{totalCount === 0 ? (
Expand Down Expand Up @@ -291,6 +299,7 @@ export default async function AllVaultsDashboard({
totalCount={totalCount}
visibleColumns={visibleCols}
bagPaths={bagPaths}
useUserFlightNumbers={useUserFlightNumbers}
/>
) : (
<InventoryList
Expand All @@ -303,6 +312,7 @@ export default async function AllVaultsDashboard({
pageSize={pageSize}
totalCount={totalCount}
bagPaths={bagPaths}
useUserFlightNumbers={useUserFlightNumbers}
/>
)}
</div>
Expand Down
Loading
Loading