Add period history UI with CRUD operations
All checks were successful
Deploy / deploy (push) Successful in 2m27s
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:
149
src/app/api/period-history/route.ts
Normal file
149
src/app/api/period-history/route.ts
Normal 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user