fix(web): paginate readable sleep PDF treatments and remarks

This commit is contained in:
fy59 2026-10-01 21:42:00 +02:00
parent e7439c5177
commit 3758875dbb
4 changed files with 312 additions and 127 deletions

View file

@ -2,6 +2,14 @@
## Unreleased — 0.1.7 ## Unreleased — 0.1.7
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
separate medication intakes, accents, doses, units, and quantities visible.
Row heights and page count now follow content; exceptional entries continue
on clearly dated pages. The former truncated observations duplicate is gone.
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
Sleep, sport, database, and synchronization data are unchanged.
- Added a read-only completed-session overlay to the Web Sleep agenda and its - Added a read-only completed-session overlay to the Web Sleep agenda and its
local vector PDF preview/export. Factual session start and end times share local vector PDF preview/export. Factual session start and end times share
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a the Sleep row's 18:00 axis in both renderers; blue outlined bands and a

View file

@ -236,7 +236,12 @@ Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
intersect `[start,end)` and are clipped only for display; missing or invalid intersect `[start,end)` and are clipped only for display; missing or invalid
ends are point markers without invented duration. The Web keeps a separate ends are point markers without invented duration. The Web keeps a separate
blue lane and accessible session list. Preview and download wait for the blue lane and accessible session list. Preview and download wait for the
complete sport read and use the same selected sessions. The PDF prints blue complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
width-aware wrapping, content-driven row heights, and dated continuation pages
for exceptional notes. Each selected intake and remark is printed once without
a fixed character limit. The shorter 430 pt timeline shares one coordinate
mapping across hour ticks, grid, sleep markers, medication markers, and blue
sport bands. The PDF prints blue
outlined bands and paginated names, factual times, and durations, marking outlined bands and paginated names, factual times, and durations, marking
sessions without a corresponding Sleep row as `sleep not recorded`. Sport sessions without a corresponding Sleep row as `sleep not recorded`. Sport
creates no Sleep event and changes no sleep total, session, schema, or sync creates no Sleep event and changes no sleep total, session, schema, or sync

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary"; import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary";
import { buildSleepDiaryPdf } from "./sleepDiaryPdf"; import { buildSleepDiaryPdf } from "./sleepDiaryPdf";
const day = (value: number) => `2026-09-${String(value).padStart(2, "0")}`; const day = (value: number) => new Date(Date.UTC(2026, 8, value)).toISOString().slice(0, 10);
const timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`; const timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`;
const completeEntry = (index: number): SleepEntry => { const completeEntry = (index: number): SleepEntry => {
@ -97,6 +97,8 @@ const completeEntry = (index: number): SleepEntry => {
}; };
}; };
const compactDateForTest = (date: string) => date.split("-").reverse().join("/");
const snapshot = (count: number): SleepSnapshot => ({ const snapshot = (count: number): SleepSnapshot => ({
api_version: 1, api_version: 1,
entries: Array.from({ length: count }, (_, index) => completeEntry(index)), entries: Array.from({ length: count }, (_, index) => completeEntry(index)),
@ -183,11 +185,11 @@ describe("sleep diary PDF", () => {
it("draws a proportional 45-minute long-awakening interval between sleep ranges", async () => { it("draws a proportional 45-minute long-awakening interval between sleep ranges", async () => {
const source = await buildSleepDiaryPdf(snapshot(1), "fr").text(); const source = await buildSleepDiaryPdf(snapshot(1), "fr").text();
const firstSleep = source.indexOf("0.75 g 216.17 509.00 83.33 18.00 re f"); const firstSleep = source.indexOf("0.75 g 183.58 509.00 71.67 18.00 re f");
const longAwakening = source.indexOf( const longAwakening = source.indexOf(
"0.98 0.70 0.53 rg 299.50 509.00 15.63 18.00 re f", "0.98 0.70 0.53 rg 255.25 509.00 13.44 18.00 re f",
); );
const secondSleep = source.indexOf("0.75 g 315.13 509.00 67.71 18.00 re f"); const secondSleep = source.indexOf("0.75 g 268.69 509.00 58.23 18.00 re f");
expect(firstSleep).toBeGreaterThan(-1); expect(firstSleep).toBeGreaterThan(-1);
expect(longAwakening).toBeGreaterThan(firstSleep); expect(longAwakening).toBeGreaterThan(firstSleep);
expect(secondSleep).toBeGreaterThan(longAwakening); expect(secondSleep).toBeGreaterThan(longAwakening);
@ -225,13 +227,75 @@ describe("sleep diary PDF", () => {
it("paginates fourteen, twenty-one and thirty days", async () => { it("paginates fourteen, twenty-one and thirty days", async () => {
for (const count of [14, 21, 30]) { for (const count of [14, 21, 30]) {
const source = await buildSleepDiaryPdf(snapshot(count), "en").text(); const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe( const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
Math.ceil(count / 9), expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
); expect(pageCount).toBeLessThan(count);
expect(source).toContain("OBSERVATIONS"); expect(source).toContain("OBSERVATIONS");
} }
}); });
it("preserves every selected treatment and all remarks beyond former limits", async () => {
const data = snapshot(7);
data.entries.forEach((entry, index) => {
entry.treatment_and_notes = `Paragraph ${index}: ${"unbrokenword".repeat(20)}\n\n` +
`Second paragraph with accents: éèê œ, dose 2,5 mg ×3. END-${index}`;
});
const source = await buildSleepDiaryPdf(data, "fr").text();
const content = await textualContent(buildSleepDiaryPdf(data, "fr"));
for (let index = 0; index < 7; index++) {
expect(content).toContain(`END-${index}`);
expect(content).toContain(`Paragraph ${index}:`);
}
expect(content).toContain("œ, dose 2,5");
expect(content).toContain("mg ×3. END-0");
expect(content).toContain("TRAITEMENTS");
expect(content).toContain("REMARQUES");
expect(content).toContain("mg ×2");
expect(source).not.toContain("slice(0, 160)");
});
it("creates dated continuations for a single oversized entry", async () => {
const data = snapshot(1);
data.entries[0].treatment_and_notes = Array.from({ length: 150 }, (_, index) =>
`Unique paragraph ${index} with meaningful complete text.`).join("\n");
const source = await buildSleepDiaryPdf(data, "en").text();
const content = await textualContent(buildSleepDiaryPdf(data, "en"));
expect((source.match(/\/Type \/Page /g) ?? []).length).toBeGreaterThan(1);
expect(content).toContain("continued");
expect(content).toContain("Unique paragraph 0");
expect(content).toContain("Unique paragraph 149");
expect((content.match(/Unique paragraph /g) ?? []).length).toBe(150);
});
it("keeps empty and short notes compact in both languages", async () => {
const data = snapshot(7);
data.entries.forEach((entry) => { entry.intakes = []; entry.treatment_and_notes = ""; });
for (const language of ["fr", "en"] as const) {
const source = await buildSleepDiaryPdf(data, language).text();
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(1);
expect(source).not.toContain("continued");
}
data.entries[0].treatment_and_notes = "Short note.";
const content = await textualContent(buildSleepDiaryPdf(data, "en"));
expect(content).toContain("Short note.");
});
it("paginates 7, 14, 21 and 30 selected nights in FR and EN without empty pages", async () => {
for (const count of [7, 14, 21, 30]) {
for (const language of ["fr", "en"] as const) {
const data = snapshot(count);
const source = await buildSleepDiaryPdf(data, language).text();
const content = await textualContent(buildSleepDiaryPdf(data, language));
const pages = (source.match(/\/Type \/Page /g) ?? []).length;
expect(pages).toBeGreaterThan(0);
expect((content.match(/OBSERVATIONS/g) ?? []).length).toBe(pages);
for (const entry of data.entries)
expect(content).toContain(compactDateForTest(entry.night_start_date));
expect(content).not.toContain("OUTSIDE-SELECTION");
}
}
});
it("prints factual sport bands and a separate legible listing without changing sleep totals", async () => { it("prints factual sport bands and a separate legible listing without changing sleep totals", async () => {
const sessions = [ const sessions = [
{ identity: "se_morning", session_type: "training", label: "Musculation", { identity: "se_morning", session_type: "training", label: "Musculation",

View file

@ -87,6 +87,136 @@ const pdfString = (value: string) =>
const text = (x: number, y: number, size: number, value: string) => const text = (x: number, y: number, size: number, value: string) =>
`0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`; `0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`;
// CONTRACT: all timeline marks, grid ticks, headers, and sport bands use these
// same coordinates. The notes column gets over twice its former 84 pt width.
const layout = {
left: 30,
right: 812,
dateX: 33,
timelineX: 94,
timelineWidth: 430,
qualityX: [528, 562, 596],
notesX: 632,
notesRight: 812,
rowTop: 538,
rowBottom: 137,
minRowHeight: 40,
noteFont: 7.2,
noteLeading: 9.5,
notePadding: 5,
} as const;
type NoteLine = { value: string; separator?: boolean };
type RowFragment = { entry: SleepEntry; lines: NoteLine[]; first: boolean; height: number };
// WHY: PDF Helvetica is not monospaced. Wrapping by character count loses
// content or puts it outside the cell. These are the built-in Helvetica widths
// in thousandths of an em; unsupported Unicode uses the same '?' fallback as
// pdfString. An extra margin absorbs viewer/font rounding differences.
const helveticaAscii = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
const glyphWidth = (character: string) => {
const code = character.codePointAt(0) ?? 63;
if (code >= 32 && code <= 126) return helveticaAscii[code - 32];
const base = character.normalize("NFD").charAt(0);
const baseCode = base.codePointAt(0) ?? 63;
if (baseCode >= 32 && baseCode <= 126) return helveticaAscii[baseCode - 32];
return character === "œ" ? 833 : character === "Œ" ? 1000 : 600;
};
const lineWidth = (value: string) =>
Array.from(value).reduce((sum, character) => sum + glyphWidth(character), 0) * layout.noteFont / 1000;
const maxNoteWidth = layout.notesRight - layout.notesX - layout.notePadding * 2 - 4;
function wrapParagraph(paragraph: string): string[] {
if (paragraph === "") return [""];
const words = paragraph.match(/\S+|\s+/gu) ?? [];
const lines: string[] = [];
let current = "";
for (const word of words) {
if (/^\s+$/u.test(word) && current === "") continue;
if (lineWidth(current + word) <= maxNoteWidth) {
current += word;
continue;
}
if (current.trim()) lines.push(current.trimEnd());
current = "";
if (/^\s+$/u.test(word)) continue;
if (lineWidth(word) <= maxNoteWidth) {
current = word;
continue;
}
for (const character of Array.from(word)) {
if (current && lineWidth(current + character) > maxNoteWidth) {
lines.push(current);
current = "";
}
current += character;
}
}
if (current) lines.push(current.trimEnd());
return lines;
}
function noteLines(entry: SleepEntry, language: Language): NoteLine[] {
const lines: NoteLine[] = [];
if (entry.intakes.length) {
lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" });
for (const intake of entry.intakes) {
const dose = intake.dose_value === null ? "" :
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
for (const value of wrapParagraph(`${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${dose}${quantity}`))
lines.push({ value });
}
}
if (entry.treatment_and_notes) {
lines.push({ value: language === "fr" ? "REMARQUES" : "NOTES", separator: lines.length > 0 });
for (const paragraph of entry.treatment_and_notes.split(/\r\n|\n|\r/u))
for (const value of wrapParagraph(paragraph)) lines.push({ value });
}
return lines;
}
const rowHeight = (lineCount: number) =>
Math.max(layout.minRowHeight, 9 + lineCount * layout.noteLeading + 4);
// INVARIANT: a fragment is emitted only when it fits. Oversized entries are
// split into dated continuations; every note line belongs to exactly one page.
function paginate(entries: readonly SleepEntry[], language: Language): RowFragment[][] {
const pages: RowFragment[][] = [[]];
let remaining = layout.rowTop - layout.rowBottom;
const fresh = () => {
pages.push([]);
remaining = layout.rowTop - layout.rowBottom;
};
for (const entry of entries) {
const lines = noteLines(entry, language);
let offset = 0;
let first = true;
do {
const fullHeight = rowHeight(lines.length - offset);
if (fullHeight > remaining && pages[pages.length - 1].length > 0) fresh();
const maxLines = Math.floor((remaining - 13) / layout.noteLeading);
const count = Math.min(lines.length - offset, Math.max(1, maxLines));
const fragmentLines = lines.slice(offset, offset + count);
const height = rowHeight(fragmentLines.length);
if (height > remaining) throw new Error("PDF row exceeds available page height");
pages[pages.length - 1].push({ entry, lines: fragmentLines, first, height });
remaining -= height;
offset += count;
first = false;
if (offset < lines.length) fresh();
} while (offset < lines.length);
}
return pages;
}
const compactDate = (isoDate: string) => { const compactDate = (isoDate: string) => {
const [year, month, day] = isoDate.split("-"); const [year, month, day] = isoDate.split("-");
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`;
@ -113,25 +243,28 @@ const pointMarker = (type: SleepEventType) => {
function row( function row(
stream: string[], stream: string[],
entry: SleepEntry, fragment: RowFragment,
y: number, y: number,
language: Language, language: Language,
sport: readonly SportSession[], sport: readonly SportSession[],
) { ) {
const { entry, lines, first, height } = fragment;
const projection = projectSleepTimeline(entry); const projection = projectSleepTimeline(entry);
const window = sleepRowWindow(entry); const window = sleepRowWindow(entry);
const timelineX = 112; const timelineX = layout.timelineX;
const width = 500; const width = layout.timelineWidth;
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`); stream.push(`0.7 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
stream.push(text(33, y - 13, 5, compactDate(entry.night_start_date))); stream.push(text(layout.dateX, y - 13, 5, compactDate(entry.night_start_date)));
stream.push( stream.push(
text( text(
33, layout.dateX,
y - 23, y - 23,
5, 5,
`${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`, `${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`,
), ),
); );
if (!first) stream.push(text(layout.dateX, y - 34, 6, language === "fr" ? "suite" : "continued"));
if (first) {
for (let hour = 0; hour <= 24; hour++) { for (let hour = 0; hour <= 24; hour++) {
const x = timelineX + (hour / 24) * width; const x = timelineX + (hour / 24) * width;
stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`); stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`);
@ -184,26 +317,9 @@ function row(
timelinePosition(intake.taken_at, window.start, window.end) * width; timelinePosition(intake.taken_at, window.start, window.end) * width;
stream.push(text(x, y - 36, 6, "M")); stream.push(text(x, y - 36, 6, "M"));
}); });
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-")); stream.push(text(layout.qualityX[0], y - 16, 7, entry.sleep_quality ?? "-"));
stream.push(text(658, y - 16, 7, entry.wake_quality ?? "-")); stream.push(text(layout.qualityX[1], y - 16, 7, entry.wake_quality ?? "-"));
const medicationText = entry.intakes stream.push(text(layout.qualityX[2], y - 16, 7, entry.day_form ?? "-"));
.map(
(intake) =>
`${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${
intake.dose_value === null
? ""
: ` — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}${intake.quantity > 1 ? ` ×${intake.quantity}` : ""}`
}`,
)
.join(" ; ");
const notes = [medicationText, entry.treatment_and_notes]
.filter(Boolean)
.join(" | ");
stream.push(text(696, y - 16, 7, entry.day_form ?? "-"));
stream.push(text(730, y - 7, 4, notes.slice(0, 40)));
stream.push(text(730, y - 16, 4, notes.slice(40, 80)));
stream.push(text(730, y - 25, 4, notes.slice(80, 120)));
stream.push(text(730, y - 34, 4, notes.slice(120, 160)));
projectSportSegments(sport, window).forEach((segment) => { projectSportSegments(sport, window).forEach((segment) => {
const x = timelineX + segment.left * width; const x = timelineX + segment.left * width;
const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width); const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width);
@ -213,6 +329,12 @@ function row(
`${segmentWidth.toFixed(2)} 5 re B Q\n`); `${segmentWidth.toFixed(2)} 5 re B Q\n`);
}); });
} }
lines.forEach((line, index) => {
const baseline = y - 12 - index * layout.noteLeading;
if (line.separator) stream.push(`0.8 G ${layout.notesX + 3} ${(baseline + 5).toFixed(2)} m ${layout.notesRight - 3} ${(baseline + 5).toFixed(2)} l S 0 G\n`);
stream.push(text(layout.notesX + layout.notePadding, baseline, layout.noteFont, line.value));
});
}
function summaryLine(snapshot: SleepSnapshot, language: Language) { function summaryLine(snapshot: SleepSnapshot, language: Language) {
if (language === "fr") if (language === "fr")
@ -236,7 +358,7 @@ function summaryLine(snapshot: SleepSnapshot, language: Language) {
function page( function page(
snapshot: SleepSnapshot, snapshot: SleepSnapshot,
entries: SleepEntry[], fragments: RowFragment[],
index: number, index: number,
count: number, count: number,
language: Language, language: Language,
@ -255,29 +377,31 @@ function page(
text(740, 565, 7, `${index + 1}/${count}`), text(740, 565, 7, `${index + 1}/${count}`),
text(30, 548, 7, "DATE"), text(30, 548, 7, "DATE"),
]; ];
for (let hour = 0; hour <= 24; hour++) for (let hour = 0; hour < 24; hour++)
stream.push( stream.push(
text(112 + (hour / 24) * 500, 548, 4, String((18 + hour) % 24)), text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)),
); );
if (language === "fr") { if (language === "fr") {
stream.push(text(616, 552, 4, "QUALITÉ DU")); stream.push(text(layout.qualityX[0], 552, 4, "QUALITÉ DU"));
stream.push(text(616, 545, 4, "SOMMEIL")); stream.push(text(layout.qualityX[0], 545, 4, "SOMMEIL"));
stream.push(text(654, 552, 4, "QUALITÉ DU")); stream.push(text(layout.qualityX[1], 552, 4, "QUALITÉ DU"));
stream.push(text(654, 545, 4, "RÉVEIL")); stream.push(text(layout.qualityX[1], 545, 4, "RÉVEIL"));
stream.push(text(692, 552, 4, "FORME DE LA")); stream.push(text(layout.qualityX[2], 552, 4, "FORME DE LA"));
stream.push(text(692, 545, 4, "JOURNÉE")); stream.push(text(layout.qualityX[2], 545, 4, "JOURNÉE"));
stream.push(text(728, 552, 4, "TRAITEMENT ET")); stream.push(text(layout.notesX, 552, 4, "TRAITEMENT ET"));
stream.push(text(728, 545, 4, "REMARQUES")); stream.push(text(layout.notesX, 545, 4, "REMARQUES"));
} else { } else {
stream.push(text(616, 552, 4, "SLEEP QUALITY")); stream.push(text(layout.qualityX[0], 552, 4, "SLEEP QUALITY"));
stream.push(text(654, 552, 4, "WAKE QUALITY")); stream.push(text(layout.qualityX[1], 552, 4, "WAKE QUALITY"));
stream.push(text(692, 552, 4, "DAY FORM")); stream.push(text(layout.qualityX[2], 552, 4, "DAY FORM"));
stream.push(text(728, 552, 4, "TREATMENT / NOTES")); stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
} }
entries.forEach((entry, rowIndex) => let rowTop = layout.rowTop;
row(stream, entry, 538 - rowIndex * 40, language, sport), fragments.forEach((fragment) => {
); row(stream, fragment, rowTop, language, sport);
const y = 520 - entries.length * 40; rowTop -= fragment.height;
});
const y = 120;
stream.push(text(30, y, 9, "OBSERVATIONS")); stream.push(text(30, y, 9, "OBSERVATIONS"));
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language))); stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
const unvalidated = snapshot.entries.filter( const unvalidated = snapshot.entries.filter(
@ -296,19 +420,6 @@ function page(
: `WARNING: ${plural(unvalidated, "unvalidated day", "unvalidated days")} in this export.`, : `WARNING: ${plural(unvalidated, "unvalidated day", "unvalidated days")} in this export.`,
), ),
); );
entries
.filter((entry) => entry.treatment_and_notes)
.slice(0, 3)
.forEach((entry, noteIndex) =>
stream.push(
text(
30,
y - 36 - noteIndex * 10,
6,
`${compactDate(entry.night_start_date)} : ${entry.treatment_and_notes.slice(0, 110)}`,
),
),
);
if (language === "fr") { if (language === "fr") {
stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport).")); stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
stream.push( stream.push(
@ -398,10 +509,7 @@ export function buildSleepDiaryPdf(
reportStart = snapshot.entries[0]?.night_start_date ?? "—", reportStart = snapshot.entries[0]?.night_start_date ?? "—",
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—", reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
): Blob { ): Blob {
const groups = Array.from( const groups = paginate(snapshot.entries, language);
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
(_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9),
);
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) }, const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) },
(_, index) => sport.slice(index * 28, (index + 1) * 28)); (_, index) => sport.slice(index * 28, (index + 1) * 28));
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`); const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`);