diff --git a/src/calculator.ts b/src/calculator.ts index 68b894d..8fa3de6 100644 --- a/src/calculator.ts +++ b/src/calculator.ts @@ -15,7 +15,7 @@ export function multiply(a: number, b: number): number { return a * b } -// BUG: Division by zero is not handled export function divide(a: number, b: number): number { + if (b === 0) throw new Error("Division by zero") return a / b } diff --git a/src/date-utils.ts b/src/date-utils.ts index 37272a7..0efeda2 100644 --- a/src/date-utils.ts +++ b/src/date-utils.ts @@ -5,28 +5,28 @@ /** * Format a date as a human-readable relative string. * e.g. "2 days ago", "just now", "in 3 hours" - * - * BUG: off-by-one — uses Math.floor where Math.round is needed for days, - * causing "1 day ago" to appear for anything from 12h to 47h. */ export function formatRelative(date: Date, now: Date = new Date()): string { const diffMs = now.getTime() - date.getTime() - const diffSec = diffMs / 1000 - const diffMin = diffSec / 60 - const diffHours = diffMin / 60 - const diffDays = Math.floor(diffHours / 24) // BUG: should be Math.round - - if (Math.abs(diffSec) < 60) return "just now" - if (Math.abs(diffMin) < 60) { - const m = Math.round(Math.abs(diffMin)) - return diffMs > 0 ? `${m} minute${m !== 1 ? "s" : ""} ago` : `in ${m} minute${m !== 1 ? "s" : ""}` - } - if (Math.abs(diffHours) < 24) { - const h = Math.round(Math.abs(diffHours)) - return diffMs > 0 ? `${h} hour${h !== 1 ? "s" : ""} ago` : `in ${h} hour${h !== 1 ? "s" : ""}` + const isPast = diffMs > 0 + const absSec = Math.abs(diffMs) / 1000 + + const phrase = (value: number, unit: string): string => { + const quantity = `${value} ${unit}${value !== 1 ? "s" : ""}` + return isPast ? `${quantity} ago` : `in ${quantity}` } - const d = Math.abs(diffDays) - return diffMs > 0 ? `${d} day${d !== 1 ? "s" : ""} ago` : `in ${d} day${d !== 1 ? "s" : ""}` + + if (absSec < 60) return "just now" + + // Round within each unit *before* testing its upper bound, so the displayed + // number can never overflow its own unit (e.g. "60 minutes ago"). + const minutes = Math.round(absSec / 60) + if (minutes < 60) return phrase(minutes, "minute") + + const hours = Math.round(absSec / 3600) + if (hours < 24) return phrase(hours, "hour") + + return phrase(Math.round(absSec / 86400), "day") } /** diff --git a/src/string-utils.ts b/src/string-utils.ts index 63fba18..32e9524 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -11,10 +11,22 @@ export function reverse(str: string): string { return str.split("").reverse().join("") } -// TODO: implement truncate — should truncate at a word boundary, with "..." -// counting toward maxLength. Return unchanged if str.length <= maxLength. +/** + * Truncate a string at a word boundary, appending "..." which counts toward + * maxLength. Returns the string unchanged if str.length <= maxLength. + */ export function truncate(str: string, maxLength: number): string { - throw new Error("not implemented") + if (!str || str.length <= maxLength) return str + + const ellipsis = "..." + if (maxLength <= ellipsis.length) return str.slice(0, Math.max(0, maxLength)) + + const budget = maxLength - ellipsis.length + const slice = str.slice(0, budget) + const lastSpace = slice.lastIndexOf(" ") + const head = lastSpace > 0 ? slice.slice(0, lastSpace) : slice + + return head.trimEnd() + ellipsis } export function slugify(str: string): string { @@ -24,8 +36,7 @@ export function slugify(str: string): string { .replace(/^-|-$/g, "") } -// BUG: This doesn't handle multiple consecutive spaces export function wordCount(str: string): number { if (!str.trim()) return 0 - return str.split(" ").length + return str.trim().split(/\s+/).length } diff --git a/src/task-manager.ts b/src/task-manager.ts index a920e85..1f1e48b 100644 --- a/src/task-manager.ts +++ b/src/task-manager.ts @@ -52,20 +52,40 @@ export class TaskManager { return true } - // TODO: implement — remove a task by id, return true if removed, false if not found + /** Remove a task by id. Returns true if removed, false if not found. */ remove(id: string): boolean { - throw new Error("not implemented") + return this.tasks.delete(id) } - // TODO: implement — update title/description/priority of a task - // return true if updated, false if not found + /** + * Update the title, description and/or priority of a task. + * Returns true if updated, false if not found. + */ update(id: string, changes: Partial>): boolean { - throw new Error("not implemented") + const task = this.tasks.get(id) + if (!task) return false + if (changes.title !== undefined) task.title = changes.title + if (changes.description !== undefined) task.description = changes.description + if (changes.priority !== undefined) task.priority = changes.priority + return true } - // TODO: implement — return all tasks sorted by the given field - // priority sort order: high > medium > low + /** + * Return all tasks sorted by the given field. + * priority: high > medium > low. createdAt: oldest first. + */ sortBy(field: "priority" | "createdAt" | "status"): Task[] { - throw new Error("not implemented") + const priorityOrder: Record = { high: 0, medium: 1, low: 2 } + const statusOrder: Record = { in_progress: 0, pending: 1, completed: 2 } + const tasks = Array.from(this.tasks.values()) + + switch (field) { + case "priority": + return tasks.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]) + case "createdAt": + return tasks.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + case "status": + return tasks.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]) + } } } diff --git a/src/validator.ts b/src/validator.ts index 27bf385..abf1ceb 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -4,25 +4,22 @@ /** * Returns true if the string is a valid email address. - * - * BUG: the regex does not allow subdomains (e.g. user@mail.example.com fails) - * and rejects valid TLDs longer than 4 chars (e.g. .museum, .travel). + * Supports subdomains (user@mail.example.com) and long TLDs (.museum, .travel). */ export function isEmail(value: string): boolean { - // BUG: too restrictive — missing subdomain support and long TLDs - return /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,4}$/.test(value) + const EMAIL = + /^[^\s@]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/ + return EMAIL.test(value) } /** * Returns true if the string is a valid URL (http or https). - * - * BUG: rejects URLs with ports (e.g. http://localhost:3000) + * Ports are permitted (e.g. http://localhost:3000). */ export function isUrl(value: string): boolean { try { const url = new URL(value) - // BUG: only allows http/https but also rejects valid port usage - return (url.protocol === "http:" || url.protocol === "https:") && url.port === "" + return url.protocol === "http:" || url.protocol === "https:" } catch { return false }