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:
@@ -9,19 +9,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwindcss": "^4.2.0"
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7"
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,33 +7,8 @@ interface AdditivePickerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Deutsche Namen für häufige Zusatzstoffe (E-Nummern)
|
||||
const ADDITIVE_NAMES: Record<string, string> = {
|
||||
'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'
|
||||
};
|
||||
// Die Zusatzstoffe kommen direkt aus der DB mit id + name,
|
||||
// daher wird hier kein separates Mapping benötigt.
|
||||
|
||||
export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) {
|
||||
const handleToggle = (additiveId: string) => {
|
||||
@@ -68,7 +43,7 @@ export function AdditivePicker({ additives, selectedIds, onChange, className = '
|
||||
>
|
||||
{sortedAdditives.map(additive => {
|
||||
const isSelected = selectedIds.includes(additive.id);
|
||||
const displayName = ADDITIVE_NAMES[additive.id] || additive.name;
|
||||
const displayName = additive.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
|
||||
@@ -65,7 +65,7 @@ export function AllergenPicker({ allergens, selectedIds, onChange, className = '
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(allergen.id)}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
aria-describedby={`allergen-${allergen.id}-description`}
|
||||
/>
|
||||
|
||||
|
||||
@@ -82,8 +82,24 @@ export function ProductForm({
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ProductSearch({
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter products based on search query
|
||||
const filteredProducts = query.trim()
|
||||
@@ -95,7 +96,7 @@ export function ProductSearch({
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
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);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export function ProductSearch({
|
||||
const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim()));
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} role="combobox" aria-expanded={isOpen}>
|
||||
<div ref={containerRef} className={`relative ${className}`} role="combobox" aria-expanded={isOpen}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
||||
@@ -204,7 +204,7 @@ export function Sidebar({
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`
|
||||
}
|
||||
aria-current={({ isActive }) => isActive ? 'page' : undefined}
|
||||
end={item.to === '/'}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="ml-3">{item.name}</span>
|
||||
@@ -257,7 +257,13 @@ function CopyWeekDialog({ targetYear, targetWeek, onCopy, onCancel }: CopyWeekDi
|
||||
};
|
||||
|
||||
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">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -35,7 +35,13 @@ export function SpecialDayDialog({
|
||||
const isEditing = !!existingSpecialDay;
|
||||
|
||||
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">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={className} aria-live="polite">
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
|
||||
|
||||
@@ -124,22 +124,22 @@ export function useProducts() {
|
||||
const searchTerm = query.toLowerCase();
|
||||
return products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.allergens.some(a => a.name.toLowerCase().includes(searchTerm)) ||
|
||||
product.additives.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))
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Allergenen filtern
|
||||
const filterByAllergen = (allergenId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.allergens.some(a => a.id === allergenId)
|
||||
(product.allergens || []).some(a => a.id === allergenId)
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Zusatzstoffen filtern
|
||||
const filterByAdditive = (additiveId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.additives.some(a => a.id === additiveId)
|
||||
(product.additives || []).some(a => a.id === additiveId)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -109,15 +109,12 @@ export function useWeekPlan(year: number, week: number) {
|
||||
// Eintrag bearbeiten
|
||||
const updateEntry = async (
|
||||
entryId: number,
|
||||
day: WeekDay,
|
||||
meal: MealType,
|
||||
slot: number,
|
||||
productId?: number,
|
||||
customText?: string,
|
||||
groupLabel?: GroupLabel
|
||||
): Promise<PlanEntry | null> => {
|
||||
try {
|
||||
const updatedEntry = await UpdatePlanEntry(entryId, day, meal, slot, productId, customText, groupLabel);
|
||||
const updatedEntry = await UpdatePlanEntry(entryId, productId, customText, groupLabel);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
|
||||
@@ -18,18 +18,22 @@ export function getWeekFromDate(date: Date): { year: number; week: number } {
|
||||
const tempDate = new Date(date.valueOf());
|
||||
const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0
|
||||
|
||||
// Zum Donnerstag der gleichen Woche gehen (ISO 8601)
|
||||
tempDate.setDate(tempDate.getDate() - dayNum + 3);
|
||||
const firstThursday = tempDate.valueOf();
|
||||
tempDate.setMonth(0, 1);
|
||||
// Das ISO-Jahr ist das Jahr des Donnerstags
|
||||
const isoYear = tempDate.getFullYear();
|
||||
|
||||
if (tempDate.getDay() !== 4) {
|
||||
tempDate.setMonth(0, 1 + ((4 - tempDate.getDay()) + 7) % 7);
|
||||
// Ersten Donnerstag des ISO-Jahres finden
|
||||
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 {
|
||||
year: tempDate.getFullYear(),
|
||||
year: isoYear,
|
||||
week: week
|
||||
};
|
||||
}
|
||||
@@ -38,14 +42,13 @@ export function getWeekFromDate(date: Date): { year: number; week: number } {
|
||||
* Berechnet das erste Datum einer Kalenderwoche
|
||||
*/
|
||||
export function getDateFromWeek(year: number, week: number): Date {
|
||||
const date = new Date(year, 0, 1);
|
||||
const dayOfWeek = date.getDay();
|
||||
const daysToMonday = dayOfWeek <= 4 ? dayOfWeek - 1 : dayOfWeek - 8;
|
||||
// Find Jan 4 (always in ISO week 1) then find its Monday
|
||||
const jan4 = new Date(year, 0, 4);
|
||||
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);
|
||||
date.setDate(date.getDate() + (week - 1) * 7);
|
||||
|
||||
return date;
|
||||
return monday;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,9 +106,10 @@ export function getPrevWeek(year: number, week: number): { year: number; week: n
|
||||
* Berechnet die Anzahl Kalenderwochen in einem Jahr
|
||||
*/
|
||||
export function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31);
|
||||
const week = getWeekFromDate(dec31);
|
||||
return week.year === year ? week.week : week.week - 1;
|
||||
// Dec 28 is always in the last ISO week of its year
|
||||
const dec28 = new Date(year, 11, 28);
|
||||
const week = getWeekFromDate(dec28);
|
||||
return week.week;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user