Skip to content

Commit 16f2b18

Browse files
dveltonCopilot
andcommitted
Add intermission period report export
Adds an 'Export Period X Report' button that appears between periods after ending each period. The report includes period stats, goal log, shot map, zone breakdown, and running game totals. Exports as PDF using the existing html2canvas + jsPDF pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 59c77fc commit 16f2b18

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

src/components/GamePlay.jsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import HockeyRink from './HockeyRink';
33
import ShotPopup from './ShotPopup';
44
import ShotOverlay from './ShotOverlay';
55
import PeriodControls from './PeriodControls';
6+
import PeriodReport from './PeriodReport';
7+
import { exportToPdf } from '../utils/pdfExport';
68

79
export default function GamePlay({ gameState, onEndGame }) {
810
const {
@@ -20,10 +22,36 @@ export default function GamePlay({ gameState, onEndGame }) {
2022
const [pendingShot, setPendingShot] = useState(null);
2123
const [fadeShot, setFadeShot] = useState(null);
2224
const [showOverlay, setShowOverlay] = useState(false);
25+
const [exportingReport, setExportingReport] = useState(false);
2326

2427
// Rink flips for period 2
2528
const flipped = currentPeriod === 2;
2629

30+
// Most recently completed period (for intermission report export)
31+
const lastCompletedPeriod = (() => {
32+
const completed = game.periods.filter((p) => p.endTime);
33+
return completed.length > 0 ? completed[completed.length - 1] : null;
34+
})();
35+
36+
const handleExportPeriodReport = async () => {
37+
if (!lastCompletedPeriod) return;
38+
setExportingReport(true);
39+
try {
40+
const pNum = lastCompletedPeriod.number;
41+
const label = pNum === 'OT' ? 'OT' : `P${pNum}`;
42+
const filename = `${game.homeTeam}-vs-${game.awayTeam}-${label}-${game.date.slice(0, 10)}.pdf`;
43+
await exportToPdf('period-report', filename);
44+
} catch (err) {
45+
console.error('Period report export failed:', err);
46+
}
47+
setExportingReport(false);
48+
};
49+
50+
const exportButtonLabel = (n) => {
51+
if (n === 'OT') return 'Export Overtime Report';
52+
return `Export Period ${n} Report`;
53+
};
54+
2755
const handleTapRink = useCallback(
2856
(x, y) => {
2957
if (currentPeriod === null) return;
@@ -188,6 +216,20 @@ export default function GamePlay({ gameState, onEndGame }) {
188216
</div>
189217
)}
190218

219+
{/* Export period report */}
220+
{currentPeriod === null && lastCompletedPeriod && (
221+
<button
222+
onClick={handleExportPeriodReport}
223+
disabled={exportingReport}
224+
className="w-full py-2.5 flex items-center justify-center gap-2 bg-slate-100 text-slate-700 font-semibold rounded-xl active:bg-slate-200 transition-colors disabled:opacity-50"
225+
>
226+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
227+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
228+
</svg>
229+
{exportingReport ? 'Exporting...' : exportButtonLabel(lastCompletedPeriod.number)}
230+
</button>
231+
)}
232+
191233
<PeriodControls
192234
game={game}
193235
currentPeriod={currentPeriod}
@@ -219,6 +261,17 @@ export default function GamePlay({ gameState, onEndGame }) {
219261
onClose={() => setShowOverlay(false)}
220262
/>
221263
)}
264+
265+
{/* Off-screen period report for PDF export */}
266+
{currentPeriod === null && lastCompletedPeriod && (
267+
<div style={{ position: 'fixed', left: '-9999px', top: 0 }} aria-hidden="true">
268+
<PeriodReport
269+
id="period-report"
270+
game={game}
271+
periodNumber={lastCompletedPeriod.number}
272+
/>
273+
</div>
274+
)}
222275
</div>
223276
);
224277
}

src/components/PeriodReport.jsx

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import MiniShotMap from './MiniShotMap';
2+
3+
export default function PeriodReport({ game, periodNumber, id }) {
4+
const period = game.periods.find((p) => p.number === periodNumber);
5+
if (!period) return null;
6+
7+
const shots = period.shots;
8+
const homeShots = shots.filter((s) => s.team === 'home');
9+
const awayShots = shots.filter((s) => s.team === 'away');
10+
const homeGoals = homeShots.filter((s) => s.result === 'goal');
11+
const awayGoals = awayShots.filter((s) => s.result === 'goal');
12+
const homeBlocked = homeShots.filter((s) => s.result === 'blocked').length;
13+
const awayBlocked = awayShots.filter((s) => s.result === 'blocked').length;
14+
15+
const pct = (num, denom) => {
16+
if (denom === 0) return '-';
17+
return ((num / denom) * 100).toFixed(0) + '%';
18+
};
19+
20+
const periodLabelFull = (n) => {
21+
if (n === 'OT') return 'Overtime';
22+
if (n === 1) return '1st Period';
23+
if (n === 2) return '2nd Period';
24+
if (n === 3) return '3rd Period';
25+
return `Period ${n}`;
26+
};
27+
28+
const formatElapsed = (shotTimestamp) => {
29+
if (!period.startTime || !shotTimestamp) return '';
30+
const elapsed = new Date(shotTimestamp) - new Date(period.startTime);
31+
if (elapsed < 0) return '';
32+
const totalSeconds = Math.floor(elapsed / 1000);
33+
const mins = Math.floor(totalSeconds / 60);
34+
const secs = totalSeconds % 60;
35+
return `${mins}:${secs.toString().padStart(2, '0')}`;
36+
};
37+
38+
const formatDate = (iso) => {
39+
const d = new Date(iso);
40+
return d.toLocaleDateString(undefined, {
41+
month: 'long',
42+
day: 'numeric',
43+
year: 'numeric',
44+
});
45+
};
46+
47+
const formatDuration = () => {
48+
if (!period.startTime || !period.endTime) return '';
49+
const ms = new Date(period.endTime) - new Date(period.startTime);
50+
const totalMins = Math.floor(ms / 60000);
51+
const secs = Math.floor((ms % 60000) / 1000);
52+
return `${totalMins}:${secs.toString().padStart(2, '0')}`;
53+
};
54+
55+
// Zone classification (same logic as GameSummary)
56+
const classifyZone = (x, team) => {
57+
const homeAttacksRight = periodNumber !== 2;
58+
const isLeft = x < 0.375;
59+
const isRight = x > 0.625;
60+
if (team === 'home') {
61+
if (homeAttacksRight) {
62+
if (isRight) return 'offensive';
63+
if (isLeft) return 'defensive';
64+
} else {
65+
if (isLeft) return 'offensive';
66+
if (isRight) return 'defensive';
67+
}
68+
} else if (team === 'away') {
69+
if (homeAttacksRight) {
70+
if (isLeft) return 'offensive';
71+
if (isRight) return 'defensive';
72+
} else {
73+
if (isRight) return 'offensive';
74+
if (isLeft) return 'defensive';
75+
}
76+
}
77+
return 'neutral';
78+
};
79+
80+
const zoneCount = (teamShots, team, zone) =>
81+
teamShots.filter((s) => classifyZone(s.x, team) === zone).length;
82+
83+
const homeOffensive = zoneCount(homeShots, 'home', 'offensive');
84+
const homeNeutral = zoneCount(homeShots, 'home', 'neutral');
85+
const homeDefensive = zoneCount(homeShots, 'home', 'defensive');
86+
const awayOffensive = zoneCount(awayShots, 'away', 'offensive');
87+
const awayNeutral = zoneCount(awayShots, 'away', 'neutral');
88+
const awayDefensive = zoneCount(awayShots, 'away', 'defensive');
89+
90+
// Running game totals through this period
91+
const periodsThrough = game.periods.filter((p) => {
92+
if (p.number === periodNumber) return true;
93+
if (typeof p.number === 'number' && typeof periodNumber === 'number') return p.number <= periodNumber;
94+
if (typeof p.number === 'number') return true;
95+
return false;
96+
});
97+
const cumulativeShots = periodsThrough.flatMap((p) => p.shots);
98+
const cumulativeHome = cumulativeShots.filter((s) => s.team === 'home');
99+
const cumulativeAway = cumulativeShots.filter((s) => s.team === 'away');
100+
const cumulativeHomeGoals = cumulativeHome.filter((s) => s.result === 'goal').length;
101+
const cumulativeAwayGoals = cumulativeAway.filter((s) => s.result === 'goal').length;
102+
103+
// All goals sorted chronologically
104+
const allGoals = [
105+
...homeGoals.map((s) => ({ ...s, teamName: game.homeTeam })),
106+
...awayGoals.map((s) => ({ ...s, teamName: game.awayTeam })),
107+
].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
108+
109+
const duration = formatDuration();
110+
111+
const ZoneBar = ({ label, count, total, color }) => {
112+
const width = total > 0 ? (count / total) * 100 : 0;
113+
return (
114+
<div className="flex items-center gap-1.5 text-xs">
115+
<span className="w-8 text-right text-slate-500 shrink-0">{label}</span>
116+
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
117+
<div className="h-full rounded-full" style={{ width: `${width}%`, backgroundColor: color }} />
118+
</div>
119+
<span className="w-12 text-right text-slate-600">{count} ({pct(count, total)})</span>
120+
</div>
121+
);
122+
};
123+
124+
return (
125+
<div id={id} className="bg-white p-5" style={{ width: '480px', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
126+
{/* Header */}
127+
<div className="text-center mb-4 pb-3 border-b border-slate-200">
128+
<h1 className="text-lg font-bold text-slate-800">
129+
{game.homeTeam} vs {game.awayTeam}
130+
</h1>
131+
<p className="text-xs text-slate-400 mt-0.5">{formatDate(game.date)}</p>
132+
<div className="mt-2">
133+
<span className="text-sm font-semibold text-slate-700 bg-slate-100 px-3 py-1 rounded-full">
134+
{periodLabelFull(periodNumber)} Report
135+
</span>
136+
{duration && (
137+
<span className="text-xs text-slate-400 ml-2">({duration})</span>
138+
)}
139+
</div>
140+
</div>
141+
142+
{/* Period stats table */}
143+
<div className="mb-4">
144+
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
145+
Period Stats
146+
</h2>
147+
<table className="w-full text-sm">
148+
<thead>
149+
<tr className="border-b border-slate-200">
150+
<th className="text-left py-1.5 pr-3 font-semibold text-slate-600">Team</th>
151+
<th className="text-center py-1.5 px-2 font-semibold text-slate-600">Shots</th>
152+
<th className="text-center py-1.5 px-2 font-semibold text-slate-600">Goals</th>
153+
<th className="text-center py-1.5 px-2 font-semibold text-slate-600">Blocked</th>
154+
<th className="text-center py-1.5 px-2 font-semibold text-slate-600">Sh%</th>
155+
</tr>
156+
</thead>
157+
<tbody>
158+
<tr className="border-b border-slate-100">
159+
<td className="py-1.5 pr-3 font-medium text-blue-600">{game.homeTeam}</td>
160+
<td className="text-center py-1.5 px-2">{homeShots.length}</td>
161+
<td className="text-center py-1.5 px-2 font-semibold">{homeGoals.length}</td>
162+
<td className="text-center py-1.5 px-2">{homeBlocked}</td>
163+
<td className="text-center py-1.5 px-2">{pct(homeGoals.length, homeShots.length)}</td>
164+
</tr>
165+
<tr>
166+
<td className="py-1.5 pr-3 font-medium text-red-500">{game.awayTeam}</td>
167+
<td className="text-center py-1.5 px-2">{awayShots.length}</td>
168+
<td className="text-center py-1.5 px-2 font-semibold">{awayGoals.length}</td>
169+
<td className="text-center py-1.5 px-2">{awayBlocked}</td>
170+
<td className="text-center py-1.5 px-2">{pct(awayGoals.length, awayShots.length)}</td>
171+
</tr>
172+
</tbody>
173+
</table>
174+
</div>
175+
176+
{/* Goal log */}
177+
{allGoals.length > 0 && (
178+
<div className="mb-4">
179+
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
180+
Goals
181+
</h2>
182+
<div className="space-y-1">
183+
{allGoals.map((g, i) => (
184+
<div key={g.id || i} className="flex items-center gap-2 text-sm">
185+
<span className={`w-2 h-2 rounded-full shrink-0 ${g.team === 'home' ? 'bg-blue-500' : 'bg-red-500'}`} />
186+
<span className="font-medium text-slate-700">{g.teamName}</span>
187+
{g.playerNumber && <span className="text-slate-500">#{g.playerNumber}</span>}
188+
{formatElapsed(g.timestamp) && (
189+
<span className="text-slate-400 text-xs ml-auto">{formatElapsed(g.timestamp)}</span>
190+
)}
191+
</div>
192+
))}
193+
</div>
194+
</div>
195+
)}
196+
197+
{/* Shot map */}
198+
{shots.length > 0 && (
199+
<div className="mb-4">
200+
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
201+
Shot Map
202+
</h2>
203+
<MiniShotMap shots={shots} homeTeam={game.homeTeam} awayTeam={game.awayTeam} />
204+
<div className="flex justify-center gap-4 mt-1 text-xs">
205+
<span className="flex items-center gap-1">
206+
<span className="w-2 h-2 rounded-full bg-blue-500 inline-block" />
207+
<span className="text-slate-500">{game.homeTeam}</span>
208+
</span>
209+
<span className="flex items-center gap-1">
210+
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" />
211+
<span className="text-slate-500">{game.awayTeam}</span>
212+
</span>
213+
<span className="text-slate-400">● goal / ○ blocked</span>
214+
</div>
215+
</div>
216+
)}
217+
218+
{/* Zone breakdown */}
219+
{(homeShots.length > 0 || awayShots.length > 0) && (
220+
<div className="mb-4">
221+
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
222+
Shots by Zone
223+
</h2>
224+
<div className="grid grid-cols-2 gap-4">
225+
{homeShots.length > 0 && (
226+
<div>
227+
<div className="text-xs font-semibold text-blue-600 mb-1">{game.homeTeam}</div>
228+
<div className="space-y-1">
229+
<ZoneBar label="Off" count={homeOffensive} total={homeShots.length} color="#3b82f6" />
230+
<ZoneBar label="Neut" count={homeNeutral} total={homeShots.length} color="#3b82f6" />
231+
<ZoneBar label="Def" count={homeDefensive} total={homeShots.length} color="#3b82f6" />
232+
</div>
233+
</div>
234+
)}
235+
{awayShots.length > 0 && (
236+
<div>
237+
<div className="text-xs font-semibold text-red-500 mb-1">{game.awayTeam}</div>
238+
<div className="space-y-1">
239+
<ZoneBar label="Off" count={awayOffensive} total={awayShots.length} color="#ef4444" />
240+
<ZoneBar label="Neut" count={awayNeutral} total={awayShots.length} color="#ef4444" />
241+
<ZoneBar label="Def" count={awayDefensive} total={awayShots.length} color="#ef4444" />
242+
</div>
243+
</div>
244+
)}
245+
</div>
246+
</div>
247+
)}
248+
249+
{/* Running game totals — only shown after 2+ periods */}
250+
{periodsThrough.length > 1 && (
251+
<div className="pt-3 border-t border-slate-200">
252+
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
253+
Game Totals Through {periodLabelFull(periodNumber)}
254+
</h2>
255+
<div className="grid grid-cols-2 gap-3 text-sm">
256+
<div className="bg-blue-50 rounded-lg p-2.5 text-center">
257+
<div className="font-bold text-blue-600">
258+
{cumulativeHome.length} shots / {cumulativeHomeGoals}G
259+
</div>
260+
<div className="text-xs text-slate-500">{game.homeTeam}</div>
261+
</div>
262+
<div className="bg-red-50 rounded-lg p-2.5 text-center">
263+
<div className="font-bold text-red-500">
264+
{cumulativeAway.length} shots / {cumulativeAwayGoals}G
265+
</div>
266+
<div className="text-xs text-slate-500">{game.awayTeam}</div>
267+
</div>
268+
</div>
269+
</div>
270+
)}
271+
</div>
272+
);
273+
}

0 commit comments

Comments
 (0)