diff --git a/deployment_guide.md b/deployment_guide.md index 47ad0e5..5d9fdb1 100644 --- a/deployment_guide.md +++ b/deployment_guide.md @@ -142,3 +142,34 @@ Point your domain (e.g., `gym.yourdomain.com`) to the NAS IP and the mapped port - Forward Hostname / IP: `[NAS_IP]` - Forward Port: `3033` - Websockets Support: Enable (if needed for future features). + +## 6. Troubleshooting + +### "Readonly Database" Error +If you see an error like `Invalid prisma.userProfile.upsert() invocation: attempt to write a readonly database`: + +1. **Verify Permissions:** Run the diagnostic script inside your container: + ```bash + docker exec -it node-apps node /usr/src/app/gymflow/server/check_db_perms.js + ``` +2. **Fix Permissions:** If the checks fail, run these commands on your NAS inside the `gymflow/server` directory: + ```bash + sudo chmod 777 . + sudo chmod 666 prod.db + ``` + *Note: SQLite needs write access to the directory itself to create temporary journaling files (`-wal`, `-shm`).* + +3. **Check Docker User:** Alternatively, ensure your Docker container is running as a user who owns these files (e.g., set `user: "1000:1000"` in `docker-compose.yml` if your NAS user has that ID). + +### "Invalid ELF Header" Error +If you see an error like `invalid ELF header` for `better-sqlite3.node`: +This happens because the `node_modules` contains Windows binaries (from your local machine) instead of Linux binaries. + +1. **Fix Inside Container:** Run the following command to force a rebuild of native modules for Linux: + ```bash + docker exec -it node-apps /bin/sh -c "cd /usr/src/app/gymflow/server && npm rebuild better-sqlite3" + ``` +2. **Restart Container:** After rebuilding, restart the container: + ```bash + docker-compose restart nodejs-apps + ``` diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/server/check_adapter_props.js b/server/check_adapter_props.js new file mode 100644 index 0000000..fd4b7c6 --- /dev/null +++ b/server/check_adapter_props.js @@ -0,0 +1,29 @@ +const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3'); +const path = require('path'); + +async function check() { + console.log('--- Prisma Adapter Diagnostic ---'); + const factory = new PrismaBetterSqlite3({ url: 'file:./dev.db' }); + + console.log('Factory Properties:'); + console.log(Object.keys(factory)); + console.log('Factory.adapterName:', factory.adapterName); + console.log('Factory.provider:', factory.provider); + + try { + const adapter = await factory.connect(); + console.log('\nAdapter Properties:'); + console.log(Object.keys(adapter)); + console.log('Adapter name:', adapter.adapterName); + console.log('Adapter provider:', adapter.provider); + + // Also check if there are hidden/prototype properties + let proto = Object.getPrototypeOf(adapter); + console.log('Adapter Prototype Properties:', Object.getOwnPropertyNames(proto)); + + } catch (e) { + console.error('Failed to connect:', e); + } +} + +check(); diff --git a/server/check_db_perms.js b/server/check_db_perms.js new file mode 100644 index 0000000..7e247db --- /dev/null +++ b/server/check_db_perms.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); + +const dbPath = path.resolve(__dirname, 'prod.db'); +const dirPath = __dirname; + +console.log('--- GymFlow Database Permission Check ---'); +console.log(`Checking directory: ${dirPath}`); +console.log(`Checking file: ${dbPath}`); + +// 1. Check Directory +try { + fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK); + console.log('✅ Directory is readable and writable.'); +} catch (err) { + console.error('❌ Directory is NOT writable! SQLite needs directory write access to create temporary files.'); +} + +// 2. Check File +if (fs.existsSync(dbPath)) { + try { + fs.accessSync(dbPath, fs.constants.R_OK | fs.constants.W_OK); + console.log('✅ Database file is readable and writable.'); + } catch (err) { + console.error('❌ Database file is NOT writable!'); + } +} else { + console.log('ℹ️ Database file does not exist yet.'); +} + +// 3. Try to write a test file in the directory +const testFile = path.join(dirPath, '.write_test'); +try { + fs.writeFileSync(testFile, 'test'); + fs.unlinkSync(testFile); + console.log('✅ Successfully performed a test write in the directory.'); +} catch (err) { + console.error(`❌ Failed test write in directory: ${err.message}`); +} + +console.log('\n--- Recommendation ---'); +console.log('If any checks failed, run these commands on your NAS (in the gymflow/server folder):'); +console.log('1. sudo chmod 777 .'); +console.log('2. sudo chmod 666 prod.db'); +console.log('\nAlternatively, ensure your Docker container is running with a user that owns these files.'); diff --git a/server/prisma/test.db b/server/prisma/test.db index ac39fce..882938a 100644 Binary files a/server/prisma/test.db and b/server/prisma/test.db differ diff --git a/server/prod.db b/server/prod.db index 5f7691d..c9b5e78 100644 Binary files a/server/prod.db and b/server/prod.db differ diff --git a/server/reset_prod_db.js b/server/reset_prod_db.js index 8be675e..f0f420d 100644 --- a/server/reset_prod_db.js +++ b/server/reset_prod_db.js @@ -54,10 +54,33 @@ async function resetDb() { // 4. Create the Admin user console.log(`Creating fresh admin user...`); - // In Prisma 7, we must use the adapter for better-sqlite3 + // In Prisma 7, PrismaBetterSqlite3 is a factory. + // We use the factory to create the adapter, then we access the internal client + // to disable WAL mode for NAS/Network share compatibility (journal_mode = DELETE). const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3'); - const adapter = new PrismaBetterSqlite3({ url: dbPath }); - const prisma = new PrismaClient({ adapter }); + const factory = new PrismaBetterSqlite3({ url: dbPath }); + + const adapterWrapper = { + provider: 'sqlite', + adapterName: '@prisma/adapter-better-sqlite3', + async connect() { + const adapter = await factory.connect(); + if (adapter.client) { + console.log(`Setting journal_mode = DELETE for NAS compatibility`); + adapter.client.pragma('journal_mode = DELETE'); + } + return adapter; + }, + async connectToShadowDb() { + const adapter = await factory.connectToShadowDb(); + if (adapter.client) { + adapter.client.pragma('journal_mode = DELETE'); + } + return adapter; + } + }; + + const prisma = new PrismaClient({ adapter: adapterWrapper }); try { const hashedPassword = await bcrypt.hash(adminPassword, 10); diff --git a/server/src/lib/prisma.ts b/server/src/lib/prisma.ts index bf89831..210327c 100644 --- a/server/src/lib/prisma.ts +++ b/server/src/lib/prisma.ts @@ -35,13 +35,36 @@ console.log('Initializing Prisma Client with database:', dbPath); let prisma: PrismaClient; +// In Prisma 7, PrismaBetterSqlite3 is a factory. +// We use a wrapper to intercept the connection and disable WAL mode +// for NAS/Network share compatibility (journal_mode = DELETE). try { - const adapter = new PrismaBetterSqlite3({ url: dbPath }); + const factory = new PrismaBetterSqlite3({ url: dbPath }); + + const adapterWrapper = { + provider: 'sqlite', + adapterName: '@prisma/adapter-better-sqlite3', + async connect() { + const adapter = (await factory.connect()) as any; + if (adapter.client) { + console.log('[Prisma] Setting journal_mode = DELETE for NAS compatibility'); + adapter.client.pragma('journal_mode = DELETE'); + } + return adapter; + }, + async connectToShadowDb() { + const adapter = (await factory.connectToShadowDb()) as any; + if (adapter.client) { + adapter.client.pragma('journal_mode = DELETE'); + } + return adapter; + } + }; prisma = global.prisma || new PrismaClient({ - adapter: adapter as any, + adapter: adapterWrapper as any, }); } catch (e: any) { console.error('Failed to initialize Prisma Client:', e.message); diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index e9e2e75..8698c41 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -321,6 +321,7 @@ const Profile: React.FC = ({ user, onLogout, lang, onLanguageChang value={birthDate} onChange={(val) => setBirthDate(val)} testId="profile-birth-date" + maxDate={new Date()} />
diff --git a/src/components/ui/DatePicker.tsx b/src/components/ui/DatePicker.tsx index 056e259..58c905a 100644 --- a/src/components/ui/DatePicker.tsx +++ b/src/components/ui/DatePicker.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useId } from 'react'; import { createPortal } from 'react-dom'; -import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; import { Button } from './Button'; import { Ripple } from './Ripple'; @@ -12,6 +12,7 @@ interface DatePickerProps { icon?: React.ReactNode; disabled?: boolean; testId?: string; + maxDate?: Date; // Optional maximum date constraint } export const DatePicker: React.FC = ({ @@ -21,7 +22,8 @@ export const DatePicker: React.FC = ({ placeholder = 'Select date', icon = , disabled = false, - testId + testId, + maxDate }) => { const id = useId(); const [isOpen, setIsOpen] = useState(false); @@ -30,6 +32,11 @@ export const DatePicker: React.FC = ({ const containerRef = useRef(null); const popoverRef = useRef(null); + // MD3 Enhancement: Calendar view and text input states + const [calendarView, setCalendarView] = useState<'days' | 'months' | 'years'>('days'); + const [textInputValue, setTextInputValue] = useState(''); + const [textInputError, setTextInputError] = useState(''); + // Update popover position when opening or when window resizes const updatePosition = () => { if (containerRef.current) { @@ -100,6 +107,10 @@ export const DatePicker: React.FC = ({ const nextOpen = !isOpen; if (nextOpen) { updatePosition(); + // MD3 Enhancement: Reset to days view when opening + setCalendarView('days'); + setTextInputValue(''); + setTextInputError(''); } setIsOpen(nextOpen); } @@ -122,57 +133,257 @@ export const DatePicker: React.FC = ({ setIsOpen(false); }; + // MD3 Enhancement: Year selection handler + const handleYearSelect = (year: number) => { + setViewDate(new Date(year, viewDate.getMonth(), 1)); + setCalendarView('days'); + }; + + // MD3 Enhancement: Text input validation and handling + const validateDateInput = (input: string): boolean => { + // Check format YYYY-MM-DD + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(input)) { + setTextInputError('Format: YYYY-MM-DD'); + return false; + } + + // Check if date is valid + const date = new Date(input); + if (isNaN(date.getTime())) { + setTextInputError('Invalid date'); + return false; + } + + // Check if the input matches the parsed date (catches invalid dates like 2023-02-30) + const [year, month, day] = input.split('-').map(Number); + if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) { + setTextInputError('Invalid date'); + return false; + } + + // Check against maxDate constraint + if (maxDate && date > maxDate) { + setTextInputError('Date cannot be in the future'); + return false; + } + + setTextInputError(''); + return true; + }; + + const handleTextInputSubmit = () => { + if (validateDateInput(textInputValue)) { + onChange(textInputValue); + setIsOpen(false); + setTextInputValue(''); + } + }; + const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate(); const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay(); + const renderCalendar = () => { const year = viewDate.getFullYear(); const month = viewDate.getMonth(); const daysCount = daysInMonth(year, month); const startingDay = firstDayOfMonth(year, month); - const monthName = viewDate.toLocaleString('default', { month: 'long' }); - - const days = []; - for (let i = 0; i < startingDay; i++) { - days.push(
); - } + const monthName = viewDate.toLocaleString('en-US', { month: 'long' }); + const shortMonthName = viewDate.toLocaleString('en-US', { month: 'short' }); + // Format selected date for header display const selectedDate = value ? new Date(value) : null; - const isSelected = (d: number) => { - return selectedDate && - selectedDate.getFullYear() === year && - selectedDate.getMonth() === month && - selectedDate.getDate() === d; - }; + const headerDateText = selectedDate + ? selectedDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) + : 'Select date'; - const today = new Date(); - const isToday = (d: number) => { - return today.getFullYear() === year && - today.getMonth() === month && - today.getDate() === d; - }; + // Render year selection view + const renderYearsView = () => { + const currentYear = new Date().getFullYear(); + const maxYear = maxDate ? maxDate.getFullYear() : currentYear + 100; + const years = []; + // Generate years from current year going backwards (recent first) + for (let y = maxYear; y >= currentYear - 100; y--) { + years.push(y); + } - for (let d = 1; d <= daysCount; d++) { - days.push( - + const selectedYear = selectedDate?.getFullYear(); + + return ( +
+
+ {years.map(y => ( + + ))} +
+
); - } + }; + + // Render month selection view + const renderMonthsView = () => { + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + return ( +
+
+ {months.map((monthLabel, i) => ( + + ))} +
+
+ ); + }; + + // Render days view + const renderDaysView = () => { + const days = []; + for (let i = 0; i < startingDay; i++) { + days.push(
); + } + + const isSelected = (d: number) => { + return selectedDate && + selectedDate.getFullYear() === year && + selectedDate.getMonth() === month && + selectedDate.getDate() === d; + }; + + const today = new Date(); + const isToday = (d: number) => { + return today.getFullYear() === year && + today.getMonth() === month && + today.getDate() === d; + }; + + // Check if a date is disabled (after maxDate) + const isDisabled = (d: number) => { + if (!maxDate) return false; + const checkDate = new Date(year, month, d); + return checkDate > maxDate; + }; + + for (let d = 1; d <= daysCount; d++) { + const disabled = isDisabled(d); + days.push( + + ); + } + + return ( + <> +
+ {/* Month navigation */} +
+ + + +
+ + {/* Year navigation */} +
+ + + +
+
+ +
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( +
+ {day} +
+ ))} +
+ +
+ {days} +
+ + ); + }; const calendarContent = (
= ({ }} className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top" > -
-
- {monthName} {year} -
-
- - -
-
- -
- {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( -
- {day} -
- ))} -
- -
- {days} -
+ {/* Calendar content based on view */} + {calendarView === 'years' ? ( + renderYearsView() + ) : calendarView === 'months' ? ( + renderMonthsView() + ) : ( + renderDaysView() + )} + {/* Footer with Cancel button */}
+