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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^4.1.2"
"vitest": "^4.1.2",
"vitest-mock-extended": "^4.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
Warnings:

- You are about to drop the column `yearsExperience` on the `PoolTeacher` table. All the data in the column will be lost.
- A unique constraint covering the columns `[absenceReportId,periodId,applicableDate]` on the table `AbsencePeriodRequirement` will be added. If there are existing duplicate values, this will fail.

*/
-- DropIndex
DROP INDEX "AbsencePeriodRequirement_absenceReportId_periodId_key";

-- AlterTable
ALTER TABLE "AbsencePeriodRequirement" ADD COLUMN "applicableDate" TIMESTAMP(3);

-- AlterTable
ALTER TABLE "PoolTeacher" DROP COLUMN "yearsExperience";

-- CreateIndex
CREATE UNIQUE INDEX "AbsencePeriodRequirement_absenceReportId_periodId_applicabl_key" ON "AbsencePeriodRequirement"("absenceReportId", "periodId", "applicableDate");
7 changes: 4 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ model AbsenceReport {
// trio. The legacy fields stay populated until 27.5 cuts them.
//
// Invariants this model enforces at the DB level:
// * (absenceReportId, periodId) is unique — one decision per period
// * (absenceReportId, periodId, applicableDate) is unique — one decision per period
// * `period.schoolId` must match `absenceReport.schoolId` (enforced
// at app-level in the helper for now; Prisma can't model it
// cross-FK)
Expand All @@ -381,7 +381,8 @@ model AbsencePeriodRequirement {
// Optional handover note targeted at the relief teacher for this
// specific period — D4 / Phase 27 question 2. Null when the absent
// teacher didn't leave any guidance for this slot.
lessonNote String? @db.Text
applicableDate DateTime? // which date this period requirement applies to (for multi-day absences)
lessonNote String? @db.Text
// Optional reference to the source timetable row this requirement
// was derived from. Nullable; SetNull on delete so deleting a
// timetable entry doesn't erase the absence's period record.
Expand All @@ -392,7 +393,7 @@ model AbsencePeriodRequirement {
period Period @relation(fields: [periodId], references: [id], onDelete: Restrict)
timetableEntry TimetableEntry? @relation(fields: [timetableEntryId], references: [id], onDelete: SetNull)

@@unique([absenceReportId, periodId])
@@unique([absenceReportId, periodId, applicableDate])
@@index([absenceReportId])
@@index([periodId])
@@index([timetableEntryId])
Expand Down
32 changes: 25 additions & 7 deletions src/__tests__/absencePeriodRequirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface RecordedCall {
absenceReportId: string;
periodId: string;
coverageType: "RELIEF_REQUIRED" | "NO_RELIEF";
lessonNote: string | null;
lessonNote?: Record<string, Record<string, string>>;
}>;
}

Expand All @@ -32,6 +32,9 @@ function makeTx(allPeriodsInSchool: { id: string }[]) {
}),
},
absencePeriodRequirement: {
deleteMany: vi.fn(async () => {
return { count: 0 };
}),
createMany: vi.fn(async ({ data }: RecordedCall) => {
calls.create.push({ data });
return { count: data.length };
Expand All @@ -51,6 +54,7 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "NO_RELIEF",
datePeriods: {},
periodIds: [],
lessonNotes: null,
},
Expand All @@ -74,6 +78,7 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "FULL_DAY",
datePeriods: {}, // ignored for FULL_DAY
periodIds: [], // ignored for FULL_DAY
lessonNotes: null,
},
Expand All @@ -95,6 +100,9 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "PARTIAL_DAY",
datePeriods: {
"2026-01-01": ["p1", "p3"],
},
periodIds: ["p1", "p3"],
lessonNotes: null,
},
Expand All @@ -115,6 +123,7 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "PARTIAL_DAY",
datePeriods: {},
periodIds: [],
lessonNotes: null,
},
Expand All @@ -131,19 +140,22 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "FULL_DAY",
datePeriods: {}, // ignored for FULL_DAY
periodIds: [],
lessonNotes: {
p1: "Continue Chapter 4 worksheet.",
p3: "Quiz starts at 10:00.",
// p2 has no note → stays null
"2026-01-01": {
p1: "Continue Chapter 4 worksheet.",
p3: "Quiz starts at 10:00.",
// p2 has no note → stays null
}
},
});

const row = (id: string) =>
calls.create[0].data.find((r) => r.periodId === id);
expect(row("p3")?.lessonNote).toBe("Quiz starts at 10:00.");
expect(row("p1")?.lessonNote).toBe("Continue Chapter 4 worksheet.");
expect(row("p2")?.lessonNote).toBeNull();
expect(row("p3")?.lessonNote).toBe("Quiz starts at 10:00.");
});

it("ignores stale lessonNote keys (periods that aren't in the emitted set)", async () => {
Expand All @@ -153,10 +165,15 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "PARTIAL_DAY",
datePeriods: {
"2026-01-01": ["p1"],
},
periodIds: ["p1"],
lessonNotes: {
p1: "Real note",
p99: "Stale — period was deleted",
"2026-01-01": {
p1: "Real note",
p99: "Stale — period was deleted",
}
},
});

Expand All @@ -174,6 +191,7 @@ describe("dualWriteAbsencePeriodRequirements", () => {
absenceReportId: "a1",
schoolId: "s1",
coverageType: "FULL_DAY",
datePeriods: {}, // ignored for FULL_DAY
periodIds: [],
lessonNotes: null,
},
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/buildReliefOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ describe("buildReliefOverlay", () => {
className: "3A",
subject: "English",
},
applicableDate: new Date("2026-05-04"),
},
{
periodId: "p2",
Expand All @@ -177,6 +178,7 @@ describe("buildReliefOverlay", () => {
className: "4A",
subject: "Science",
},
applicableDate: new Date("2026-05-04"),
},
],
},
Expand All @@ -195,6 +197,64 @@ describe("buildReliefOverlay", () => {
expect(result[0].type).toBe("uncovered");
});

it("handles PARTIAL_DAY absences across multiple days when requirements are date-scoped", () => {
const absences: ActiveAbsence[] = [
{
id: "abs-5",
startDate: new Date("2026-05-04"),
endDate: new Date("2026-05-08"),
coverageType: "PARTIAL_DAY",
periodRequirements: [
{
periodId: "p1",
coverageType: "RELIEF_REQUIRED",
applicableDate: new Date("2026-05-04"),
timetableEntry: {
dayOfWeek: 1,
className: "3A",
subject: "English",
},
},
{
periodId: "p2",
coverageType: "RELIEF_REQUIRED",
applicableDate: new Date("2026-05-05"),
timetableEntry: {
dayOfWeek: 2,
className: "4A",
subject: "Science",
},
},
{
periodId: "p3",
coverageType: "RELIEF_REQUIRED",
applicableDate: new Date("2026-05-06"),
timetableEntry: {
dayOfWeek: 3,
className: "5B",
subject: "History",
},
},
],
},
];

const timetableEntries: TeacherTimetableEntry[] = [
{ dayOfWeek: 1, periodId: "p1", className: "3A", subject: "English" },
{ dayOfWeek: 2, periodId: "p2", className: "4A", subject: "Science" },
{ dayOfWeek: 3, periodId: "p3", className: "5B", subject: "History" },
];

const result = buildReliefOverlay([], [], absences, timetableEntries, weekDates);

expect(result).toHaveLength(3);
expect(result.map((item) => `${item.dayOfWeek}-${item.periodId}`).sort()).toEqual([
"1-p1",
"2-p2",
"3-p3",
]);
});

it("skips absences with coverageType NO_RELIEF entirely", () => {
const absences: ActiveAbsence[] = [
{
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/prismaMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { mockDeep, DeepMockProxy } from "vitest-mock-extended";
import { PrismaClient } from "@prisma/client";

export type MockPrismaClient = DeepMockProxy<PrismaClient>;

export const prismaMock = mockDeep<PrismaClient>();
Loading