Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci-infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,15 @@ const App: React.FC = () => {

// Render main app content with header
return (
<>
<Header setView={navigateWithProtection} onSignOut={handleSignOutWithProtection} activeSection={activeSection} onSwitchSection={handleSwitchSectionWithProtection} />
<>
<Header
setView={navigateWithProtection}
onSignOut={handleSignOutWithProtection}
activeSection={activeSection}
onSwitchSection={handleSwitchSectionWithProtection}
currentUser={currentUser}
userRole={userRole}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{renderMainContent()}
</main>
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions components/AccountSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import React, { useState } from 'react';
import { Section, ToastType } from '../types';
import * as supabaseAuth from '../services/supabaseAuth';
Expand Down
75 changes: 24 additions & 51 deletions components/BoyMarksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,8 +74,8 @@ const BoyMarksPage: React.FC<BoyMarksPageProps> = ({ 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.');
}
Expand All @@ -96,34 +100,9 @@ const BoyMarksPage: React.FC<BoyMarksPageProps> = ({ 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);
}
Expand Down Expand Up @@ -193,32 +172,26 @@ const BoyMarksPage: React.FC<BoyMarksPageProps> = ({ 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);
Expand Down
4 changes: 1 addition & 3 deletions components/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import React from 'react';
// CalendarIcon is no longer needed as the custom button is removed.
// import { CalendarIcon } from './Icons';
Expand Down Expand Up @@ -53,4 +51,4 @@ const DatePicker: React.FC<DatePickerProps> = ({
);
};

export default DatePicker;
export default DatePicker;
20 changes: 11 additions & 9 deletions components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<HeaderProps> = ({ setView, onSignOut, activeSection, onSwitchSection }) => {
const { currentUser: user, userRole } = useAuthAndRole();
const Header: React.FC<HeaderProps> = ({ 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.
Expand Down Expand Up @@ -99,7 +101,7 @@ const Header: React.FC<HeaderProps> = ({ setView, onSignOut, activeSection, onSw

{/* Desktop Menu */}
<div className="hidden lg:flex items-center space-x-2">
{user && (
{currentUser && (
<>
<button onClick={() => handleNavClick('home')} className={navLinkClasses}>Home</button>
<button onClick={() => handleNavClick('dashboard')} className={navLinkClasses}>Dashboard</button>
Expand All @@ -126,7 +128,7 @@ const Header: React.FC<HeaderProps> = ({ setView, onSignOut, activeSection, onSw
{isProfileMenuOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button">
<div className="py-1">
<p className="block px-4 py-2 text-sm text-slate-500 truncate border-b border-slate-100">{user?.email || user?.id}</p>
<p className="block px-4 py-2 text-sm text-slate-500 truncate border-b border-slate-100">{currentUser.email || currentUser.id}</p>
<button onClick={() => handleNavClick('accountSettings')} className={dropdownItemClasses} role="menuitem">
<CogIcon className="h-5 w-5 mr-2 text-slate-500" />
Account Settings
Expand All @@ -149,7 +151,7 @@ const Header: React.FC<HeaderProps> = ({ setView, onSignOut, activeSection, onSw

{/* Mobile Menu Button (Hamburger Icon) */}
<div className="lg:hidden flex items-center">
{user && (
{currentUser && (
<button onClick={() => setIsMenuOpen(!isMenuOpen)} className={`inline-flex items-center justify-center p-2 rounded-md text-gray-300 hover:text-white hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-inset ${ringColor}`}>
<span className="sr-only">Open main menu</span>
{isMenuOpen ? <XIcon className="block h-6 w-6" /> : <MenuIcon className="block h-6 w-6" />}
Expand All @@ -160,7 +162,7 @@ const Header: React.FC<HeaderProps> = ({ setView, onSignOut, activeSection, onSw
</nav>

{/* Mobile Menu Panel */}
{isMenuOpen && user && (
{isMenuOpen && currentUser && (
<div className={`lg:hidden absolute w-full ${bgColor} shadow-lg z-30`} id="mobile-menu">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<button onClick={() => handleNavClick('home')} className={mobileNavLinkClasses}>Home</button>
Expand All @@ -173,7 +175,7 @@ const Header: React.FC<HeaderProps> = ({ setView, onSignOut, activeSection, onSw
)}
{/* Profile-related options in mobile menu */}
<div className="pt-2 mt-2 border-t border-white/20">
<p className="block px-3 py-2 text-base font-medium text-gray-200">{user?.email || user?.id}</p>
<p className="block px-3 py-2 text-base font-medium text-gray-200">{currentUser.email || currentUser.id}</p>
<button onClick={() => handleNavClick('accountSettings')} className={mobileNavLinkClasses}>
<div className="flex items-center"><CogIcon className="h-5 w-5 mr-3"/><span>Account Settings</span></div>
</button>
Expand Down
2 changes: 0 additions & 2 deletions components/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import React, { useState, useMemo, useEffect } from 'react';
import { Boy, Squad, View, Section, JuniorSquad, ToastType, SortByType, SchoolYear, JuniorYear } from '../types';
import Modal from './Modal';
Expand Down
2 changes: 0 additions & 2 deletions components/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import React, { useState, useEffect } from 'react';
import { Section, SectionSettings, ToastType, UserRole } from '../types';
import { saveSettings } from '../services/settings';
Expand Down
Loading
Loading