Add period history UI with CRUD operations
All checks were successful
Deploy / deploy (push) Successful in 2m27s

- Add GET /api/period-history route with pagination, cycle length
  calculation, and prediction accuracy tracking
- Add PATCH/DELETE /api/period-logs/[id] routes for editing and
  deleting period entries with ownership validation
- Add /period-history page with table view, edit/delete modals,
  and pagination controls
- Include 61 new tests covering all functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 22:33:36 +00:00
parent 6e391a46be
commit 07577dbdbb
7 changed files with 2278 additions and 30 deletions

View File

@@ -0,0 +1,423 @@
// ABOUTME: Unit tests for period history API route.
// ABOUTME: Tests GET /api/period-history for pagination, cycle length calculation, and auth.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetList = vi.fn();
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getList: mockGetList,
})),
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb);
};
}),
}));
import { GET } from "./route";
describe("GET /api/period-history", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLogs: PeriodLog[] = [
{
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
},
{
id: "period2",
user: "user123",
startDate: new Date("2024-12-18"),
predictedDate: new Date("2024-12-19"),
created: new Date("2024-12-18T10:00:00Z"),
},
{
id: "period3",
user: "user123",
startDate: new Date("2024-11-20"),
predictedDate: null,
created: new Date("2024-11-20T10:00:00Z"),
},
];
// Helper to create mock request with query parameters
function createMockRequest(params: Record<string, string> = {}): NextRequest {
const url = new URL("http://localhost:3000/api/period-history");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return {
url: url.toString(),
nextUrl: url,
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns paginated period logs for authenticated user", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(3);
expect(body.total).toBe(3);
expect(body.page).toBe(1);
expect(body.limit).toBe(20);
expect(body.hasMore).toBe(false);
});
it("calculates cycle lengths between consecutive periods", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1 (Jan 15) - Period 2 (Dec 18) = 28 days
expect(body.items[0].cycleLength).toBe(28);
// Period 2 (Dec 18) - Period 3 (Nov 20) = 28 days
expect(body.items[1].cycleLength).toBe(28);
// Period 3 is the first log, no previous period to calculate from
expect(body.items[2].cycleLength).toBeNull();
});
it("calculates average cycle length", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Average of 28 and 28 = 28
expect(body.averageCycleLength).toBe(28);
});
it("returns null average when only one period exists", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[2]], // Only one period
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.averageCycleLength).toBeNull();
});
it("uses default pagination values (page=1, limit=20)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects page parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 3,
page: 2,
});
const mockRequest = createMockRequest({ page: "2" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
2,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects limit parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 5,
page: 1,
});
const mockRequest = createMockRequest({ limit: "10" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("returns empty array when no logs exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(0);
expect(body.total).toBe(0);
expect(body.hasMore).toBe(false);
expect(body.averageCycleLength).toBeNull();
});
it("returns 400 for invalid page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for non-numeric page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "abc" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for invalid limit value (too low)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns 400 for invalid limit value (too high)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "101" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns hasMore=true when more pages exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: Array(20).fill(mockPeriodLogs[0]),
totalItems: 50,
totalPages: 3,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(true);
});
it("returns hasMore=false on last page", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[0]],
totalItems: 41,
totalPages: 3,
page: 3,
});
const mockRequest = createMockRequest({ page: "3" });
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(false);
});
it("sorts by startDate descending (most recent first)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
sort: "-startDate",
}),
);
});
it("only returns logs for the authenticated user", async () => {
currentMockUser = { ...mockUser, id: "different-user" };
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="different-user"'),
}),
);
});
it("includes prediction accuracy for each period", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1: actual Jan 15, predicted Jan 16 -> 1 day early
expect(body.items[0].daysEarly).toBe(1);
expect(body.items[0].daysLate).toBe(0);
// Period 2: actual Dec 18, predicted Dec 19 -> 1 day early
expect(body.items[1].daysEarly).toBe(1);
expect(body.items[1].daysLate).toBe(0);
// Period 3: no prediction (first log)
expect(body.items[2].daysEarly).toBeNull();
expect(body.items[2].daysLate).toBeNull();
});
});

View File

@@ -0,0 +1,149 @@
// ABOUTME: API route for retrieving period history with calculated cycle lengths.
// ABOUTME: GET /api/period-history returns paginated period logs with cycle statistics.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import type { PeriodLog } from "@/types";
// Pagination constants
const MIN_PAGE = 1;
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
interface PeriodLogWithCycleLength extends PeriodLog {
cycleLength: number | null;
daysEarly: number | null;
daysLate: number | null;
}
interface PeriodHistoryResponse {
items: PeriodLogWithCycleLength[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
averageCycleLength: number | null;
}
function calculateDaysBetween(date1: Date, date2: Date): number {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d1.getTime() - d2.getTime());
return Math.round(diffTime / (1000 * 60 * 60 * 24));
}
export const GET = withAuth(async (request: NextRequest, user, pb) => {
const { searchParams } = new URL(request.url);
// Parse and validate pagination parameters
const pageParam = searchParams.get("page");
const limitParam = searchParams.get("limit");
let page = MIN_PAGE;
let limit = DEFAULT_LIMIT;
// Validate page parameter
if (pageParam !== null) {
const parsedPage = Number.parseInt(pageParam, 10);
if (Number.isNaN(parsedPage) || parsedPage < MIN_PAGE) {
return NextResponse.json(
{ error: "Invalid page: must be a positive integer" },
{ status: 400 },
);
}
page = parsedPage;
}
// Validate limit parameter
if (limitParam !== null) {
const parsedLimit = Number.parseInt(limitParam, 10);
if (
Number.isNaN(parsedLimit) ||
parsedLimit < MIN_LIMIT ||
parsedLimit > MAX_LIMIT
) {
return NextResponse.json(
{
error: `Invalid limit: must be between ${MIN_LIMIT} and ${MAX_LIMIT}`,
},
{ status: 400 },
);
}
limit = parsedLimit;
}
// Query period logs for user
const result = await pb
.collection("period_logs")
.getList<PeriodLog>(page, limit, {
filter: `user="${user.id}"`,
sort: "-startDate",
});
// Calculate cycle lengths between consecutive periods
// Periods are sorted by startDate descending (most recent first)
const itemsWithCycleLength: PeriodLogWithCycleLength[] = result.items.map(
(log, index) => {
let cycleLength: number | null = null;
// If there's a next period (earlier period), calculate cycle length
const nextPeriod = result.items[index + 1];
if (nextPeriod) {
cycleLength = calculateDaysBetween(log.startDate, nextPeriod.startDate);
}
// Calculate prediction accuracy
let daysEarly: number | null = null;
let daysLate: number | null = null;
if (log.predictedDate) {
const actualDate = new Date(log.startDate);
const predictedDate = new Date(log.predictedDate);
const diffDays = Math.round(
(actualDate.getTime() - predictedDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (diffDays < 0) {
daysEarly = Math.abs(diffDays);
daysLate = 0;
} else {
daysEarly = 0;
daysLate = diffDays;
}
}
return {
...log,
cycleLength,
daysEarly,
daysLate,
};
},
);
// Calculate average cycle length (only if we have at least 2 periods)
const cycleLengths = itemsWithCycleLength
.map((log) => log.cycleLength)
.filter((length): length is number => length !== null);
const averageCycleLength =
cycleLengths.length > 0
? Math.round(
cycleLengths.reduce((sum, len) => sum + len, 0) / cycleLengths.length,
)
: null;
const response: PeriodHistoryResponse = {
items: itemsWithCycleLength,
total: result.totalItems,
page: result.page,
limit,
totalPages: result.totalPages,
hasMore: result.page < result.totalPages,
averageCycleLength,
};
return NextResponse.json(response, { status: 200 });
});

View File

@@ -0,0 +1,428 @@
// ABOUTME: Unit tests for period log edit and delete API routes.
// ABOUTME: Tests PATCH and DELETE /api/period-logs/[id] for auth, validation, and ownership.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetOne = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockGetList = vi.fn();
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getOne: mockGetOne,
update: mockUpdate,
delete: mockDelete,
getList: mockGetList,
})),
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (
request: NextRequest,
context?: { params?: Promise<{ id: string }> },
) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb, context);
};
}),
}));
import { DELETE, PATCH } from "./route";
describe("PATCH /api/period-logs/[id]", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLog: PeriodLog = {
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
};
// Helper to create mock request with JSON body
function createMockRequest(body: unknown): NextRequest {
return {
json: async () => body,
} as unknown as NextRequest;
}
// Helper to create params context
function createParamsContext(id: string) {
return {
params: Promise.resolve({ id }),
};
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetOne.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when period log not found", async () => {
currentMockUser = mockUser;
mockGetOne.mockRejectedValue({ status: 404 });
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(
mockRequest,
createParamsContext("nonexistent"),
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe("Period log not found");
});
it("returns 403 when user does not own the period log", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue({
...mockPeriodLog,
user: "different-user",
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error).toBe("Access denied");
});
it("returns 400 when startDate is missing", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const mockRequest = createMockRequest({});
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 when startDate format is invalid", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const mockRequest = createMockRequest({ startDate: "not-a-date" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 when startDate is in the future", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const futureDateStr = futureDate.toISOString().split("T")[0];
const mockRequest = createMockRequest({ startDate: futureDateStr });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("future");
});
it("updates period log with valid startDate", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockUpdate.mockResolvedValue({
...mockPeriodLog,
startDate: new Date("2025-01-14"),
});
// Mock that this is the most recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(
"period1",
expect.objectContaining({
startDate: "2025-01-14",
}),
);
});
it("updates user.lastPeriodDate if editing the most recent period", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockUpdate.mockResolvedValue({
...mockPeriodLog,
startDate: new Date("2025-01-14"),
});
// This is the most recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
await PATCH(mockRequest, createParamsContext("period1"));
// Check that user was updated with new lastPeriodDate
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: "2025-01-14",
}),
);
});
it("does not update user.lastPeriodDate if editing an older period", async () => {
currentMockUser = mockUser;
const olderPeriod = {
...mockPeriodLog,
id: "period2",
startDate: new Date("2024-12-18"),
};
mockGetOne.mockResolvedValue(olderPeriod);
mockUpdate.mockResolvedValue({
...olderPeriod,
startDate: new Date("2024-12-17"),
});
// There's a more recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog, olderPeriod],
totalItems: 2,
});
const mockRequest = createMockRequest({ startDate: "2024-12-17" });
await PATCH(mockRequest, createParamsContext("period2"));
// User should not be updated since this isn't the most recent period
// Only period_logs should be updated
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockPb.collection).toHaveBeenCalledWith("period_logs");
});
it("returns updated period log in response", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const updatedLog = { ...mockPeriodLog, startDate: new Date("2025-01-14") };
mockUpdate.mockResolvedValue(updatedLog);
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.id).toBe("period1");
});
});
describe("DELETE /api/period-logs/[id]", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLog: PeriodLog = {
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
};
// Helper to create mock request
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
}
// Helper to create params context
function createParamsContext(id: string) {
return {
params: Promise.resolve({ id }),
};
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetOne.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when period log not found", async () => {
currentMockUser = mockUser;
mockGetOne.mockRejectedValue({ status: 404 });
const mockRequest = createMockRequest();
const response = await DELETE(
mockRequest,
createParamsContext("nonexistent"),
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe("Period log not found");
});
it("returns 403 when user does not own the period log", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue({
...mockPeriodLog,
user: "different-user",
});
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error).toBe("Access denied");
});
it("deletes period log successfully", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// After deletion, previous period becomes most recent
mockGetList.mockResolvedValue({
items: [
{ ...mockPeriodLog, id: "period2", startDate: new Date("2024-12-18") },
],
totalItems: 1,
});
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
expect(mockDelete).toHaveBeenCalledWith("period1");
const body = await response.json();
expect(body.success).toBe(true);
});
it("updates user.lastPeriodDate to previous period when deleting most recent", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// After deletion, previous period becomes most recent
const previousPeriod = {
...mockPeriodLog,
id: "period2",
startDate: new Date("2024-12-18"),
};
mockGetList.mockResolvedValue({
items: [previousPeriod],
totalItems: 1,
});
const mockRequest = createMockRequest();
await DELETE(mockRequest, createParamsContext("period1"));
// Check that user was updated with previous period's date
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: expect.any(String),
}),
);
});
it("sets user.lastPeriodDate to null when deleting the only period", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// No periods remaining after deletion
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
});
const mockRequest = createMockRequest();
await DELETE(mockRequest, createParamsContext("period1"));
// Check that user was updated with null lastPeriodDate
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: null,
}),
);
});
});

View File

@@ -0,0 +1,185 @@
// ABOUTME: API route for editing and deleting individual period logs.
// ABOUTME: PATCH updates startDate, DELETE removes period entry.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type PocketBase from "pocketbase";
import { withAuth } from "@/lib/auth-middleware";
import type { PeriodLog, User } from "@/types";
// Date format regex: YYYY-MM-DD
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
interface RouteContext {
params?: Promise<{ id: string }>;
}
// Helper to format date as YYYY-MM-DD
function formatDateStr(date: Date): string {
return date.toISOString().split("T")[0];
}
// Helper to check if a period log is the most recent for a user
async function isMostRecentPeriod(
pb: PocketBase,
userId: string,
periodLogId: string,
): Promise<boolean> {
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
filter: `user="${userId}"`,
sort: "-startDate",
});
if (result.items.length === 0) {
return false;
}
return result.items[0].id === periodLogId;
}
// Helper to get the most recent period after deletion
async function getMostRecentPeriodAfterDeletion(
pb: PocketBase,
userId: string,
): Promise<PeriodLog | null> {
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
filter: `user="${userId}"`,
sort: "-startDate",
});
return result.items[0] || null;
}
export const PATCH = withAuth(
async (
request: NextRequest,
user: User,
pb: PocketBase,
context?: RouteContext,
) => {
// Get ID from route params
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
// Fetch the period log
let periodLog: PeriodLog;
try {
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
} catch (error) {
// Handle PocketBase 404 errors (can be Error or plain object)
const err = error as { status?: number };
if (err.status === 404) {
return NextResponse.json(
{ error: "Period log not found" },
{ status: 404 },
);
}
throw error;
}
// Check ownership
if (periodLog.user !== user.id) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// Parse request body
const body = await request.json();
// Validate startDate is present
if (!body.startDate) {
return NextResponse.json(
{ error: "startDate is required" },
{ status: 400 },
);
}
// Validate startDate format
if (!DATE_REGEX.test(body.startDate)) {
return NextResponse.json(
{ error: "startDate must be in YYYY-MM-DD format" },
{ status: 400 },
);
}
// Validate startDate is a valid date
const parsedDate = new Date(body.startDate);
if (Number.isNaN(parsedDate.getTime())) {
return NextResponse.json(
{ error: "startDate is not a valid date" },
{ status: 400 },
);
}
// Validate startDate is not in the future
const today = new Date();
today.setHours(23, 59, 59, 999); // End of today
if (parsedDate > today) {
return NextResponse.json(
{ error: "startDate cannot be in the future" },
{ status: 400 },
);
}
// Update the period log
const updatedPeriodLog = await pb
.collection("period_logs")
.update<PeriodLog>(id, {
startDate: body.startDate,
});
// If this is the most recent period, update user.lastPeriodDate
const isLatest = await isMostRecentPeriod(pb, user.id, id);
if (isLatest) {
await pb.collection("users").update(user.id, {
lastPeriodDate: body.startDate,
});
}
return NextResponse.json(updatedPeriodLog, { status: 200 });
},
);
export const DELETE = withAuth(
async (
_request: NextRequest,
user: User,
pb: PocketBase,
context?: RouteContext,
) => {
// Get ID from route params
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
// Fetch the period log
let periodLog: PeriodLog;
try {
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
} catch (error) {
// Handle PocketBase 404 errors (can be Error or plain object)
const err = error as { status?: number };
if (err.status === 404) {
return NextResponse.json(
{ error: "Period log not found" },
{ status: 404 },
);
}
throw error;
}
// Check ownership
if (periodLog.user !== user.id) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// Delete the period log
await pb.collection("period_logs").delete(id);
// Update user.lastPeriodDate to the previous period (or null if no more periods)
const previousPeriod = await getMostRecentPeriodAfterDeletion(pb, user.id);
await pb.collection("users").update(user.id, {
lastPeriodDate: previousPeriod
? formatDateStr(new Date(previousPeriod.startDate))
: null,
});
return NextResponse.json({ success: true }, { status: 200 });
},
);

View File

@@ -0,0 +1,607 @@
// ABOUTME: Unit tests for the Period History page component.
// ABOUTME: Tests data loading, table rendering, edit/delete functionality, and pagination.
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
import PeriodHistoryPage from "./page";
describe("PeriodHistoryPage", () => {
const mockPeriodLog = {
id: "period1",
user: "user123",
startDate: "2025-01-15",
predictedDate: "2025-01-16",
created: "2025-01-15T10:00:00Z",
cycleLength: 28,
daysEarly: 1,
daysLate: 0,
};
const mockHistoryResponse = {
items: [mockPeriodLog],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
hasMore: false,
averageCycleLength: 28,
};
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
});
});
describe("rendering", () => {
it("renders the period history heading", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /period history/i }),
).toBeInTheDocument();
});
});
it("renders a back link to dashboard", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("link", { name: /back to dashboard/i }),
).toHaveAttribute("href", "/");
});
});
it("renders the period history table with headers", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders.length).toBeGreaterThanOrEqual(4);
expect(columnHeaders[0]).toHaveTextContent(/date/i);
expect(columnHeaders[1]).toHaveTextContent(/cycle length/i);
expect(columnHeaders[2]).toHaveTextContent(/prediction accuracy/i);
expect(columnHeaders[3]).toHaveTextContent(/actions/i);
});
});
});
describe("data loading", () => {
it("fetches period history data on mount", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/period-history"),
);
});
});
it("shows loading state while fetching", async () => {
let resolveHistory: (value: unknown) => void = () => {};
const historyPromise = new Promise((resolve) => {
resolveHistory = resolve;
});
mockFetch.mockReturnValue({
ok: true,
json: () => historyPromise,
});
render(<PeriodHistoryPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
resolveHistory(mockHistoryResponse);
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
it("displays period log entries in the table", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
// Check that the log entry data is displayed
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
// Check the table contains cycle length (use getAllByText since it appears twice)
const cycleLengthElements = screen.getAllByText(/28 days/i);
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
});
});
it("displays prediction accuracy", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
// 1 day early
expect(screen.getByText(/1 day early/i)).toBeInTheDocument();
});
});
it("displays multiple period entries", async () => {
const logs = [
mockPeriodLog,
{
...mockPeriodLog,
id: "period2",
startDate: "2024-12-18",
cycleLength: 28,
},
{
...mockPeriodLog,
id: "period3",
startDate: "2024-11-20",
cycleLength: null,
},
];
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
items: logs,
total: 3,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
expect(screen.getByText(/dec 18, 2024/i)).toBeInTheDocument();
expect(screen.getByText(/nov 20, 2024/i)).toBeInTheDocument();
});
});
});
describe("average cycle length", () => {
it("displays average cycle length", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/average cycle/i)).toBeInTheDocument();
// The text "28 days" appears in both average section and table
const cycleLengthElements = screen.getAllByText(/28 days/i);
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
});
});
it("shows no average when only one period exists", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
averageCycleLength: null,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.queryByText(/average cycle.*\d+ days/i),
).not.toBeInTheDocument();
});
});
});
describe("empty state", () => {
it("shows empty state message when no periods exist", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
hasMore: false,
averageCycleLength: null,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/no period history/i)).toBeInTheDocument();
});
});
});
describe("error handling", () => {
it("shows error message on fetch failure", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () =>
Promise.resolve({ error: "Failed to fetch period history" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/failed to fetch period history/i),
).toBeInTheDocument();
});
});
it("shows generic error for network failures", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
});
describe("pagination", () => {
it("shows pagination info", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
});
});
it("renders previous and next buttons", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 2,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /previous/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /next/i }),
).toBeInTheDocument();
});
});
it("disables previous button on first page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /previous/i }),
).toBeDisabled();
});
});
it("disables next button on last page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 3,
totalPages: 3,
hasMore: false,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /next/i })).toBeDisabled();
});
});
it("fetches next page when next button is clicked", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /next/i }),
).toBeInTheDocument();
});
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 2,
totalPages: 3,
hasMore: true,
}),
});
fireEvent.click(screen.getByRole("button", { name: /next/i }));
await waitFor(() => {
expect(mockFetch).toHaveBeenLastCalledWith(
expect.stringContaining("page=2"),
);
});
});
it("hides pagination when there is only one page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 5,
page: 1,
totalPages: 1,
hasMore: false,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /previous/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /next/i }),
).not.toBeInTheDocument();
});
});
});
describe("edit functionality", () => {
it("renders edit button for each period", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
});
it("shows edit modal when edit button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
});
});
it("submits edit with new date", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ ...mockPeriodLog, startDate: "2025-01-14" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await waitFor(() => {
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
});
const dateInput = screen.getByLabelText(/period start date/i);
fireEvent.change(dateInput, { target: { value: "2025-01-14" } });
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/period-logs/period1",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ startDate: "2025-01-14" }),
}),
);
});
});
});
describe("delete functionality", () => {
it("renders delete button for each period", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
});
it("shows confirmation dialog when delete button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
});
it("deletes period when confirmed", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ ...mockHistoryResponse, items: [], total: 0 }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/period-logs/period1",
expect.objectContaining({
method: "DELETE",
}),
);
});
});
it("cancels delete when cancel button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
fireEvent.click(cancelButton);
await waitFor(() => {
expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument();
});
// Should not have made a delete call
expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch
});
});
describe("total entries display", () => {
it("shows total entries count", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 12,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/12 periods/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,435 @@
// ABOUTME: Period history view showing all logged periods with cycle length calculations.
// ABOUTME: Allows editing and deleting period entries with confirmation dialogs.
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
interface PeriodLogWithCycleLength {
id: string;
user: string;
startDate: string;
predictedDate: string | null;
created: string;
cycleLength: number | null;
daysEarly: number | null;
daysLate: number | null;
}
interface PeriodHistoryResponse {
items: PeriodLogWithCycleLength[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
averageCycleLength: number | null;
}
/**
* Formats a date string for display.
*/
function formatDate(dateStr: string | Date): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Formats prediction accuracy for display.
*/
function formatPredictionAccuracy(
daysEarly: number | null,
daysLate: number | null,
): string {
if (daysEarly === null && daysLate === null) {
return "-";
}
if (daysEarly && daysEarly > 0) {
return `${daysEarly} day${daysEarly > 1 ? "s" : ""} early`;
}
if (daysLate && daysLate > 0) {
return `${daysLate} day${daysLate > 1 ? "s" : ""} late`;
}
return "On time";
}
export default function PeriodHistoryPage() {
const [data, setData] = useState<PeriodHistoryResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
// Edit modal state
const [editingPeriod, setEditingPeriod] =
useState<PeriodLogWithCycleLength | null>(null);
const [editDate, setEditDate] = useState("");
const [editError, setEditError] = useState<string | null>(null);
// Delete confirmation state
const [deletingPeriod, setDeletingPeriod] =
useState<PeriodLogWithCycleLength | null>(null);
const fetchHistory = useCallback(async (pageNum: number) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set("page", pageNum.toString());
const response = await fetch(`/api/period-history?${params.toString()}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Failed to fetch period history");
}
setData(result);
setPage(result.page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchHistory(1);
}, [fetchHistory]);
const handlePreviousPage = () => {
if (page > 1) {
fetchHistory(page - 1);
}
};
const handleNextPage = () => {
if (data?.hasMore) {
fetchHistory(page + 1);
}
};
const handleEditClick = (period: PeriodLogWithCycleLength) => {
setEditingPeriod(period);
// Extract date portion from ISO string or Date object
const dateStr = new Date(period.startDate).toISOString().split("T")[0];
setEditDate(dateStr);
setEditError(null);
};
const handleEditCancel = () => {
setEditingPeriod(null);
setEditDate("");
setEditError(null);
};
const handleEditSave = async () => {
if (!editingPeriod) return;
try {
const response = await fetch(`/api/period-logs/${editingPeriod.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startDate: editDate }),
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.error || "Failed to update period");
}
// Close modal and refresh data
setEditingPeriod(null);
setEditDate("");
fetchHistory(page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setEditError(message);
}
};
const handleDeleteClick = (period: PeriodLogWithCycleLength) => {
setDeletingPeriod(period);
};
const handleDeleteCancel = () => {
setDeletingPeriod(null);
};
const handleDeleteConfirm = async () => {
if (!deletingPeriod) return;
try {
const response = await fetch(`/api/period-logs/${deletingPeriod.id}`, {
method: "DELETE",
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.error || "Failed to delete period");
}
// Close modal and refresh data
setDeletingPeriod(null);
fetchHistory(page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
setDeletingPeriod(null);
}
};
if (loading && !data) {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Period History</h1>
<p className="text-gray-500">Loading...</p>
</div>
);
}
return (
<div className="container mx-auto p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Period History</h1>
<Link
href="/"
className="text-blue-600 hover:text-blue-700 hover:underline"
>
Back to Dashboard
</Link>
</div>
{error && (
<div
role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{/* Average Cycle Length */}
{data && data.averageCycleLength !== null && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-700">
<span className="font-medium">Average Cycle:</span>{" "}
{data.averageCycleLength} days
</p>
</div>
)}
{/* Total Entries */}
{data && (
<p className="text-sm text-gray-600 mb-4">{data.total} periods</p>
)}
{/* Empty State */}
{data && data.items.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p>No period history found</p>
<p className="text-sm mt-2">
Log your period to start tracking your cycle.
</p>
</div>
)}
{/* Period History Table */}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cycle Length
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prediction Accuracy
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.items.map((period) => (
<tr key={period.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{formatDate(period.startDate)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{period.cycleLength !== null
? `${period.cycleLength} days`
: "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{formatPredictionAccuracy(
period.daysEarly,
period.daysLate,
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditClick(period)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteClick(period)}
className="text-red-600 hover:text-red-800 hover:underline"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handlePreviousPage}
disabled={page <= 1}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page} of {data.totalPages}
</span>
<button
type="button"
onClick={handleNextPage}
disabled={!data.hasMore}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
{/* Edit Modal */}
{editingPeriod && (
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by form focus
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleEditCancel}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="edit-modal-title"
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="edit-modal-title" className="text-lg font-semibold mb-4">
Edit Period Date
</h2>
{editError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded mb-4 text-sm">
{editError}
</div>
)}
<div className="mb-4">
<label
htmlFor="editDate"
className="block text-sm font-medium text-gray-700 mb-1"
>
Period Start Date
</label>
<input
id="editDate"
type="date"
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleEditCancel}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
onClick={handleEditSave}
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Save
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deletingPeriod && (
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by buttons
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleDeleteCancel}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="delete-modal-title" className="text-lg font-semibold mb-4">
Delete Period
</h2>
<p className="text-gray-600 mb-6">
Are you sure you want to delete the period from{" "}
<span className="font-medium">
{formatDate(deletingPeriod.startDate)}
</span>
? This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleDeleteCancel}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteConfirm}
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Confirm
</button>
</div>
</div>
</div>
)}
</div>
);
}