diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f868841..ddf93a4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,6 +47,7 @@ flowchart LR - `App.tsx` orchestrates auth state, active section, view switching, and modal/toast state. - `components/*` implements pages and shared UI. +- `components/reports/*` contains the branded session PDF document used by the dashboard export flow. - Navigation is app-state driven rather than router-driven. ### 2. Hook Layer @@ -65,6 +66,7 @@ flowchart LR - `profiles` for app roles and user metadata - `members` for member records - `marks` for per-member attendance and scores +- `services/reporting/sessionReport.ts` derives PDF-ready session reporting aggregates from loaded section data. - `services/settings.ts` handles section-level settings, updating the seeded `company` and `junior` rows in place. ## State Model @@ -77,6 +79,7 @@ Sources of truth: - React component and hook state for loaded records and view state The app does not maintain an offline cache or a separate backend API. +The branded session PDF generator also runs fully client-side in the browser. ## Security Model diff --git a/assets/branding/bb-background.jpg b/assets/branding/bb-background.jpg new file mode 100644 index 0000000..a59dd4b Binary files /dev/null and b/assets/branding/bb-background.jpg differ diff --git a/assets/branding/bb-logo.png b/assets/branding/bb-logo.png new file mode 100644 index 0000000..e2c1059 Binary files /dev/null and b/assets/branding/bb-logo.png differ diff --git a/assets/branding/company-logo.png b/assets/branding/company-logo.png new file mode 100644 index 0000000..d000bc6 Binary files /dev/null and b/assets/branding/company-logo.png differ diff --git a/assets/branding/junior-logo.png b/assets/branding/junior-logo.png new file mode 100644 index 0000000..ddb3ce2 Binary files /dev/null and b/assets/branding/junior-logo.png differ diff --git a/components/DashboardPage.tsx b/components/DashboardPage.tsx index 5511dc3..8abb37a 100644 --- a/components/DashboardPage.tsx +++ b/components/DashboardPage.tsx @@ -5,11 +5,13 @@ * providing a high-level overview for reporting and comparison. */ -import React, { useMemo } from 'react'; +import React, { Suspense, useMemo } from 'react'; import { Boy, Squad, Section, JuniorSquad, Mark } from '../types'; -import { StarIcon, ChartBarIcon } from './Icons'; +import { StarIcon, ChartBarIcon, ClipboardDocumentListIcon } from './Icons'; import BarChart from './BarChart'; +const SessionReportModal = React.lazy(() => import('./SessionReportModal')); + interface DashboardPageProps { boys: Boy[]; @@ -38,6 +40,7 @@ const SQUAD_CHART_COLORS: Record = { } const DashboardPage: React.FC = ({ boys, activeSection }) => { + const [isReportModalOpen, setIsReportModalOpen] = React.useState(false); const isCompany = activeSection === 'company'; const SQUAD_COLORS = isCompany ? COMPANY_SQUAD_COLORS : JUNIOR_SQUAD_COLORS; @@ -165,7 +168,26 @@ const DashboardPage: React.FC = ({ boys, activeSection }) => return (
-

Dashboard

+
+
+

Dashboard

+

+ Review session performance across marks and attendance, then export a single master BB report for the active section. +

+
+ +
{/* Visualizations Grid */}
@@ -274,8 +296,19 @@ const DashboardPage: React.FC = ({ boys, activeSection }) =>
+ + {isReportModalOpen && ( + + setIsReportModalOpen(false)} + /> + + )} ); }; -export default DashboardPage; \ No newline at end of file +export default DashboardPage; diff --git a/components/SessionReportModal.tsx b/components/SessionReportModal.tsx new file mode 100644 index 0000000..5665a96 --- /dev/null +++ b/components/SessionReportModal.tsx @@ -0,0 +1,173 @@ +import React, { useMemo, useState } from 'react'; +import { PDFDownloadLink } from '@react-pdf/renderer'; + +import Modal from './Modal'; +import SessionReportDocument from './reports/SessionReportDocument'; +import { buildSessionReportData, getSectionDateRange } from '../services/reporting/sessionReport'; +import type { Boy, Section } from '../types'; + +interface SessionReportModalProps { + boys: Boy[]; + activeSection: Section; + isOpen: boolean; + onClose: () => void; +} + +const formatDate = (value: string) => + new Date(`${value}T00:00:00`).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + +const SessionReportModal: React.FC = ({ + boys, + activeSection, + isOpen, + onClose, +}) => { + const sectionRange = useMemo(() => getSectionDateRange(boys), [boys]); + const [startDate, setStartDate] = useState(sectionRange?.startDate ?? ''); + const [endDate, setEndDate] = useState(sectionRange?.endDate ?? ''); + const inputBrandClasses = + activeSection === 'company' + ? 'focus:border-company-blue focus:ring-company-blue' + : 'focus:border-junior-blue focus:ring-junior-blue'; + const buttonBrandClasses = + activeSection === 'company' + ? 'bg-company-blue focus:ring-company-blue' + : 'bg-junior-blue focus:ring-junior-blue'; + + React.useEffect(() => { + setStartDate(sectionRange?.startDate ?? ''); + setEndDate(sectionRange?.endDate ?? ''); + }, [sectionRange, isOpen]); + + const hasValidRange = Boolean(startDate && endDate && startDate <= endDate); + const report = useMemo(() => { + if (!hasValidRange) { + return null; + } + + return buildSessionReportData({ + boys, + section: activeSection, + range: { startDate, endDate }, + }); + }, [activeSection, boys, endDate, hasValidRange, startDate]); + + const hasDataForRange = Boolean(report && report.headlineStats.meetingCount > 0); + const sectionLabel = activeSection === 'company' ? 'Company Section' : 'Junior Section'; + const filename = `${activeSection}-session-report-${startDate || 'start'}-to-${endDate || 'end'}.pdf`; + const estimatedPageCount = report + ? 1 + 1 + 1 + Math.max(1, Math.ceil(report.meetings.length / 18)) + 1 + Math.max(1, Math.ceil(report.members.length / (activeSection === 'junior' ? 14 : 16))) + report.members.reduce((sum, member) => sum + 1 + Math.ceil(Math.max(member.meetings.length - 14, 0) / 22), 0) + : 0; + + return ( + + {!sectionRange ? ( +
+

+ There are no recorded marks in this section yet, so there is nothing to export. +

+
+ ) : ( +
+
+

Single Master Report

+

+ This export produces one branded end-of-session PDF for the active section using the current BB logo + and photography already used in the app. The document includes the section summary, attendance and marks + trends, squad breakdowns, and a page for every member in the selected date range. +

+
+ +
+ + +
+ + {hasValidRange ? ( + hasDataForRange && report ? ( +
+
+
+

Section

+

{sectionLabel}

+
+
+

Meetings

+

{report.headlineStats.meetingCount}

+
+
+

Attendance

+

{report.headlineStats.attendanceRate}%

+
+
+

Pages

+

{estimatedPageCount}

+
+
+

+ {`Report range: ${formatDate(startDate)} to ${formatDate(endDate)}. This will export ${report.members.length} member detail pages in addition to the summary pages.`} +

+
+ ) : ( +
+ There are no recorded marks inside that range. Pick dates that include at least one saved meeting. +
+ ) + ) : ( +
+ Choose a valid date range where the start date is on or before the end date. +
+ )} + +
+ + + {hasDataForRange && report ? ( + } + fileName={filename} + className={`inline-flex items-center rounded-md px-4 py-2 text-sm font-medium text-white hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-offset-2 ${buttonBrandClasses}`} + > + {({ loading }) => (loading ? 'Preparing PDF...' : 'Download Master PDF')} + + ) : ( + PDF download becomes available once the range is valid. + )} +
+
+ )} +
+ ); +}; + +export default SessionReportModal; diff --git a/components/reports/SessionReportDocument.tsx b/components/reports/SessionReportDocument.tsx new file mode 100644 index 0000000..a3baa53 --- /dev/null +++ b/components/reports/SessionReportDocument.tsx @@ -0,0 +1,941 @@ +import React from 'react'; +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; + +import type { MemberMeetingRecord, MemberSessionSummary, SessionReportData, SquadSessionSummary } from '../../types/reporting'; +import bbLogo from '../../assets/branding/bb-logo.png'; +import bbBackground from '../../assets/branding/bb-background.jpg'; +import companyLogo from '../../assets/branding/company-logo.png'; +import juniorLogo from '../../assets/branding/junior-logo.png'; + +const BB_LOGO_URL = bbLogo; +const BB_BACKGROUND_URL = bbBackground; +const COMPANY_LOGO_URL = companyLogo; +const JUNIOR_LOGO_URL = juniorLogo; + +const styles = StyleSheet.create({ + page: { + backgroundColor: '#f8fafc', + color: '#0f172a', + fontSize: 10, + paddingTop: 84, + paddingBottom: 36, + paddingHorizontal: 32, + fontFamily: 'Helvetica', + }, + coverPage: { + paddingTop: 48, + paddingBottom: 48, + paddingHorizontal: 42, + backgroundColor: '#10182e', + color: '#ffffff', + fontFamily: 'Helvetica', + }, + coverContent: { + height: '100%', + flexDirection: 'column', + justifyContent: 'space-between', + }, + coverTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 34, + }, + coverBrandColumn: { + width: '58%', + }, + coverPhotoCard: { + width: '34%', + backgroundColor: '#18233f', + borderRadius: 18, + overflow: 'hidden', + border: '1 solid #314469', + }, + coverPhoto: { + width: '100%', + height: 228, + objectFit: 'cover', + }, + coverMainLogo: { + width: 120, + height: 120, + objectFit: 'contain', + }, + coverSectionLogo: { + width: 100, + height: 100, + objectFit: 'contain', + }, + coverEyebrow: { + color: '#9fb4ff', + fontSize: 12, + letterSpacing: 1.5, + textTransform: 'uppercase', + marginBottom: 14, + }, + coverTitle: { + color: '#ffffff', + fontSize: 28, + lineHeight: 1.2, + fontWeight: 700, + marginBottom: 10, + }, + coverSubtitle: { + color: '#dbe7ff', + fontSize: 13, + lineHeight: 1.5, + maxWidth: 380, + }, + coverStatGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: 24, + marginBottom: 28, + }, + coverStatCard: { + width: '48%', + backgroundColor: '#162441', + border: '1 solid #395485', + borderRadius: 12, + padding: 14, + marginRight: '2%', + marginBottom: 12, + }, + coverStatLabel: { + color: '#9fb4ff', + fontSize: 10, + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: 6, + }, + coverStatValue: { + color: '#ffffff', + fontSize: 22, + fontWeight: 700, + }, + coverFooter: { + color: '#c8d7f6', + fontSize: 10, + lineHeight: 1.5, + }, + coverDivider: { + width: 68, + height: 4, + borderRadius: 999, + backgroundColor: '#9fb4ff', + marginTop: 22, + marginBottom: 24, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 18, + paddingBottom: 10, + borderBottom: '1 solid #cbd5e1', + }, + headerBrand: { + flexDirection: 'row', + alignItems: 'center', + width: '66%', + }, + headerTextBlock: { + marginLeft: 12, + paddingRight: 10, + }, + headerLogo: { + width: 48, + height: 48, + objectFit: 'contain', + }, + sectionLogo: { + width: 44, + height: 44, + objectFit: 'contain', + }, + headerTitle: { + fontSize: 16, + lineHeight: 1.25, + fontWeight: 700, + color: '#0f172a', + }, + headerMeta: { + fontSize: 9, + color: '#475569', + lineHeight: 1.4, + textAlign: 'right', + width: '30%', + }, + sectionTitle: { + fontSize: 16, + fontWeight: 700, + marginBottom: 12, + color: '#0f172a', + }, + sectionCopy: { + fontSize: 10, + color: '#334155', + lineHeight: 1.5, + marginBottom: 16, + }, + statGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 18, + }, + statCard: { + width: '31%', + backgroundColor: '#ffffff', + border: '1 solid #e2e8f0', + borderRadius: 10, + padding: 12, + marginRight: '2.33%', + }, + statCardLabel: { + color: '#64748b', + fontSize: 9, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: 6, + }, + statCardValue: { + color: '#0f172a', + fontSize: 18, + fontWeight: 700, + }, + statCardHint: { + color: '#475569', + fontSize: 9, + marginTop: 4, + }, + twoColumn: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + column: { + flexGrow: 1, + flexBasis: 0, + width: '48.5%', + }, + card: { + backgroundColor: '#ffffff', + border: '1 solid #e2e8f0', + borderRadius: 10, + padding: 14, + marginBottom: 14, + }, + memberTableSection: { + marginTop: 6, + }, + cardTitle: { + fontSize: 12, + fontWeight: 700, + color: '#0f172a', + marginBottom: 10, + }, + keyRow: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + borderBottom: '1 solid #f1f5f9', + paddingVertical: 5, + }, + keyLabel: { + fontSize: 9, + color: '#475569', + }, + keyValue: { + fontSize: 9, + color: '#0f172a', + fontWeight: 700, + }, + rankRow: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 6, + borderBottom: '1 solid #f1f5f9', + }, + rankLabel: { + fontSize: 10, + color: '#0f172a', + fontWeight: 700, + }, + rankMeta: { + fontSize: 9, + color: '#64748b', + marginTop: 2, + }, + rankValue: { + fontSize: 12, + color: '#0f172a', + fontWeight: 700, + }, + chartBlock: { + marginBottom: 14, + }, + chartLabelRow: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + chartLabel: { + fontSize: 9, + color: '#334155', + }, + chartValue: { + fontSize: 9, + color: '#0f172a', + fontWeight: 700, + }, + chartTrack: { + width: '100%', + height: 11, + borderRadius: 999, + backgroundColor: '#e2e8f0', + }, + chartFill: { + height: 11, + borderRadius: 999, + }, + table: { + width: '100%', + border: '1 solid #e2e8f0', + borderRadius: 8, + overflow: 'hidden', + marginBottom: 12, + }, + tableHead: { + backgroundColor: '#e2e8f0', + }, + tableRow: { + display: 'flex', + flexDirection: 'row', + borderBottom: '1 solid #e2e8f0', + }, + tableRowAlt: { + backgroundColor: '#f8fafc', + }, + tableCell: { + paddingVertical: 7, + paddingHorizontal: 8, + fontSize: 8.5, + color: '#0f172a', + }, + tableHeadCell: { + fontSize: 8, + fontWeight: 700, + color: '#334155', + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + footer: { + position: 'absolute', + bottom: 16, + left: 32, + right: 32, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + fontSize: 8, + color: '#64748b', + }, + note: { + marginTop: 8, + fontSize: 8.5, + color: '#64748b', + lineHeight: 1.45, + }, + memberMetricsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + memberMetricsCard: { + width: '31%', + backgroundColor: '#ffffff', + border: '1 solid #e2e8f0', + borderRadius: 10, + padding: 12, + }, +}); + +const formatDate = (value: string) => + new Date(`${value}T00:00:00`).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + +const formatMonth = (value: string) => + new Date(`${value}-01T00:00:00`).toLocaleDateString(undefined, { + month: 'long', + year: 'numeric', + }); + +const formatGeneratedAt = (value: string) => + new Date(value).toLocaleString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + +const formatNumber = (value: number) => + Number.isInteger(value) ? value.toString() : value.toFixed(2); + +const getAccent = (section: SessionReportData['section']) => + section === 'company' + ? { primary: '#222943', secondary: '#9fb4ff', soft: '#dbe4ff', sectionLogo: COMPANY_LOGO_URL } + : { primary: '#284e8b', secondary: '#8ec6ff', soft: '#d8e9ff', sectionLogo: JUNIOR_LOGO_URL }; + +const chunk = (items: T[], size: number) => { + const chunks: T[][] = []; + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + + return chunks; +}; + +const renderHorizontalBars = ( + items: { label: string; value: number; helper?: string }[], + accentColor: string, + maxValue: number, +) => ( + + {items.map((item) => { + const width = maxValue > 0 ? `${Math.max((item.value / maxValue) * 100, 4)}%` : '0%'; + return ( + + + {item.label} + {item.helper ?? formatNumber(item.value)} + + + + + + ); + })} + +); + +const renderPageHeader = (report: SessionReportData, title: string) => { + const accent = getAccent(report.section); + + return ( + + + + + + {title} + {report.sectionLabel} + + + + {`Session: ${formatDate(report.range.startDate)} to ${formatDate(report.range.endDate)}\nGenerated: ${formatGeneratedAt(report.generatedAt)}`} + + + ); +}; + +const renderPageFooter = (report: SessionReportData) => ( + + {`BB Manager session report • ${report.sectionLabel}`} + `Page ${pageNumber} of ${totalPages}`} /> + +); + +const renderMemberTable = (members: MemberSessionSummary[], section: SessionReportData['section']) => ( + + + Member + Squad + Year + Attend + Absent + Rate + Total + {section === 'junior' && ( + Uniform + )} + {section === 'junior' && ( + Behav. + )} + + {members.map((member, index) => ( + + {member.name} + {String(member.squad)} + {String(member.year)} + {member.attendanceCount} + {member.absenceCount} + {`${formatNumber(member.attendanceRate)}%`} + {formatNumber(member.totalMarks)} + {section === 'junior' && ( + {formatNumber(member.uniformTotal ?? 0)} + )} + {section === 'junior' && ( + {formatNumber(member.behaviourTotal ?? 0)} + )} + + ))} + +); + +const renderSquadCard = (squad: SquadSessionSummary) => ( + + {`Squad ${squad.squad}`} + + Members + {squad.memberCount} + + + Attendance + {`${formatNumber(squad.attendanceRate)}%`} + + + Total marks + {formatNumber(squad.totalMarks)} + + + Average mark when present + {formatNumber(squad.averageScoreWhenPresent)} + + + Leading member + + {squad.topMember ? `${squad.topMember.name} (${formatNumber(squad.topMember.totalMarks)})` : 'N/A'} + + + +); + +const renderMeetingLedgerTable = (meetings: SessionReportData['meetings']) => ( + + + Meeting + Present + Absent + Attend Rate + Marks + + {meetings.map((meeting, index) => ( + + {formatDate(meeting.date)} + {meeting.attendanceCount} + {meeting.absenceCount} + {`${formatNumber(meeting.attendanceRate)}%`} + {formatNumber(meeting.totalMarks)} + + ))} + +); + +export interface SessionReportDocumentProps { + report: SessionReportData; +} + +const SessionReportDocument: React.FC = ({ report }) => { + const accent = getAccent(report.section); + const topMeetingMark = Math.max(...report.meetings.map((meeting) => meeting.totalMarks), 0); + const topMonthMark = Math.max(...report.months.map((month) => month.totalMarks), 0); + const meetingChunks: SessionReportData['meetings'][] = chunk(report.meetings, 18); + const squadMemberChunks: MemberSessionSummary[][] = chunk( + report.members, + report.section === 'junior' ? 14 : 16, + ); + + return ( + + + + + + + + + BB Manager Master Session Report + {report.sectionLabel} + + {`A complete session summary covering attendance, marks, squad performance, and member-level detail from ${formatDate(report.range.startDate)} to ${formatDate(report.range.endDate)}.`} + + + + + + + + + + + + + Members + {report.headlineStats.memberCount} + + + Meetings + {report.headlineStats.meetingCount} + + + Attendance + {`${formatNumber(report.headlineStats.attendanceRate)}%`} + + + Total Marks + {formatNumber(report.headlineStats.totalMarks)} + + + + + + {`Generated ${formatGeneratedAt(report.generatedAt)}\nThis master PDF is based on the recorded marks and attendance already stored in BB Manager for the selected section and date range.`} + + + + + + {renderPageHeader(report, 'Executive Summary')} + Session Snapshot + + This page condenses the full session into the numbers most useful to officers at the end of a BB session: + membership size, how consistently members attended, how many marks were awarded, and which members led the section overall. + + + + + Total attendance records + {report.headlineStats.attendanceCount} + {`${report.headlineStats.absenceCount} absences recorded`} + + + Average mark when present + {formatNumber(report.headlineStats.averageMarksWhenPresent)} + Across all attended meetings + + + Reporting range + + {`${formatDate(report.range.startDate)}\n${formatDate(report.range.endDate)}`} + + + + + + + + Top Members + {report.topMembers.map((member, index) => ( + + + {`${index + 1}. ${member.name}`} + {`Squad ${member.squad} • Attendance ${formatNumber(member.attendanceRate)}% • Avg ${formatNumber(member.averageScoreWhenPresent)}`} + + {formatNumber(member.totalMarks)} + + ))} + + + + + + Squad Snapshot + {report.squads.map((squad) => ( + + {`Squad ${squad.squad}`} + + {`${formatNumber(squad.totalMarks)} marks • ${formatNumber(squad.attendanceRate)}%`} + + + ))} + + + + Reporting Notes + + Attendance percentages are calculated from recorded marks in the selected range. Present nights use the saved score; + absence rows are counted when a member was explicitly marked absent. + + + + + {renderPageFooter(report)} + + + + {renderPageHeader(report, 'Attendance And Marks Trends')} + + + + + Meeting Attendance Rate + {renderHorizontalBars( + report.meetings.map((meeting) => ({ + label: formatDate(meeting.date), + value: meeting.attendanceRate, + helper: `${formatNumber(meeting.attendanceRate)}%`, + })), + accent.primary, + 100, + )} + + + + + Meeting Marks Awarded + {renderHorizontalBars( + report.meetings.map((meeting) => ({ + label: formatDate(meeting.date), + value: meeting.totalMarks, + helper: formatNumber(meeting.totalMarks), + })), + accent.secondary, + topMeetingMark, + )} + + + + + + Monthly Session Pattern + {renderHorizontalBars( + report.months.map((month) => ({ + label: formatMonth(month.month), + value: month.totalMarks, + helper: `${formatNumber(month.totalMarks)} marks • ${formatNumber(month.attendanceRate)}%`, + })), + accent.primary, + topMonthMark, + )} + + + {renderPageFooter(report)} + + + {meetingChunks.map((meetingChunk, index) => ( + + {renderPageHeader(report, index === 0 ? 'Meeting Ledger' : 'Meeting Ledger Continued')} + + Meeting-by-meeting attendance and marks for the selected session range. + + {renderMeetingLedgerTable(meetingChunk)} + {renderPageFooter(report)} + + ))} + + + {renderPageHeader(report, 'Squad Breakdown')} + + + Squad-level comparison helps show which groups carried the session in marks, which groups were most reliable for attendance, + and where section leadership might want to focus at the start of the next session. + + + + {report.squads.filter((_, index) => index % 2 === 0).map(renderSquadCard)} + {report.squads.filter((_, index) => index % 2 === 1).map(renderSquadCard)} + + + {renderPageFooter(report)} + + + {squadMemberChunks.map((memberChunk, index) => ( + + {renderPageHeader(report, index === 0 ? 'Section Member Ledger' : 'Section Member Ledger Continued')} + + Full member-level attendance and marks summary for the selected session range. + + {renderMemberTable(memberChunk, report.section)} + {renderPageFooter(report)} + + ))} + + {report.members.flatMap((member) => { + const continuationChunks = chunk(member.meetings.slice(14), 22); + + return [ + + {renderPageHeader(report, `${member.name} Session Detail`)} + + + + Squad / Year + {`S${member.squad} / ${member.year}`} + {member.isSquadLeader ? 'Squad leader' : 'Member'} + + + Attendance + {`${formatNumber(member.attendanceRate)}%`} + {`${member.attendanceCount} present, ${member.absenceCount} absent`} + + + Total Marks + {formatNumber(member.totalMarks)} + {`Avg ${formatNumber(member.averageScoreWhenPresent)} when present`} + + + + + + + Member Summary + + Best night + + {member.bestNightDate ? `${formatNumber(member.bestNightScore)} on ${formatDate(member.bestNightDate)}` : 'N/A'} + + + + Last attended + {member.lastAttendedDate ? formatDate(member.lastAttendedDate) : 'N/A'} + + {report.section === 'junior' && ( + + Uniform total + {formatNumber(member.uniformTotal ?? 0)} + + )} + {report.section === 'junior' && ( + + Behaviour total + {formatNumber(member.behaviourTotal ?? 0)} + + )} + {report.section === 'company' && ( + + Recorded meetings + {member.meetings.length} + + )} + + + + + + Session Performance + + Attendance record + {`${member.attendanceCount} / ${member.meetings.length}`} + + + Best score + {formatNumber(member.bestNightScore)} + + + Attendance rate + {`${formatNumber(member.attendanceRate)}%`} + + + Average when present + {formatNumber(member.averageScoreWhenPresent)} + + + + + + + + + Date + Status + Score + {report.section === 'junior' && ( + Uniform + )} + {report.section === 'junior' && ( + Behaviour + )} + + {member.meetings.slice(0, 14).map((meeting, index) => ( + + {formatDate(meeting.date)} + {meeting.attended ? 'Present' : 'Absent'} + + {meeting.attended ? formatNumber(meeting.score) : '-'} + + {report.section === 'junior' && ( + + {meeting.attended ? formatNumber(meeting.uniformScore ?? 0) : '-'} + + )} + {report.section === 'junior' && ( + + {meeting.attended ? formatNumber(meeting.behaviourScore ?? 0) : '-'} + + )} + + ))} + + + {renderPageFooter(report)} + , + ...continuationChunks.map((meetingChunk, index) => ( + + {renderPageHeader(report, `${member.name} Session Detail Continued`)} + + Continued meeting-by-meeting record for this member. + + + + Date + Status + Score + {report.section === 'junior' && ( + Uniform + )} + {report.section === 'junior' && ( + Behaviour + )} + + {meetingChunk.map((meeting, meetingIndex) => ( + + {formatDate(meeting.date)} + {meeting.attended ? 'Present' : 'Absent'} + + {meeting.attended ? formatNumber(meeting.score) : '-'} + + {report.section === 'junior' && ( + + {meeting.attended ? formatNumber(meeting.uniformScore ?? 0) : '-'} + + )} + {report.section === 'junior' && ( + + {meeting.attended ? formatNumber(meeting.behaviourScore ?? 0) : '-'} + + )} + + ))} + + {renderPageFooter(report)} + + )), + ]; + })} + + ); +}; + +export default SessionReportDocument; diff --git a/docs/06-data-and-services.md b/docs/06-data-and-services.md index 20da0bf..c9193c8 100644 --- a/docs/06-data-and-services.md +++ b/docs/06-data-and-services.md @@ -7,6 +7,7 @@ This document describes how the app talks to Supabase. - `services/supabaseClient.ts`: shared Supabase client - `services/supabaseAuth.ts`: sign-in, sign-out, password update, auth subscription - `services/db.ts`: members, marks, profiles, and role-guardrail helpers +- `services/reporting/sessionReport.ts`: pure session-report aggregation for dashboard PDF export - `services/settings.ts`: section settings for the seeded `company` and `junior` rows ## Live Table Mapping @@ -30,4 +31,4 @@ The current app talks to these tables: - The UI-facing `Boy` model is assembled from `members` and `marks`. - Role information is loaded from `profiles`, not from a separate `user_roles` table. - Section settings are updated in place; missing `settings` rows are a bootstrap problem, not a normal runtime case. -- The active UI is limited to member management, marks entry, dashboard reporting, and section settings. +- The active UI is limited to member management, marks entry, dashboard reporting, session PDF export, and section settings. diff --git a/docs/user-guide.md b/docs/user-guide.md index d85a60d..52b6070 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -24,7 +24,7 @@ flowchart LR | `Select a Section` | Choose either the Company or Junior section. | | `Home` | View, add, edit, and delete members in the active section. | | `Weekly Marks` | Record attendance and marks for the selected meeting date. | -| `Dashboard` | Review summary charts and attendance trends. | +| `Dashboard` | Review summary charts, attendance trends, and generate the master end-of-session PDF. | | `Section Settings` | Update section-level configuration such as the weekly meeting day. | | `Account Settings` | Change your personal password. | @@ -32,9 +32,9 @@ flowchart LR 1. Sign in with your Supabase account. 2. Choose the section you are responsible for. -3. Use `Home` to manage the member roster. -4. Use `Weekly Marks` for weekly attendance and scoring. -5. Use `Dashboard` when you want a quick summary of section performance. +3. Manage the member roster from `Home`. +4. Record weekly attendance and scoring in `Weekly Marks`. +5. View section performance or download the session PDF from `Dashboard`. ## Member Management @@ -52,6 +52,9 @@ flowchart LR ## Dashboard - Use the dashboard for a quick performance summary. +- Use `Generate Master PDF` to export one branded end-of-session report for the active section. +- Choose the session date range before downloading the report. +- The PDF includes section summary pages plus a detail page for each member with recorded marks in the selected range. - It is reporting only. It does not change roster data. ## Settings @@ -62,5 +65,4 @@ flowchart LR ## Support Notes - Accounts are provisioned manually by an administrator. -- If you need a PDF, render or print this Markdown file to PDF from your browser or editor. - Keep this guide with the branch or release bundle you hand to new users. diff --git a/package-lock.json b/package-lock.json index f19f422..18aea1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "boys'-brigade-manager", "version": "0.0.0", "dependencies": { + "@react-pdf/renderer": "^4.3.2", "@supabase/supabase-js": "^2.48.0", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -272,6 +273,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -990,6 +1000,180 @@ "node": ">=18" } }, + "node_modules/@react-pdf/fns": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", + "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==", + "license": "MIT" + }, + "node_modules/@react-pdf/font": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz", + "integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==", + "license": "MIT", + "dependencies": { + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/types": "^2.9.2", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz", + "integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==", + "license": "MIT", + "dependencies": { + "@react-pdf/png-js": "^3.0.0", + "jay-peg": "^1.1.1" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz", + "integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/image": "^3.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "emoji-regex-xs": "^1.0.0", + "queue": "^6.0.1", + "yoga-layout": "^3.2.1" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz", + "integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.1", + "linebreak": "^1.1.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz", + "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz", + "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz", + "integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/@react-pdf/render": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz", + "integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz", + "integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/font": "^4.0.4", + "@react-pdf/layout": "^4.4.2", + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/reconciler": "^2.0.0", + "@react-pdf/render": "^4.3.2", + "@react-pdf/types": "^2.9.2", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz", + "integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/types": "^2.9.2", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz", + "integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz", + "integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==", + "license": "MIT", + "dependencies": { + "@react-pdf/font": "^4.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -1473,6 +1657,15 @@ "node": ">=20.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1732,6 +1925,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1873,6 +2072,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.8.28", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", @@ -1883,6 +2102,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1919,6 +2147,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", @@ -2032,6 +2278,15 @@ "node": ">= 6" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2049,9 +2304,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2084,6 +2348,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2115,6 +2385,12 @@ } } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2150,6 +2426,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2219,6 +2501,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2229,6 +2520,12 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2300,6 +2597,23 @@ "node": ">=8" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2424,6 +2738,21 @@ "node": ">= 0.4" } }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2431,6 +2760,12 @@ "dev": true, "license": "MIT" }, + "node_modules/hyphen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", + "integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==", + "license": "ISC" + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -2440,6 +2775,18 @@ "node": ">=20.0.0" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2512,6 +2859,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2574,6 +2927,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2588,7 +2950,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -2630,6 +2991,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -2637,6 +3017,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2698,6 +3090,12 @@ "node": ">=10" } }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2826,11 +3224,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2864,6 +3270,18 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3159,9 +3577,28 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3204,6 +3641,12 @@ "react": "^19.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3250,6 +3693,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3271,6 +3723,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3351,6 +3809,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3410,6 +3888,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3434,6 +3921,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3541,6 +4037,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -3602,6 +4104,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3692,6 +4200,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -3727,7 +4261,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -3805,6 +4338,20 @@ } } }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/vitest": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", @@ -3966,6 +4513,12 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 084015d..9c9736b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@react-pdf/renderer": "^4.3.2", "@supabase/supabase-js": "^2.48.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/services/reporting/sessionReport.test.ts b/services/reporting/sessionReport.test.ts new file mode 100644 index 0000000..9d5cb9d --- /dev/null +++ b/services/reporting/sessionReport.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSessionReportData, getSectionDateRange } from './sessionReport'; +import type { Boy } from '../../types'; + +const companyBoys: Boy[] = [ + { + id: 'member-1', + name: 'Aaron', + squad: 1, + year: 12, + isSquadLeader: true, + marks: [ + { date: '2026-01-09', score: 8 }, + { date: '2026-01-16', score: -1 }, + { date: '2026-02-06', score: 9.5 }, + ], + }, + { + id: 'member-2', + name: 'Ben', + squad: 1, + year: 11, + isSquadLeader: false, + marks: [ + { date: '2026-01-09', score: 7 }, + { date: '2026-01-16', score: 6 }, + { date: '2026-02-06', score: 7.5 }, + ], + }, + { + id: 'member-3', + name: 'Callum', + squad: 2, + year: 10, + isSquadLeader: false, + marks: [ + { date: '2026-01-09', score: -1 }, + { date: '2026-01-16', score: 8 }, + ], + }, +]; + +describe('sessionReport', () => { + it('derives the section date range from all member marks', () => { + expect(getSectionDateRange(companyBoys)).toEqual({ + startDate: '2026-01-09', + endDate: '2026-02-06', + }); + }); + + it('returns null when there are no marks to report', () => { + expect(getSectionDateRange([])).toBeNull(); + expect( + getSectionDateRange([ + { + id: 'member-empty', + name: 'No Marks', + squad: 1, + year: 10, + isSquadLeader: false, + marks: [], + }, + ]), + ).toBeNull(); + }); + + it('builds section, squad, meeting, and top-member summaries', () => { + const report = buildSessionReportData({ + boys: companyBoys, + section: 'company', + range: { + startDate: '2026-01-01', + endDate: '2026-02-28', + }, + now: new Date('2026-03-22T10:00:00Z'), + }); + + expect(report.headlineStats).toEqual({ + memberCount: 3, + meetingCount: 3, + attendanceCount: 6, + absenceCount: 2, + attendanceRate: 75, + totalMarks: 46, + averageMarksWhenPresent: 7.67, + }); + expect(report.topMembers[0]).toMatchObject({ + name: 'Ben', + totalMarks: 20.5, + }); + expect(report.squads).toHaveLength(2); + expect(report.squads[0]).toMatchObject({ + squad: '1', + memberCount: 2, + attendanceRate: 83.33, + totalMarks: 38, + }); + expect(report.meetings[1]).toEqual({ + date: '2026-01-16', + attendanceCount: 2, + absenceCount: 1, + attendanceRate: 66.67, + totalMarks: 14, + averageMarksWhenPresent: 7, + }); + expect(report.months).toEqual([ + { + month: '2026-01', + attendanceCount: 4, + absenceCount: 2, + attendanceRate: 66.67, + totalMarks: 29, + }, + { + month: '2026-02', + attendanceCount: 2, + absenceCount: 0, + attendanceRate: 100, + totalMarks: 17, + }, + ]); + }); + + it('keeps junior uniform and behaviour totals in member summaries', () => { + const report = buildSessionReportData({ + boys: [ + { + id: 'junior-1', + name: 'Daniel', + squad: 3, + year: 'P7', + isSquadLeader: false, + marks: [ + { date: '2026-03-01', score: 8, uniformScore: 5, behaviourScore: 3 }, + { date: '2026-03-08', score: 7, uniformScore: 4, behaviourScore: 3 }, + ], + }, + ], + section: 'junior', + range: { + startDate: '2026-03-01', + endDate: '2026-03-31', + }, + }); + + expect(report.members[0]).toMatchObject({ + totalMarks: 15, + uniformTotal: 9, + behaviourTotal: 6, + attendanceRate: 100, + }); + }); +}); diff --git a/services/reporting/sessionReport.ts b/services/reporting/sessionReport.ts new file mode 100644 index 0000000..3a99b70 --- /dev/null +++ b/services/reporting/sessionReport.ts @@ -0,0 +1,242 @@ +import type { Boy, Mark, Section } from '../../types'; +import type { + BuildSessionReportInput, + MeetingSummary, + MemberMeetingRecord, + MemberSessionSummary, + MonthlySummary, + ReportDateRange, + SessionHeadlineStats, + SessionReportData, + SquadSessionSummary, +} from '../../types/reporting'; + +const SECTION_LABELS: Record = { + company: 'Company Section', + junior: 'Junior Section', +}; + +const round = (value: number) => Math.round(value * 100) / 100; + +const isMarkWithinRange = (mark: Mark, range: ReportDateRange) => + mark.date >= range.startDate && mark.date <= range.endDate; + +const sortByDateAsc = (items: T[]) => + [...items].sort((left, right) => left.date.localeCompare(right.date)); + +const sortMembersForReport = (members: MemberSessionSummary[]) => + [...members].sort((left, right) => { + if (right.totalMarks !== left.totalMarks) { + return right.totalMarks - left.totalMarks; + } + + if (right.attendanceRate !== left.attendanceRate) { + return right.attendanceRate - left.attendanceRate; + } + + return left.name.localeCompare(right.name); + }); + +const buildMemberMeetingRecords = (marks: Mark[]): MemberMeetingRecord[] => + sortByDateAsc(marks).map((mark) => ({ + date: mark.date, + attended: mark.score >= 0, + score: mark.score >= 0 ? mark.score : 0, + uniformScore: mark.score >= 0 ? mark.uniformScore : undefined, + behaviourScore: mark.score >= 0 ? mark.behaviourScore : undefined, + })); + +const buildMemberSummary = (boy: Boy, section: Section, range: ReportDateRange): MemberSessionSummary => { + const sessionMarks = sortByDateAsc(boy.marks.filter((mark) => isMarkWithinRange(mark, range))); + const meetings = buildMemberMeetingRecords(sessionMarks); + const presentMarks = sessionMarks.filter((mark) => mark.score >= 0); + const absentMarks = sessionMarks.filter((mark) => mark.score < 0); + const totalMarks = round(presentMarks.reduce((sum, mark) => sum + mark.score, 0)); + const attendanceCount = presentMarks.length; + const absenceCount = absentMarks.length; + const attendanceRate = sessionMarks.length > 0 ? round((attendanceCount / sessionMarks.length) * 100) : 0; + const averageScoreWhenPresent = attendanceCount > 0 ? round(totalMarks / attendanceCount) : 0; + const bestNight = [...presentMarks].sort((left, right) => right.score - left.score || left.date.localeCompare(right.date))[0]; + const uniformTotal = section === 'junior' + ? round(presentMarks.reduce((sum, mark) => sum + (mark.uniformScore ?? 0), 0)) + : undefined; + const behaviourTotal = section === 'junior' + ? round(presentMarks.reduce((sum, mark) => sum + (mark.behaviourScore ?? 0), 0)) + : undefined; + + return { + id: boy.id ?? boy.name, + name: boy.name, + squad: boy.squad, + year: boy.year, + isSquadLeader: boy.isSquadLeader ?? false, + attendanceCount, + absenceCount, + attendanceRate, + totalMarks, + averageScoreWhenPresent, + bestNightScore: bestNight?.score ?? 0, + bestNightDate: bestNight?.date, + lastAttendedDate: presentMarks.at(-1)?.date, + uniformTotal, + behaviourTotal, + meetings, + }; +}; + +const buildHeadlineStats = (members: MemberSessionSummary[], meetingDates: string[]): SessionHeadlineStats => { + const attendanceCount = members.reduce((sum, member) => sum + member.attendanceCount, 0); + const absenceCount = members.reduce((sum, member) => sum + member.absenceCount, 0); + const totalMarks = round(members.reduce((sum, member) => sum + member.totalMarks, 0)); + const totalRecords = attendanceCount + absenceCount; + + return { + memberCount: members.length, + meetingCount: meetingDates.length, + attendanceCount, + absenceCount, + attendanceRate: totalRecords > 0 ? round((attendanceCount / totalRecords) * 100) : 0, + totalMarks, + averageMarksWhenPresent: attendanceCount > 0 ? round(totalMarks / attendanceCount) : 0, + }; +}; + +const buildMeetingSummaries = (members: MemberSessionSummary[], meetingDates: string[]): MeetingSummary[] => + meetingDates.map((date) => { + const records = members + .map((member) => member.meetings.find((meeting) => meeting.date === date)) + .filter((meeting): meeting is MemberMeetingRecord => !!meeting); + const attendanceCount = records.filter((record) => record.attended).length; + const absenceCount = records.length - attendanceCount; + const totalMarks = round(records.reduce((sum, record) => sum + (record.attended ? record.score : 0), 0)); + + return { + date, + attendanceCount, + absenceCount, + attendanceRate: records.length > 0 ? round((attendanceCount / records.length) * 100) : 0, + totalMarks, + averageMarksWhenPresent: attendanceCount > 0 ? round(totalMarks / attendanceCount) : 0, + }; + }); + +const buildMonthlySummaries = (meetings: MeetingSummary[]): MonthlySummary[] => { + const months = new Map(); + + meetings.forEach((meeting) => { + const month = meeting.date.slice(0, 7); + const current = months.get(month) ?? { + month, + attendanceCount: 0, + absenceCount: 0, + attendanceRate: 0, + totalMarks: 0, + }; + + current.attendanceCount += meeting.attendanceCount; + current.absenceCount += meeting.absenceCount; + current.totalMarks = round(current.totalMarks + meeting.totalMarks); + months.set(month, current); + }); + + return [...months.values()] + .map((month) => { + const totalRecords = month.attendanceCount + month.absenceCount; + return { + ...month, + attendanceRate: totalRecords > 0 ? round((month.attendanceCount / totalRecords) * 100) : 0, + }; + }) + .sort((left, right) => left.month.localeCompare(right.month)); +}; + +const buildSquadSummaries = (members: MemberSessionSummary[]): SquadSessionSummary[] => { + const grouped = new Map(); + + members.forEach((member) => { + const key = String(member.squad); + const current = grouped.get(key) ?? []; + current.push(member); + grouped.set(key, current); + }); + + return [...grouped.entries()] + .sort((left, right) => Number(left[0]) - Number(right[0])) + .map(([squad, squadMembers]) => { + const attendanceCount = squadMembers.reduce((sum, member) => sum + member.attendanceCount, 0); + const absenceCount = squadMembers.reduce((sum, member) => sum + member.absenceCount, 0); + const totalMarks = round(squadMembers.reduce((sum, member) => sum + member.totalMarks, 0)); + const topMember = sortMembersForReport(squadMembers)[0]; + const totalRecords = attendanceCount + absenceCount; + + return { + squad, + memberCount: squadMembers.length, + attendanceCount, + absenceCount, + attendanceRate: totalRecords > 0 ? round((attendanceCount / totalRecords) * 100) : 0, + totalMarks, + averageScoreWhenPresent: attendanceCount > 0 ? round(totalMarks / attendanceCount) : 0, + topMember: topMember + ? { + id: topMember.id, + name: topMember.name, + totalMarks: topMember.totalMarks, + attendanceRate: topMember.attendanceRate, + } + : undefined, + members: sortMembersForReport(squadMembers), + }; + }); +}; + +export const getSectionDateRange = (boys: Boy[]): ReportDateRange | null => { + const dates = boys.flatMap((boy) => boy.marks.map((mark) => mark.date)).sort(); + + if (dates.length === 0) { + return null; + } + + return { + startDate: dates[0], + endDate: dates[dates.length - 1], + }; +}; + +export const buildSessionReportData = ({ + boys, + section, + range, + now = new Date(), +}: BuildSessionReportInput): SessionReportData => { + const members = sortMembersForReport(boys.map((boy) => buildMemberSummary(boy, section, range))); + const meetingDates = Array.from( + new Set( + members.flatMap((member) => member.meetings.map((meeting) => meeting.date)), + ), + ).sort((left, right) => left.localeCompare(right)); + const meetings = buildMeetingSummaries(members, meetingDates); + const months = buildMonthlySummaries(meetings); + const squads = buildSquadSummaries(members); + + return { + section, + sectionLabel: SECTION_LABELS[section], + generatedAt: now.toISOString(), + range, + meetingDates, + headlineStats: buildHeadlineStats(members, meetingDates), + topMembers: members.slice(0, 5).map((member) => ({ + id: member.id, + name: member.name, + squad: member.squad, + totalMarks: member.totalMarks, + attendanceRate: member.attendanceRate, + averageScoreWhenPresent: member.averageScoreWhenPresent, + })), + members, + squads, + meetings, + months, + }; +}; diff --git a/types/reporting.ts b/types/reporting.ts new file mode 100644 index 0000000..125870b --- /dev/null +++ b/types/reporting.ts @@ -0,0 +1,95 @@ +import type { Boy, Mark, Section } from '../types'; + +export interface ReportDateRange { + startDate: string; + endDate: string; +} + +export interface MemberMeetingRecord { + date: string; + attended: boolean; + score: number; + uniformScore?: number; + behaviourScore?: number; +} + +export interface MemberSessionSummary { + id: string; + name: string; + squad: Boy['squad']; + year: Boy['year']; + isSquadLeader: boolean; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + totalMarks: number; + averageScoreWhenPresent: number; + bestNightScore: number; + bestNightDate?: string; + lastAttendedDate?: string; + uniformTotal?: number; + behaviourTotal?: number; + meetings: MemberMeetingRecord[]; +} + +export interface SquadSessionSummary { + squad: string; + memberCount: number; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + totalMarks: number; + averageScoreWhenPresent: number; + topMember?: Pick; + members: MemberSessionSummary[]; +} + +export interface MeetingSummary { + date: string; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + totalMarks: number; + averageMarksWhenPresent: number; +} + +export interface MonthlySummary { + month: string; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + totalMarks: number; +} + +export interface SessionHeadlineStats { + memberCount: number; + meetingCount: number; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + totalMarks: number; + averageMarksWhenPresent: number; +} + +export interface SessionReportData { + section: Section; + sectionLabel: string; + generatedAt: string; + range: ReportDateRange; + meetingDates: string[]; + headlineStats: SessionHeadlineStats; + topMembers: Pick[]; + members: MemberSessionSummary[]; + squads: SquadSessionSummary[]; + meetings: MeetingSummary[]; + months: MonthlySummary[]; +} + +export interface BuildSessionReportInput { + boys: Boy[]; + section: Section; + range: ReportDateRange; + now?: Date; +} + +export type SessionMark = Mark;