diff --git a/.gitignore b/.gitignore index 33c5b38f..fe6d17f2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ package-lock.json dev bun.* bun.lock +fix.md diff --git a/app/api/routesF/__tests__/business-days.test.ts b/app/api/routesF/__tests__/business-days.test.ts new file mode 100644 index 00000000..8a10922c --- /dev/null +++ b/app/api/routesF/__tests__/business-days.test.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../business-days/route"; + +type RequestBody = { + date: string; + days: number; + country?: string; + custom_holidays?: string[]; +}; + +function makeReq(body: RequestBody) { + return new NextRequest("http://localhost/api/routesF/business-days", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routesF/business-days", () => { + it("adds one business day and skips a weekend", async () => { + const res = await POST(makeReq({ date: "2026-03-13", days: 1 })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("2026-03-15T00:00:00.000Z"); + expect(data.skipped_days).toBe(2); + }); + + it("subtracts one business day and skips a weekend", async () => { + const res = await POST(makeReq({ date: "2026-03-15", days: -1 })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("2026-03-13T00:00:00.000Z"); + expect(data.skipped_days).toBe(2); + }); + + it("skips a holiday from bundled country holidays", async () => { + const res = await POST( + makeReq({ date: "2026-12-24", days: 1, country: "US" }) + ); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("2026-12-27T00:00:00.000Z"); + expect(data.skipped_days).toBe(3); + }); + + it("uses custom_holidays to skip additional dates", async () => { + const res = await POST( + makeReq({ + date: "2026-03-13", + days: 1, + custom_holidays: ["2026-03-15"], + }) + ); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("2026-03-16T00:00:00.000Z"); + expect(data.skipped_days).toBe(2); + }); + + it("rejects invalid date values", async () => { + const res = await POST( + makeReq({ date: "invalid", days: 1 } as unknown as RequestBody) + ); + expect(res.status).toBe(400); + }); + + it("rejects non-integer days", async () => { + const req = new NextRequest("http://localhost/api/routesF/business-days", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ date: "2026-03-13", days: 1.5 }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts b/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts new file mode 100644 index 00000000..cd0cd144 --- /dev/null +++ b/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts @@ -0,0 +1,55 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../sentence-case-capitalizer/route"; + +function makeReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routesF/sentence-case-capitalizer", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +describe("/api/routesF/sentence-case-capitalizer", () => { + it("capitalizes the first letter of each sentence", async () => { + const res = await POST( + makeReq({ text: "hello world. this is a test! is it working? yes." }) + ); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe( + "Hello world. This is a test! Is it working? Yes." + ); + }); + + it("does not split sentences on common abbreviations", async () => { + const res = await POST( + makeReq({ text: "dr. smith arrived at 10 a.m. he said hello." }) + ); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("Dr. Smith arrived at 10 a.m. He said hello."); + }); + + it("handles a paragraph with mixed punctuation", async () => { + const res = await POST( + makeReq({ text: "wow! this is great? yes it is. fantastic." }) + ); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.result).toBe("Wow! This is great? Yes it is. Fantastic."); + }); + + it("rejects invalid request bodies", async () => { + const res = await POST(makeReq({ text: 123 })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routesF/__tests__/trailing-zeros-factorial.test.ts b/app/api/routesF/__tests__/trailing-zeros-factorial.test.ts new file mode 100644 index 00000000..dd5a4c36 --- /dev/null +++ b/app/api/routesF/__tests__/trailing-zeros-factorial.test.ts @@ -0,0 +1,52 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../trailing-zeros-factorial/route"; + +function makeReq(query: string) { + return new NextRequest( + `http://localhost/api/routesF/trailing-zeros-factorial?${query}` + ); +} + +describe("/api/routesF/trailing-zeros-factorial", () => { + it("returns trailing zeros for n=100", async () => { + const res = await GET(makeReq("n=100")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.n).toBe(100); + expect(data.trailing_zeros).toBe(24); + }); + + it("returns zero trailing zeros for n=0", async () => { + const res = await GET(makeReq("n=0")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.n).toBe(0); + expect(data.trailing_zeros).toBe(0); + }); + + it("returns the correct count for a large n", async () => { + const res = await GET(makeReq("n=1000000")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.n).toBe(1000000); + expect(data.trailing_zeros).toBe(249998); + }); + + it("rejects missing n parameter", async () => { + const res = await GET( + new NextRequest("http://localhost/api/routesF/trailing-zeros-factorial") + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid n values", async () => { + const res = await GET(makeReq("n=-1")); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routesF/__tests__/word-count-reading-time.test.ts b/app/api/routesF/__tests__/word-count-reading-time.test.ts new file mode 100644 index 00000000..22d22b7b --- /dev/null +++ b/app/api/routesF/__tests__/word-count-reading-time.test.ts @@ -0,0 +1,57 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../word-count-reading-time/route"; + +function makeReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routesF/word-count-reading-time", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +describe("/api/routesF/word-count-reading-time", () => { + it("counts words, characters, sentences, and reading time with default WPM", async () => { + const text = "Hello world. This is a test."; + const res = await POST(makeReq({ text })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.words).toBe(6); + expect(data.characters).toBe(28); + expect(data.characters_no_spaces).toBe(23); + expect(data.sentences).toBe(2); + expect(data.reading_time_seconds).toBe(2); + }); + + it("uses custom WPM when provided", async () => { + const text = "One two three four five six seven eight nine ten."; + const res = await POST(makeReq({ text, wpm: 250 })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.words).toBe(10); + expect(data.reading_time_seconds).toBe(3); + }); + + it("rejects text larger than 1MB", async () => { + const largeText = "a".repeat(1024 * 1024 + 1); + const res = await POST(makeReq({ text: largeText })); + expect(res.status).toBe(400); + }); + + it("rejects non-string text values", async () => { + const res = await POST(makeReq({ text: 123 })); + expect(res.status).toBe(400); + }); + + it("rejects invalid wpm values", async () => { + const res = await POST(makeReq({ text: "Hello world.", wpm: 0 })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routesF/business-days/holidays.ts b/app/api/routesF/business-days/holidays.ts new file mode 100644 index 00000000..ad32037c --- /dev/null +++ b/app/api/routesF/business-days/holidays.ts @@ -0,0 +1,32 @@ +export type Holiday = { + date: string; + name: string; +}; + +export type HolidayCountry = "US" | "GB" | "JP"; + +export const HOLIDAYS: Record = { + US: [ + { date: "2026-01-01", name: "New Year's Day" }, + { date: "2026-07-04", name: "Independence Day" }, + { date: "2026-11-26", name: "Thanksgiving Day" }, + { date: "2026-12-24", name: "Christmas Day (observed)" }, + { date: "2026-12-25", name: "Christmas Day" }, + ], + GB: [ + { date: "2026-01-01", name: "New Year's Day" }, + { date: "2026-04-10", name: "Good Friday" }, + { date: "2026-12-25", name: "Christmas Day" }, + { date: "2026-12-28", name: "Boxing Day (substitute day)" }, + ], + JP: [ + { date: "2026-01-01", name: "New Year's Day" }, + { date: "2026-02-11", name: "National Foundation Day" }, + { date: "2026-05-05", name: "Children's Day" }, + { date: "2026-11-03", name: "Culture Day" }, + ], +}; + +export const COUNTRY_ALIASES: Record = { + UK: "GB", +}; diff --git a/app/api/routesF/business-days/route.ts b/app/api/routesF/business-days/route.ts new file mode 100644 index 00000000..cb03ade9 --- /dev/null +++ b/app/api/routesF/business-days/route.ts @@ -0,0 +1,142 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { COUNTRY_ALIASES, HOLIDAYS, type HolidayCountry } from "./holidays"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +type BusinessDaysBody = { + date?: unknown; + days?: unknown; + country?: unknown; + custom_holidays?: unknown; +}; + +function badRequest(message: string) { + return NextResponse.json({ error: message }, { status: 400 }); +} + +function parseIsoDate(value: unknown): Date | null { + if (typeof value !== "string") { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); +} + +function dateKey(date: Date) { + return date.toISOString().slice(0, 10); +} + +function isWeekend(date: Date) { + const day = date.getUTCDay(); + return day === 0 || day === 6; +} + +function addDays(date: Date, amount: number) { + return new Date(date.getTime() + amount * MS_PER_DAY); +} + +function resolveCountry(value: unknown): HolidayCountry { + if (typeof value !== "string") { + return "US"; + } + + const normalized = value.toUpperCase(); + if (normalized in HOLIDAYS) { + return normalized as HolidayCountry; + } + + if (normalized in COUNTRY_ALIASES) { + return COUNTRY_ALIASES[normalized]; + } + + return "US"; +} + +function parseCustomHolidays(value: unknown): Set | null { + if (value === undefined) { + return new Set(); + } + + if (!Array.isArray(value)) { + return null; + } + + const result = new Set(); + for (const item of value) { + const date = parseIsoDate(item); + if (!date) { + return null; + } + result.add(dateKey(date)); + } + return result; +} + +function addBusinessDays( + startDate: Date, + days: number, + holidays: Set +): { target: Date; skipped: number } { + if (days === 0) { + return { target: startDate, skipped: 0 }; + } + + const step = days > 0 ? 1 : -1; + let remaining = Math.abs(days); + let current = startDate; + let skipped = 0; + + while (remaining > 0) { + current = addDays(current, step); + if (isWeekend(current) || holidays.has(dateKey(current))) { + skipped += 1; + continue; + } + remaining -= 1; + } + + return { target: current, skipped }; +} + +export async function POST(request: NextRequest) { + let body: BusinessDaysBody; + + try { + body = (await request.json()) as BusinessDaysBody; + } catch { + return badRequest("Invalid JSON body."); + } + + const date = parseIsoDate(body.date); + if (!date) { + return badRequest("date must be a valid ISO date string."); + } + + if (typeof body.days !== "number" || !Number.isInteger(body.days)) { + return badRequest("days must be an integer."); + } + + const country = resolveCountry(body.country); + const holidays = new Set(HOLIDAYS[country].map(holiday => holiday.date)); + const customHolidays = parseCustomHolidays(body.custom_holidays); + if (customHolidays === null) { + return badRequest("custom_holidays must be an array of ISO date strings."); + } + + for (const holiday of customHolidays) { + holidays.add(holiday); + } + + const { target, skipped } = addBusinessDays(date, body.days, holidays); + return NextResponse.json({ + result: target.toISOString(), + skipped_days: skipped, + }); +} diff --git a/app/api/routesF/sentence-case-capitalizer/route.ts b/app/api/routesF/sentence-case-capitalizer/route.ts new file mode 100644 index 00000000..5ed9343c --- /dev/null +++ b/app/api/routesF/sentence-case-capitalizer/route.ts @@ -0,0 +1,165 @@ +import { type NextRequest, NextResponse } from "next/server"; + +type SentenceCaseBody = { + text?: unknown; +}; + +const ABBREVIATIONS = new Set([ + "mr.", + "mrs.", + "ms.", + "dr.", + "jr.", + "sr.", + "prof.", + "rev.", + "st.", + "mt.", + "no.", + "gov.", + "sen.", + "rep.", + "pres.", + "inc.", + "ltd.", + "co.", + "corp.", + "e.g.", + "i.e.", + "etc.", + "vs.", + "jan.", + "feb.", + "mar.", + "apr.", + "jun.", + "jul.", + "aug.", + "sep.", + "sept.", + "oct.", + "nov.", + "dec.", +]); + +function badRequest(message: string) { + return NextResponse.json({ error: message }, { status: 400 }); +} + +function parseBody(body: unknown): string | null { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return null; + } + + const record = body as Record; + if (typeof record.text !== "string") { + return null; + } + + return record.text; +} + +function isLetter(char: string) { + return /^[a-zA-Z]$/.test(char); +} + +function isBoundaryCharacter(char: string) { + return ( + char === '"' || char === "'" || char === ")" || char === "]" || char === "}" + ); +} + +function getTokenBeforeDot(text: string, index: number) { + let j = index - 1; + while (j >= 0 && /[A-Za-z.]/.test(text[j])) { + j -= 1; + } + return text.slice(j + 1, index + 1).toLowerCase(); +} + +function isAbbreviation(text: string, index: number): boolean { + if (text[index] !== ".") { + return false; + } + + const token = getTokenBeforeDot(text, index); + if (ABBREVIATIONS.has(token)) { + return true; + } + + return /^[a-z](?:\.[a-z])+$/.test(token); +} + +function isSentenceBoundary(text: string, index: number): boolean { + const punctuation = text[index]; + if (punctuation === ".") { + if (isAbbreviation(text, index)) { + return false; + } + } + + let j = index + 1; + while ( + j < text.length && + (text[j] === " " || + text[j] === "\t" || + text[j] === "\n" || + isBoundaryCharacter(text[j])) + ) { + j += 1; + } + + return j >= text.length || isLetter(text[j]); +} + +function sentenceCase(text: string): string { + let result = ""; + let capitalizeNext = true; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + let output = char; + + if (capitalizeNext && isLetter(char)) { + output = char.toUpperCase(); + capitalizeNext = false; + } + + result += output; + + if (char === "." || char === "!" || char === "?") { + if (isSentenceBoundary(text, index)) { + capitalizeNext = true; + } + } + + if ( + !capitalizeNext && + char !== " " && + char !== "\t" && + char !== "\n" && + !isBoundaryCharacter(char) + ) { + // Continue until we hit sentence-ending punctuation. + } + } + + return result; +} + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return badRequest("Invalid JSON body."); + } + + const text = parseBody(body); + if (text === null) { + return badRequest("text must be a string."); + } + + return NextResponse.json({ result: sentenceCase(text) }); +} diff --git a/app/api/routesF/trailing-zeros-factorial/route.ts b/app/api/routesF/trailing-zeros-factorial/route.ts new file mode 100644 index 00000000..3b5fbbbe --- /dev/null +++ b/app/api/routesF/trailing-zeros-factorial/route.ts @@ -0,0 +1,41 @@ +import { type NextRequest, NextResponse } from "next/server"; + +function badRequest(message: string) { + return NextResponse.json({ error: message }, { status: 400 }); +} + +function parseN(value: string | null): number | null { + if (value === null) { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return null; + } + + return parsed; +} + +function countTrailingZeros(n: number): number { + let count = 0; + let divisor = 5; + + while (divisor <= n) { + count += Math.floor(n / divisor); + divisor *= 5; + } + + return count; +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const n = parseN(url.searchParams.get("n")); + + if (n === null || n < 0 || n > 1000000) { + return badRequest("n must be an integer between 0 and 1000000."); + } + + return NextResponse.json({ n, trailing_zeros: countTrailingZeros(n) }); +} diff --git a/app/api/routesF/word-count-reading-time/route.ts b/app/api/routesF/word-count-reading-time/route.ts new file mode 100644 index 00000000..770cff9f --- /dev/null +++ b/app/api/routesF/word-count-reading-time/route.ts @@ -0,0 +1,110 @@ +import { type NextRequest, NextResponse } from "next/server"; + +type WordCountBody = { + text?: unknown; + wpm?: unknown; +}; + +const MAX_BYTES = 1024 * 1024; +const DEFAULT_WPM = 200; + +function badRequest(message: string) { + return NextResponse.json({ error: message }, { status: 400 }); +} + +function parseBody(body: unknown): { text: string; wpm: number } | null { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return null; + } + + const record = body as Record; + if (typeof record.text !== "string") { + return null; + } + + const encoder = new TextEncoder(); + const byteLength = encoder.encode(record.text).length; + if (byteLength > MAX_BYTES) { + throw new Error("TEXT_TOO_LARGE"); + } + + let wpm = DEFAULT_WPM; + if (record.wpm !== undefined) { + if ( + typeof record.wpm !== "number" || + !Number.isInteger(record.wpm) || + record.wpm <= 0 + ) { + return null; + } + wpm = record.wpm; + } + + return { text: record.text, wpm }; +} + +function countWords(text: string) { + const matches = text.match(/\b[\p{L}\p{N}']+\b/gu); + return matches ? matches.length : 0; +} + +function countSentences(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + return 0; + } + + const matches = trimmed.match(/[^.!?]+[.!?]+/g); + if (matches && matches.length > 0) { + const remainder = trimmed + .slice( + trimmed.lastIndexOf(matches[matches.length - 1]) + + matches[matches.length - 1].length + ) + .trim(); + return remainder ? matches.length + 1 : matches.length; + } + + return 1; +} + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return badRequest("Invalid JSON body."); + } + + let parsed: { text: string; wpm: number } | null; + try { + parsed = parseBody(body); + } catch (error) { + if (error instanceof Error && error.message === "TEXT_TOO_LARGE") { + return badRequest("text must be at most 1MB."); + } + return badRequest("Invalid request body."); + } + + if (!parsed) { + return badRequest( + "text must be a string and wpm must be a positive integer." + ); + } + + const { text, wpm } = parsed; + const characters = Array.from(text).length; + const charactersNoSpaces = Array.from(text.replace(/\s+/g, "")).length; + const words = countWords(text); + const sentences = countSentences(text); + const readingTimeSeconds = words === 0 ? 0 : Math.ceil((words / wpm) * 60); + + return NextResponse.json({ + words, + characters, + characters_no_spaces: charactersNoSpaces, + sentences, + reading_time_seconds: readingTimeSeconds, + }); +}