v0.2.1 — Code Review Fixes

Fixes:
- CRITICAL: compareVersions used string comparison, fails for 1.10 vs 1.9 (now numeric)
- CRITICAL: Double CloseDatabase() in main.go (defer + OnShutdown)
- CRITICAL: Tailwind v4 in package.json but v3 config/syntax (downgraded to v3)
- CRITICAL: react-router-dom v7 with v5 types (switched to v6, removed deprecated types)
- IMPORTANT: UpdatePlanEntry hook signature mismatch (7 args vs Go's 4)
- IMPORTANT: AllergenPicker hidden checkbox inaccessible to screenreaders (sr-only)
- IMPORTANT: weekHelper getWeekFromDate returned wrong ISO year for edge cases
- IMPORTANT: getWeeksInYear bug for years where Dec 31 is in week 1 of next year
- IMPORTANT: getDateFromWeek off-by-one for some years (use Jan 4 anchor)
- IMPORTANT: ProductSearch click-outside missed dropdown (use container ref)
- IMPORTANT: seed.go LastInsertId=0 on INSERT OR IGNORE skip
- IMPORTANT: SQLite missing PRAGMA foreign_keys=ON and WAL mode
- IMPORTANT: AdditivePicker ADDITIVE_NAMES used numeric IDs but data uses letters
- IMPORTANT: Missing role=dialog/aria-modal on all modal dialogs
- IMPORTANT: Missing Escape key handler on ProductForm modal
- IMPORTANT: Sidebar NavLink aria-current used function instead of string
- IMPORTANT: useProducts searchProducts null safety for allergens/additives
- NICE-TO-HAVE: Added aria-live=polite to WeekPlanner for dynamic updates
- NICE-TO-HAVE: Added postcss.config.js for Tailwind v3
- NICE-TO-HAVE: Updated model comments to match actual day/meal conventions
- NICE-TO-HAVE: Modernized vite/typescript/plugin versions
This commit is contained in:
clawd
2026-02-20 10:11:54 +00:00
parent e146442513
commit df9e7c5541
17 changed files with 98 additions and 84 deletions

4
db.go
View File

@@ -29,6 +29,10 @@ func InitDatabase() error {
db = database db = database
// SQLite Pragmas setzen
db.MustExec("PRAGMA journal_mode=WAL")
db.MustExec("PRAGMA foreign_keys=ON")
// Schema erstellen // Schema erstellen
if err := createSchema(); err != nil { if err := createSchema(); err != nil {
return fmt.Errorf("failed to create schema: %w", err) return fmt.Errorf("failed to create schema: %w", err)

View File

@@ -9,19 +9,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@types/react-router-dom": "^5.3.3",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^6.26.0"
"tailwindcss": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1", "@vitejs/plugin-react": "^4.3.0",
"typescript": "^4.6.4", "autoprefixer": "^10.4.24",
"vite": "^3.0.7" "postcss": "^8.5.6",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
} }
} }

View File

@@ -7,33 +7,8 @@ interface AdditivePickerProps {
className?: string; className?: string;
} }
// Deutsche Namen für häufige Zusatzstoffe (E-Nummern) // Die Zusatzstoffe kommen direkt aus der DB mit id + name,
const ADDITIVE_NAMES: Record<string, string> = { // daher wird hier kein separates Mapping benötigt.
'1': 'Farbstoff',
'2': 'Konservierungsstoff',
'3': 'Antioxidationsmittel',
'4': 'Geschmacksverstärker',
'5': 'Geschwefelt',
'6': 'Geschwärzt',
'7': 'Gewachst',
'8': 'Phosphat',
'9': 'Süßungsmittel',
'10': 'Phenylalaninquelle',
'11': 'Koffeinhaltig',
'12': 'Chininhaltig',
'13': 'Alkoholhaltig',
'14': 'Nitritpökelsalz',
'15': 'Milchsäure',
'16': 'Citronensäure',
'17': 'Ascorbinsäure',
'18': 'Tocopherol',
'19': 'Lecithin',
'20': 'Johannisbrotkernmehl',
'21': 'Guarkernmehl',
'22': 'Xanthan',
'23': 'Carrageen',
'24': 'Agar'
};
export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) { export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) {
const handleToggle = (additiveId: string) => { const handleToggle = (additiveId: string) => {
@@ -68,7 +43,7 @@ export function AdditivePicker({ additives, selectedIds, onChange, className = '
> >
{sortedAdditives.map(additive => { {sortedAdditives.map(additive => {
const isSelected = selectedIds.includes(additive.id); const isSelected = selectedIds.includes(additive.id);
const displayName = ADDITIVE_NAMES[additive.id] || additive.name; const displayName = additive.name;
return ( return (
<label <label

View File

@@ -65,7 +65,7 @@ export function AllergenPicker({ allergens, selectedIds, onChange, className = '
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => handleToggle(allergen.id)} onChange={() => handleToggle(allergen.id)}
className="hidden" className="sr-only"
aria-describedby={`allergen-${allergen.id}-description`} aria-describedby={`allergen-${allergen.id}-description`}
/> />

View File

@@ -82,8 +82,24 @@ export function ProductForm({
const isEditing = !!product; const isEditing = !!product;
// Escape-Taste schließt den Dialog
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-label={isEditing ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Header */} {/* Header */}

View File

@@ -24,6 +24,7 @@ export function ProductSearch({
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null); const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Filter products based on search query // Filter products based on search query
const filteredProducts = query.trim() const filteredProducts = query.trim()
@@ -95,7 +96,7 @@ export function ProductSearch({
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false); setIsOpen(false);
setSelectedIndex(-1); setSelectedIndex(-1);
} }
@@ -116,7 +117,7 @@ export function ProductSearch({
const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim())); const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim()));
return ( return (
<div className={`relative ${className}`} role="combobox" aria-expanded={isOpen}> <div ref={containerRef} className={`relative ${className}`} role="combobox" aria-expanded={isOpen}>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"

View File

@@ -204,7 +204,7 @@ export function Sidebar({
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}` }`
} }
aria-current={({ isActive }) => isActive ? 'page' : undefined} end={item.to === '/'}
> >
{item.icon} {item.icon}
<span className="ml-3">{item.name}</span> <span className="ml-3">{item.name}</span>
@@ -257,7 +257,13 @@ function CopyWeekDialog({ targetYear, targetWeek, onCopy, onCancel }: CopyWeekDi
}; };
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-label="Kalenderwoche kopieren"
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="p-6"> <div className="p-6">

View File

@@ -35,7 +35,13 @@ export function SpecialDayDialog({
const isEditing = !!existingSpecialDay; const isEditing = !!existingSpecialDay;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-label={`Sondertag für ${DAY_NAMES[day]}`}
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Header */} {/* Header */}

View File

@@ -136,7 +136,7 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
} }
return ( return (
<div className={className}> <div className={className} aria-live="polite">
{/* Error Banner */} {/* Error Banner */}
{error && ( {error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">

View File

@@ -124,22 +124,22 @@ export function useProducts() {
const searchTerm = query.toLowerCase(); const searchTerm = query.toLowerCase();
return products.filter(product => return products.filter(product =>
product.name.toLowerCase().includes(searchTerm) || product.name.toLowerCase().includes(searchTerm) ||
product.allergens.some(a => a.name.toLowerCase().includes(searchTerm)) || (product.allergens || []).some(a => a.name.toLowerCase().includes(searchTerm)) ||
product.additives.some(a => a.name.toLowerCase().includes(searchTerm)) (product.additives || []).some(a => a.name.toLowerCase().includes(searchTerm))
); );
}; };
// Produkte nach Allergenen filtern // Produkte nach Allergenen filtern
const filterByAllergen = (allergenId: string): Product[] => { const filterByAllergen = (allergenId: string): Product[] => {
return products.filter(product => return products.filter(product =>
product.allergens.some(a => a.id === allergenId) (product.allergens || []).some(a => a.id === allergenId)
); );
}; };
// Produkte nach Zusatzstoffen filtern // Produkte nach Zusatzstoffen filtern
const filterByAdditive = (additiveId: string): Product[] => { const filterByAdditive = (additiveId: string): Product[] => {
return products.filter(product => return products.filter(product =>
product.additives.some(a => a.id === additiveId) (product.additives || []).some(a => a.id === additiveId)
); );
}; };

View File

@@ -109,15 +109,12 @@ export function useWeekPlan(year: number, week: number) {
// Eintrag bearbeiten // Eintrag bearbeiten
const updateEntry = async ( const updateEntry = async (
entryId: number, entryId: number,
day: WeekDay,
meal: MealType,
slot: number,
productId?: number, productId?: number,
customText?: string, customText?: string,
groupLabel?: GroupLabel groupLabel?: GroupLabel
): Promise<PlanEntry | null> => { ): Promise<PlanEntry | null> => {
try { try {
const updatedEntry = await UpdatePlanEntry(entryId, day, meal, slot, productId, customText, groupLabel); const updatedEntry = await UpdatePlanEntry(entryId, productId, customText, groupLabel);
// State aktualisieren // State aktualisieren
setWeekPlan(prev => prev ? { setWeekPlan(prev => prev ? {

View File

@@ -18,18 +18,22 @@ export function getWeekFromDate(date: Date): { year: number; week: number } {
const tempDate = new Date(date.valueOf()); const tempDate = new Date(date.valueOf());
const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0 const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0
// Zum Donnerstag der gleichen Woche gehen (ISO 8601)
tempDate.setDate(tempDate.getDate() - dayNum + 3); tempDate.setDate(tempDate.getDate() - dayNum + 3);
const firstThursday = tempDate.valueOf(); const firstThursday = tempDate.valueOf();
tempDate.setMonth(0, 1); // Das ISO-Jahr ist das Jahr des Donnerstags
const isoYear = tempDate.getFullYear();
if (tempDate.getDay() !== 4) { // Ersten Donnerstag des ISO-Jahres finden
tempDate.setMonth(0, 1 + ((4 - tempDate.getDay()) + 7) % 7); const jan1 = new Date(isoYear, 0, 1);
if (jan1.getDay() !== 4) {
jan1.setMonth(0, 1 + ((4 - jan1.getDay()) + 7) % 7);
} }
const week = 1 + Math.ceil((firstThursday - tempDate.valueOf()) / 604800000); // 7 * 24 * 3600 * 1000 const week = 1 + Math.ceil((firstThursday - jan1.valueOf()) / 604800000); // 7 * 24 * 3600 * 1000
return { return {
year: tempDate.getFullYear(), year: isoYear,
week: week week: week
}; };
} }
@@ -38,14 +42,13 @@ export function getWeekFromDate(date: Date): { year: number; week: number } {
* Berechnet das erste Datum einer Kalenderwoche * Berechnet das erste Datum einer Kalenderwoche
*/ */
export function getDateFromWeek(year: number, week: number): Date { export function getDateFromWeek(year: number, week: number): Date {
const date = new Date(year, 0, 1); // Find Jan 4 (always in ISO week 1) then find its Monday
const dayOfWeek = date.getDay(); const jan4 = new Date(year, 0, 4);
const daysToMonday = dayOfWeek <= 4 ? dayOfWeek - 1 : dayOfWeek - 8; const dayOfWeek = (jan4.getDay() + 6) % 7; // Monday = 0
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - dayOfWeek + (week - 1) * 7);
date.setDate(date.getDate() - daysToMonday); return monday;
date.setDate(date.getDate() + (week - 1) * 7);
return date;
} }
/** /**
@@ -103,9 +106,10 @@ export function getPrevWeek(year: number, week: number): { year: number; week: n
* Berechnet die Anzahl Kalenderwochen in einem Jahr * Berechnet die Anzahl Kalenderwochen in einem Jahr
*/ */
export function getWeeksInYear(year: number): number { export function getWeeksInYear(year: number): number {
const dec31 = new Date(year, 11, 31); // Dec 28 is always in the last ISO week of its year
const week = getWeekFromDate(dec31); const dec28 = new Date(year, 11, 28);
return week.year === year ? week.week : week.week - 1; const week = getWeekFromDate(dec28);
return week.week;
} }
/** /**

View File

@@ -19,13 +19,6 @@ func main() {
log.Fatalf("Failed to initialize database: %v", err) log.Fatalf("Failed to initialize database: %v", err)
} }
// Sicherstellen, dass die Datenbank ordnungsgemäß geschlossen wird
defer func() {
if err := CloseDatabase(); err != nil {
log.Printf("Error closing database: %v", err)
}
}()
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()

View File

@@ -40,8 +40,8 @@ type WeekPlan struct {
type PlanEntry struct { type PlanEntry struct {
ID int `json:"id" db:"id"` ID int `json:"id" db:"id"`
WeekPlanID int `json:"week_plan_id" db:"week_plan_id"` WeekPlanID int `json:"week_plan_id" db:"week_plan_id"`
Day int `json:"day" db:"day"` // 0=Mo, 1=Di, 2=Mi, 3=Do, 4=Fr Day int `json:"day" db:"day"` // 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr
Meal string `json:"meal" db:"meal"` // 'breakfast' oder 'snack' Meal string `json:"meal" db:"meal"` // 'fruehstueck' oder 'vesper'
Slot int `json:"slot" db:"slot"` // Reihenfolge innerhalb des Tages Slot int `json:"slot" db:"slot"` // Reihenfolge innerhalb des Tages
ProductID *int `json:"product_id" db:"product_id"` ProductID *int `json:"product_id" db:"product_id"`
Product *Product `json:"product,omitempty"` Product *Product `json:"product,omitempty"`
@@ -53,8 +53,8 @@ type PlanEntry struct {
type SpecialDay struct { type SpecialDay struct {
ID int `json:"id" db:"id"` ID int `json:"id" db:"id"`
WeekPlanID int `json:"week_plan_id" db:"week_plan_id"` WeekPlanID int `json:"week_plan_id" db:"week_plan_id"`
Day int `json:"day" db:"day"` // 0=Mo, 1=Di, ... Day int `json:"day" db:"day"` // 1=Mo, 2=Di, ...
Type string `json:"type" db:"type"` // 'holiday' oder 'closed' Type string `json:"type" db:"type"` // 'feiertag' oder 'schliesstag'
Label *string `json:"label" db:"label"` // z.B. "Neujahr", "Teamtag" Label *string `json:"label" db:"label"` // z.B. "Neujahr", "Teamtag"
} }

View File

@@ -145,7 +145,8 @@ func seedProducts() error {
// Produkt-ID ermitteln // Produkt-ID ermitteln
var productID int64 var productID int64
if productID, err = result.LastInsertId(); err != nil { productID, err = result.LastInsertId()
if err != nil || productID == 0 {
// Wenn INSERT OR IGNORE nichts eingefügt hat, ID über SELECT holen // Wenn INSERT OR IGNORE nichts eingefügt hat, ID über SELECT holen
err = tx.Get(&productID, "SELECT id FROM products WHERE name = ?", product.Name) err = tx.Get(&productID, "SELECT id FROM products WHERE name = ?", product.Name)
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -79,7 +80,7 @@ func (u *Updater) DownloadUpdate(downloadURL string) error {
// compareVersions vergleicht zwei Versionsstrings // compareVersions vergleicht zwei Versionsstrings
// Gibt -1 zurück wenn v1 < v2, 0 wenn v1 == v2, 1 wenn v1 > v2 // Gibt -1 zurück wenn v1 < v2, 0 wenn v1 == v2, 1 wenn v1 > v2
func compareVersions(v1, v2 string) int { func compareVersions(v1, v2 string) int {
// Vereinfachter Versionsvergleich (nur für grundlegende Semantic Versioning) // Semantic Versioning Vergleich mit numerischem Parsing
v1Parts := strings.Split(strings.TrimPrefix(v1, "v"), ".") v1Parts := strings.Split(strings.TrimPrefix(v1, "v"), ".")
v2Parts := strings.Split(strings.TrimPrefix(v2, "v"), ".") v2Parts := strings.Split(strings.TrimPrefix(v2, "v"), ".")
@@ -92,13 +93,24 @@ func compareVersions(v1, v2 string) int {
} }
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
// Einfacher String-Vergleich (funktioniert für einstellige Zahlen) n1, err1 := strconv.Atoi(v1Parts[i])
n2, err2 := strconv.Atoi(v2Parts[i])
if err1 != nil || err2 != nil {
// Fallback auf String-Vergleich
if v1Parts[i] < v2Parts[i] { if v1Parts[i] < v2Parts[i] {
return -1 return -1
} }
if v1Parts[i] > v2Parts[i] { if v1Parts[i] > v2Parts[i] {
return 1 return 1
} }
continue
}
if n1 < n2 {
return -1
}
if n1 > n2 {
return 1
}
} }
return 0 return 0