From 520a1afbed762422b3aee689ee23b01749038799 Mon Sep 17 00:00:00 2001 From: JSE19 Date: Tue, 2 Jun 2026 10:09:52 +0100 Subject: [PATCH 1/4] fixes issue 898 --- .gitignore | 1 + .../routesF/__tests__/business-days.test.ts | 85 +++++++++++ app/api/routesF/business-days/holidays.ts | 32 ++++ app/api/routesF/business-days/route.ts | 139 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 app/api/routesF/__tests__/business-days.test.ts create mode 100644 app/api/routesF/business-days/holidays.ts create mode 100644 app/api/routesF/business-days/route.ts 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..302ac64a --- /dev/null +++ b/app/api/routesF/__tests__/business-days.test.ts @@ -0,0 +1,85 @@ +/** + * @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/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..7832ca0a --- /dev/null +++ b/app/api/routesF/business-days/route.ts @@ -0,0 +1,139 @@ +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 }); +} From 54d81599b658a701b9b3efce485fda6bf6e9daed Mon Sep 17 00:00:00 2001 From: JSE19 Date: Tue, 2 Jun 2026 10:16:06 +0100 Subject: [PATCH 2/4] fixes issue 905 --- .../sentence-case-capitalizer.test.ts | 50 ++++++ .../sentence-case-capitalizer/route.ts | 151 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 app/api/routesF/__tests__/sentence-case-capitalizer.test.ts create mode 100644 app/api/routesF/sentence-case-capitalizer/route.ts 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..50311697 --- /dev/null +++ b/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts @@ -0,0 +1,50 @@ +/** + * @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/sentence-case-capitalizer/route.ts b/app/api/routesF/sentence-case-capitalizer/route.ts new file mode 100644 index 00000000..846b2b94 --- /dev/null +++ b/app/api/routesF/sentence-case-capitalizer/route.ts @@ -0,0 +1,151 @@ +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) }); +} From 37de3e6f9d567c48a7b13eca8d2c9048efbe12ce Mon Sep 17 00:00:00 2001 From: JSE19 Date: Tue, 2 Jun 2026 10:23:23 +0100 Subject: [PATCH 3/4] fixes issue 855 --- .../__tests__/word-count-reading-time.test.ts | 54 ++++++++++ .../routesF/word-count-reading-time/route.ts | 99 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/api/routesF/__tests__/word-count-reading-time.test.ts create mode 100644 app/api/routesF/word-count-reading-time/route.ts 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..9c7fbfc5 --- /dev/null +++ b/app/api/routesF/__tests__/word-count-reading-time.test.ts @@ -0,0 +1,54 @@ +/** + * @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/word-count-reading-time/route.ts b/app/api/routesF/word-count-reading-time/route.ts new file mode 100644 index 00000000..9da74c61 --- /dev/null +++ b/app/api/routesF/word-count-reading-time/route.ts @@ -0,0 +1,99 @@ +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, + }); +} From 212ef519bb13572c6678f57af29bdf5468915504 Mon Sep 17 00:00:00 2001 From: JSE19 Date: Tue, 2 Jun 2026 10:29:37 +0100 Subject: [PATCH 4/4] fixes issue 890 --- .../routesF/__tests__/business-days.test.ts | 12 ++--- .../sentence-case-capitalizer.test.ts | 17 +++--- .../trailing-zeros-factorial.test.ts | 52 +++++++++++++++++++ .../__tests__/word-count-reading-time.test.ts | 13 +++-- app/api/routesF/business-days/route.ts | 7 ++- .../sentence-case-capitalizer/route.ts | 26 +++++++--- .../routesF/trailing-zeros-factorial/route.ts | 41 +++++++++++++++ .../routesF/word-count-reading-time/route.ts | 17 ++++-- 8 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 app/api/routesF/__tests__/trailing-zeros-factorial.test.ts create mode 100644 app/api/routesF/trailing-zeros-factorial/route.ts diff --git a/app/api/routesF/__tests__/business-days.test.ts b/app/api/routesF/__tests__/business-days.test.ts index 302ac64a..8a10922c 100644 --- a/app/api/routesF/__tests__/business-days.test.ts +++ b/app/api/routesF/__tests__/business-days.test.ts @@ -21,9 +21,7 @@ function makeReq(body: RequestBody) { 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 res = await POST(makeReq({ date: "2026-03-13", days: 1 })); const data = await res.json(); expect(res.status).toBe(200); @@ -32,9 +30,7 @@ describe("/api/routesF/business-days", () => { }); it("subtracts one business day and skips a weekend", async () => { - const res = await POST( - makeReq({ date: "2026-03-15", days: -1 }) - ); + const res = await POST(makeReq({ date: "2026-03-15", days: -1 })); const data = await res.json(); expect(res.status).toBe(200); @@ -69,7 +65,9 @@ describe("/api/routesF/business-days", () => { }); it("rejects invalid date values", async () => { - const res = await POST(makeReq({ date: "invalid", days: 1 } as unknown as RequestBody)); + const res = await POST( + makeReq({ date: "invalid", days: 1 } as unknown as RequestBody) + ); 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 index 50311697..cd0cd144 100644 --- a/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts +++ b/app/api/routesF/__tests__/sentence-case-capitalizer.test.ts @@ -5,11 +5,14 @@ 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), - }); + 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", () => { @@ -20,7 +23,9 @@ describe("/api/routesF/sentence-case-capitalizer", () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data.result).toBe("Hello world. This is a test! Is it working? Yes."); + expect(data.result).toBe( + "Hello world. This is a test! Is it working? Yes." + ); }); it("does not split sentences on common abbreviations", async () => { 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 index 9c7fbfc5..22d22b7b 100644 --- a/app/api/routesF/__tests__/word-count-reading-time.test.ts +++ b/app/api/routesF/__tests__/word-count-reading-time.test.ts @@ -5,11 +5,14 @@ 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), - }); + 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", () => { diff --git a/app/api/routesF/business-days/route.ts b/app/api/routesF/business-days/route.ts index 7832ca0a..cb03ade9 100644 --- a/app/api/routesF/business-days/route.ts +++ b/app/api/routesF/business-days/route.ts @@ -124,7 +124,7 @@ export async function POST(request: NextRequest) { } const country = resolveCountry(body.country); - const holidays = new Set(HOLIDAYS[country].map((holiday) => holiday.date)); + 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."); @@ -135,5 +135,8 @@ export async function POST(request: NextRequest) { } const { target, skipped } = addBusinessDays(date, body.days, holidays); - return NextResponse.json({ result: target.toISOString(), skipped_days: skipped }); + 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 index 846b2b94..5ed9343c 100644 --- a/app/api/routesF/sentence-case-capitalizer/route.ts +++ b/app/api/routesF/sentence-case-capitalizer/route.ts @@ -64,7 +64,9 @@ function isLetter(char: string) { } function isBoundaryCharacter(char: string) { - return char === '"' || char === "'" || char === ")" || char === "]" || char === "}"; + return ( + char === '"' || char === "'" || char === ")" || char === "]" || char === "}" + ); } function getTokenBeforeDot(text: string, index: number) { @@ -76,7 +78,7 @@ function getTokenBeforeDot(text: string, index: number) { } function isAbbreviation(text: string, index: number): boolean { - if (text[index] !== '.') { + if (text[index] !== ".") { return false; } @@ -90,14 +92,20 @@ function isAbbreviation(text: string, index: number): boolean { function isSentenceBoundary(text: string, index: number): boolean { const punctuation = text[index]; - if (punctuation === '.') { + 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]))) { + while ( + j < text.length && + (text[j] === " " || + text[j] === "\t" || + text[j] === "\n" || + isBoundaryCharacter(text[j])) + ) { j += 1; } @@ -119,13 +127,19 @@ function sentenceCase(text: string): string { result += output; - if (char === '.' || char === '!' || char === '?') { + if (char === "." || char === "!" || char === "?") { if (isSentenceBoundary(text, index)) { capitalizeNext = true; } } - if (!capitalizeNext && char !== ' ' && char !== '\t' && char !== '\n' && !isBoundaryCharacter(char)) { + if ( + !capitalizeNext && + char !== " " && + char !== "\t" && + char !== "\n" && + !isBoundaryCharacter(char) + ) { // Continue until we hit sentence-ending punctuation. } } 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 index 9da74c61..770cff9f 100644 --- a/app/api/routesF/word-count-reading-time/route.ts +++ b/app/api/routesF/word-count-reading-time/route.ts @@ -30,7 +30,11 @@ function parseBody(body: unknown): { text: string; wpm: number } | null { let wpm = DEFAULT_WPM; if (record.wpm !== undefined) { - if (typeof record.wpm !== "number" || !Number.isInteger(record.wpm) || record.wpm <= 0) { + if ( + typeof record.wpm !== "number" || + !Number.isInteger(record.wpm) || + record.wpm <= 0 + ) { return null; } wpm = record.wpm; @@ -52,7 +56,12 @@ function countSentences(text: string) { 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(); + const remainder = trimmed + .slice( + trimmed.lastIndexOf(matches[matches.length - 1]) + + matches[matches.length - 1].length + ) + .trim(); return remainder ? matches.length + 1 : matches.length; } @@ -79,7 +88,9 @@ export async function POST(request: NextRequest) { } if (!parsed) { - return badRequest("text must be a string and wpm must be a positive integer."); + return badRequest( + "text must be a string and wpm must be a positive integer." + ); } const { text, wpm } = parsed;