diff --git a/.github/workflows/ci-infrastructure.yml b/.github/workflows/ci-infrastructure.yml
index 84a332c..202ce73 100644
--- a/.github/workflows/ci-infrastructure.yml
+++ b/.github/workflows/ci-infrastructure.yml
@@ -30,28 +30,31 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Check required Supabase client env vars
+ - name: Check required Supabase client and smoke-test env vars
run: |
test -n "$VITE_SUPABASE_URL"
test -n "$VITE_SUPABASE_ANON_KEY"
test -n "$E2E_TEST_EMAIL"
test -n "$E2E_TEST_PASSWORD"
- - name: Smoke-test Supabase client startup
+ - name: Smoke-test Supabase client startup and runtime config
run: |
node --input-type=module -e "import { createServer } from 'vite'; const server = await createServer({ server: { middlewareMode: true }, optimizeDeps: { noDiscovery: true } }); try { await server.ssrLoadModule('/services/supabaseClient.ts'); console.log('Supabase client startup smoke test passed'); } finally { await server.close(); }"
- - name: Type-check code
+ - name: Run client-visible database contract smoke check
+ run: npm run check:db-contract
+
+ - name: Type-check app and tests
run: npm run typecheck
- - name: Run tests
+ - name: Run unit tests
run: npm run test:run
- - name: Build app
+ - name: Build production bundle
run: npm run build
- - name: Install Playwright Chromium
+ - name: Install Playwright browser dependencies
run: npx playwright install --with-deps chromium
- - name: Run E2E smoke tests
+ - name: Run browser smoke tests against live Supabase data
run: npm run test:e2e
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7a6ee98..f868841 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -65,7 +65,7 @@ flowchart LR
- `profiles` for app roles and user metadata
- `members` for member records
- `marks` for per-member attendance and scores
-- `services/settings.ts` handles section-level settings.
+- `services/settings.ts` handles section-level settings, updating the seeded `company` and `junior` rows in place.
## State Model
diff --git a/App.tsx b/App.tsx
index 09d967f..1f52199 100644
--- a/App.tsx
+++ b/App.tsx
@@ -145,8 +145,15 @@ const App: React.FC = () => {
// Render main app content with header
return (
- <>
-
+ <>
+
{renderMainContent()}
diff --git a/README.md b/README.md
index 6e64afb..fe6a3cc 100644
--- a/README.md
+++ b/README.md
@@ -17,21 +17,25 @@ There is no in-repo Express server or Docker runtime. The app is built as a stat
1. Install dependencies with `npm install`.
2. Create a local `.env` from [`.env.example`](./.env.example).
3. Start the dev server with `npm run dev`.
-4. Run `npm run typecheck`, `npm run test:run`, and `npm run build` before shipping changes.
+4. Run `npm run check:db-contract`, `npm run typecheck`, `npm run test:run`, and `npm run build` before shipping changes.
## Testing
+- `npm run check:db-contract` reads `.env` and `.env.local`, signs in with the smoke-test user, resolves `current_app_role()`, and confirms the seeded `settings` rows for `company` and `junior` are readable through the published client credentials.
- `npm run test:run` runs the lean automated suite used by CI on every push and pull request.
- `npm run test:coverage` reports coverage for the same suite.
-- `npm run test:e2e` runs the browser smoke suite against a real Supabase-backed environment. It requires `E2E_TEST_EMAIL` and `E2E_TEST_PASSWORD`.
-- `tests/e2e/` contains manual Supabase-backed smoke-test runbooks for auth, member CRUD, and marks workflows.
+- `npm run test:e2e` reads `.env` and `.env.local` and runs the browser smoke suite against a real Supabase-backed environment. It requires `E2E_TEST_EMAIL` and `E2E_TEST_PASSWORD`.
+- `tests/e2e/` contains manual Supabase-backed smoke-test runbooks for auth, section settings, member CRUD, and marks workflows.
-The CI smoke suite expects the test account to have a valid app role and at least one member in the Company section so the weekly-marks save flow has real data to exercise.
+The CI smoke suite expects the test account to have a valid app role, seeded `settings` rows for both sections, and at least one member in the Company section so the settings and weekly-marks save flows have real data to exercise.
+It verifies the client-visible contract only; it does not prove every live RLS restriction without privileged Supabase inspection.
## Environment Variables
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_ANON_KEY`
+- `E2E_TEST_EMAIL`
+- `E2E_TEST_PASSWORD`
## Documentation
diff --git a/components/AccountSettingsPage.tsx b/components/AccountSettingsPage.tsx
index 60c7c23..7aa05a4 100644
--- a/components/AccountSettingsPage.tsx
+++ b/components/AccountSettingsPage.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import React, { useState } from 'react';
import { Section, ToastType } from '../types';
import * as supabaseAuth from '../services/supabaseAuth';
diff --git a/components/BoyMarksPage.tsx b/components/BoyMarksPage.tsx
index 47e25f3..16f0c85 100644
--- a/components/BoyMarksPage.tsx
+++ b/components/BoyMarksPage.tsx
@@ -6,10 +6,14 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
-import { fetchBoyById, updateBoy } from '../services/db';
import { Boy, Mark, Squad, Section, JuniorSquad, ToastType } from '../types';
import { TrashIcon, SaveIcon } from './Icons';
import { BoyMarksPageSkeleton } from './SkeletonLoaders';
+import { fetchBoyById, saveBoyMarks } from '../services/db';
+import {
+ areMarkListsEqual,
+ normalizeEditableMarksForSave,
+} from './weeklyMarksSavePlan';
interface BoyMarksPageProps {
boyId: string;
@@ -70,8 +74,8 @@ const BoyMarksPage: React.FC = ({ boyId, refreshData, setHasU
// Sort marks by date descending for display.
boyData.marks.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setBoy(boyData);
- // Create a deep copy of the marks for editing to avoid mutating the original state.
- setEditedMarks(JSON.parse(JSON.stringify(boyData.marks)));
+ // Create a shallow copy of the marks for editing to avoid mutating the original state.
+ setEditedMarks(boyData.marks.map((mark) => ({ ...mark })));
} else {
setError('Boy not found.');
}
@@ -96,34 +100,9 @@ const BoyMarksPage: React.FC = ({ boyId, refreshData, setHasU
useEffect(() => {
if (boy) {
// Create a canonical, sorted representation of the original marks to ensure consistent key order and data types for comparison.
- const originalMarksCanonical = [...boy.marks]
- .map(m => {
- const cleanMark: any = { date: m.date, score: m.score };
- if (m.uniformScore !== undefined) cleanMark.uniformScore = m.uniformScore;
- if (m.behaviourScore !== undefined) cleanMark.behaviourScore = m.behaviourScore;
- return cleanMark;
- })
- .sort((a, b) => a.date.localeCompare(b.date));
+ const editedMarksCanonical = normalizeEditableMarksForSave(editedMarks, activeSection);
- // Create a canonical, sorted representation of the edited marks.
- // Empty string inputs are converted to 0, which mirrors the save logic.
- const editedMarksCanonical = [...editedMarks]
- .map(editableMark => {
- if (isCompany || editableMark.uniformScore === undefined) {
- // An empty string for a score is treated as 0 for comparison.
- const score = editableMark.score === '' ? 0 : parseFloat(editableMark.score as string); // Use parseFloat
- return { date: editableMark.date, score };
- }
- // For Juniors, recalculate the total score from uniform and behaviour.
- const uniformScore = editableMark.uniformScore === '' ? 0 : parseFloat(editableMark.uniformScore as string); // Use parseFloat
- const behaviourScore = editableMark.behaviourScore === '' ? 0 : parseFloat(editableMark.behaviourScore as string); // Use parseFloat
- const totalScore = Number(editableMark.score) < 0 ? -1 : uniformScore + behaviourScore; // Preserve absent status.
- return { date: editableMark.date, score: totalScore, uniformScore, behaviourScore };
- })
- .sort((a, b) => a.date.localeCompare(b.date));
-
- // Compare the stringified versions to check for differences.
- setIsDirty(JSON.stringify(originalMarksCanonical) !== JSON.stringify(editedMarksCanonical));
+ setIsDirty(!areMarkListsEqual(boy.marks, editedMarksCanonical));
} else {
setIsDirty(false);
}
@@ -193,32 +172,26 @@ const BoyMarksPage: React.FC = ({ boyId, refreshData, setHasU
const handleSaveChanges = async () => {
if (!boy || !isDirty) return;
+ if (!boy.id) {
+ setError('Boy ID is required.');
+ return;
+ }
setIsSaving(true);
setError(null);
- // Convert the local `EditableMark[]` state back into the strict `Mark[]` type for saving.
- const validMarks: Mark[] = editedMarks
- .map(editableMark => {
- if(isCompany || editableMark.uniformScore === undefined) {
- // An empty string for a score should be saved as 0.
- const score = editableMark.score === '' ? 0 : parseFloat(editableMark.score as string); // Use parseFloat
- return { date: editableMark.date, score };
- }
- // For Juniors, recalculate the total score from uniform and behaviour.
- const uniformScore = editableMark.uniformScore === '' ? 0 : parseFloat(editableMark.uniformScore as string); // Use parseFloat
- const behaviourScore = editableMark.behaviourScore === '' ? 0 : parseFloat(editableMark.behaviourScore as string); // Use parseFloat
- const totalScore = Number(editableMark.score) < 0 ? -1 : uniformScore + behaviourScore; // Preserve absent status.
- return { date: editableMark.date, score: totalScore, uniformScore, behaviourScore };
- });
-
- const updatedBoyData = { ...boy, marks: validMarks };
+ const nextMarks = normalizeEditableMarksForSave(editedMarks, activeSection);
try {
- await updateBoy(updatedBoyData, activeSection);
- // Update local state to match the saved data.
- updatedBoyData.marks.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
- setBoy(updatedBoyData);
- setEditedMarks(JSON.parse(JSON.stringify(updatedBoyData.marks)));
+ await saveBoyMarks(boy.id, activeSection, boy.marks, nextMarks);
+
+ const savedBoy = await fetchBoyById(boy.id, activeSection);
+ if (!savedBoy) {
+ throw new Error('Failed to reload saved marks.');
+ }
+
+ savedBoy.marks.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+ setBoy(savedBoy);
+ setEditedMarks(savedBoy.marks.map((mark) => ({ ...mark })));
showToast('Changes saved successfully!', 'success');
refreshData(); // Refresh data in the main App component.
setIsDirty(false);
diff --git a/components/DatePicker.tsx b/components/DatePicker.tsx
index 858d850..276053b 100644
--- a/components/DatePicker.tsx
+++ b/components/DatePicker.tsx
@@ -1,8 +1,4 @@
-"use client";
-
import React from 'react';
-// CalendarIcon is no longer needed as the custom button is removed.
-// import { CalendarIcon } from './Icons';
interface DatePickerProps {
value: string; // YYYY-MM-DD format
@@ -19,38 +15,20 @@ const DatePicker: React.FC = ({
ariaLabel = "Select date",
accentRingClass = "focus:ring-junior-blue focus:border-junior-blue"
}) => {
- // The inputRef and handleIconClick are no longer needed as showPicker() is removed.
- // const inputRef = useRef(null);
- // const handleIconClick = () => {
- // if (inputRef.current && !disabled) {
- // inputRef.current.showPicker(); // Programmatically open the date picker
- // }
- // };
+ // The old inputRef/handleIconClick/showPicker path was removed because browsers blocked it inconsistently.
return (
onChange(e.target.value)}
disabled={disabled}
className={`block w-full px-3 py-2 pr-3 bg-white border border-slate-300 rounded-md shadow-sm focus:outline-none sm:text-sm ${accentRingClass} disabled:bg-slate-100 disabled:text-slate-500 disabled:cursor-not-allowed`}
aria-label={ariaLabel}
/>
- {/* The custom button to trigger the date picker is removed to avoid the cross-origin error.
- Users can still click directly on the input field to open the native date picker. */}
- {/* */}
);
};
-export default DatePicker;
\ No newline at end of file
+export default DatePicker;
diff --git a/components/Header.tsx b/components/Header.tsx
index f31bc3f..cebac0e 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -7,8 +7,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { MenuIcon, XIcon, CogIcon, UserCircleIcon, SwitchHorizontalIcon, LogOutIcon } from './Icons';
-import { Page, Section } from '../types';
-import { useAuthAndRole } from '../hooks/useAuthAndRole';
+import { AppUser, Page, Section, UserRole } from '../types';
interface HeaderProps {
/** Function to change the current view/page. */
@@ -19,10 +18,13 @@ interface HeaderProps {
activeSection: Section;
/** Callback function to handle switching between sections. */
onSwitchSection: () => void;
+ /** The current authenticated user from the app root. */
+ currentUser: AppUser | null;
+ /** The current authenticated user's application role. */
+ userRole: UserRole | null;
}
-const Header: React.FC = ({ setView, onSignOut, activeSection, onSwitchSection }) => {
- const { currentUser: user, userRole } = useAuthAndRole();
+const Header: React.FC = ({ setView, onSignOut, activeSection, onSwitchSection, currentUser, userRole }) => {
// State to manage the visibility of the mobile menu.
const [isMenuOpen, setIsMenuOpen] = useState(false);
// State to manage the visibility of the desktop profile dropdown.
@@ -99,7 +101,7 @@ const Header: React.FC = ({ setView, onSignOut, activeSection, onSw
{/* Desktop Menu */}