Initial commit: Fahrrad Verschleißteile Tracker

- Next.js SPA mit Bun Runtime
- Prisma mit SQLite Datenbank
- Vollständige CRUD-Operationen für Fahrräder, Verschleißteile und Wartungshistorie
- Warnsystem für bevorstehende Wartungen
- Statistik-Features (Gesamtkosten, durchschnittliche Lebensdauer)
- Zod-Validierung für alle API-Requests
- Umfassende Test-Suite (41 Tests)
This commit is contained in:
Denis Urs Rudolph
2025-12-05 22:17:50 +01:00
commit de193bc783
39 changed files with 10541 additions and 0 deletions

10
lib/prisma.ts Normal file
View File

@@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

85
lib/utils.ts Normal file
View File

@@ -0,0 +1,85 @@
import { WearPart, MaintenanceHistory } from '@prisma/client'
export function calculateServiceStatus(
part: WearPart & { maintenanceHistory: MaintenanceHistory[] }
): {
status: 'OK' | 'WARNING' | 'CRITICAL'
remainingKm: number
percentageUsed: number
} {
const latestMaintenance = part.maintenanceHistory[0]
const currentMileage = latestMaintenance?.mileage ?? part.installMileage
const kmSinceInstall = currentMileage - part.installMileage
const remainingKm = part.serviceInterval - kmSinceInstall
const percentageUsed = (kmSinceInstall / part.serviceInterval) * 100
let status: 'OK' | 'WARNING' | 'CRITICAL' = 'OK'
if (percentageUsed >= 90) {
status = 'CRITICAL'
} else if (percentageUsed >= 75) {
status = 'WARNING'
}
return {
status,
remainingKm: Math.max(0, remainingKm),
percentageUsed: Math.min(100, percentageUsed),
}
}
export function calculateTotalCosts(
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
): number {
return parts.reduce((total, part) => {
const partCost = part.cost ?? 0
const maintenanceCost = part.maintenanceHistory.reduce(
(sum, m) => sum + (m.cost ?? 0),
0
)
return total + partCost + maintenanceCost
}, 0)
}
export function calculateAverageLifespan(
parts: (WearPart & { maintenanceHistory: MaintenanceHistory[] })[]
): number | null {
const replacedParts = parts.filter(
(p) => p.status === 'REPLACED' && p.maintenanceHistory.length > 0
)
if (replacedParts.length === 0) return null
const totalKm = replacedParts.reduce((sum, part) => {
const installHistory = part.maintenanceHistory.find(
(h) => h.action === 'INSTALL'
)
const replaceHistory = part.maintenanceHistory.find(
(h) => h.action === 'REPLACE'
)
if (installHistory && replaceHistory) {
return sum + (replaceHistory.mileage - installHistory.mileage)
}
return sum
}, 0)
return totalKm / replacedParts.length
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
export function formatCurrency(amount: number | null | undefined): string {
if (amount === null || amount === undefined) return '0,00 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount)
}

71
lib/validations.ts Normal file
View File

@@ -0,0 +1,71 @@
import { z } from 'zod'
export const WearPartType = z.enum([
'CHAIN',
'BRAKE_PADS',
'TIRE',
'CASSETTE',
'CHAINRING',
'DERAILLEUR',
'BRAKE_CABLE',
'SHIFT_CABLE',
'BRAKE_ROTOR',
'PEDAL',
'CRANKSET',
'BOTTOM_BRACKET',
'HEADSET',
'WHEEL',
'HUB',
'SPOKE',
'OTHER',
])
export const WearPartStatus = z.enum([
'ACTIVE',
'NEEDS_SERVICE',
'REPLACED',
'INACTIVE',
])
export const MaintenanceAction = z.enum([
'INSTALL',
'REPLACE',
'SERVICE',
'CHECK',
'ADJUST',
])
export const bikeSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich'),
brand: z.string().optional(),
model: z.string().optional(),
purchaseDate: z.string().datetime().optional().or(z.date().optional()),
notes: z.string().optional(),
})
export const wearPartSchema = z.object({
bikeId: z.string().min(1, 'Fahrrad-ID ist erforderlich'),
type: WearPartType,
brand: z.string().optional(),
model: z.string().optional(),
installDate: z.string().datetime().or(z.date()),
installMileage: z.number().int().min(0).default(0),
serviceInterval: z.number().int().min(1, 'Service-Intervall muss mindestens 1 km sein'),
status: WearPartStatus.default('ACTIVE'),
cost: z.number().positive().optional(),
notes: z.string().optional(),
})
export const maintenanceHistorySchema = z.object({
wearPartId: z.string().min(1, 'Verschleißteil-ID ist erforderlich'),
date: z.string().datetime().or(z.date()),
mileage: z.number().int().min(0),
action: MaintenanceAction,
notes: z.string().optional(),
cost: z.number().positive().optional(),
})
export type BikeInput = z.infer<typeof bikeSchema>
export type WearPartInput = z.infer<typeof wearPartSchema>
export type MaintenanceHistoryInput = z.infer<typeof maintenanceHistorySchema>