Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@
/**
* 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
const diffDays = Math.round(Math.abs(diffHours) / 24)

if (Math.abs(diffSec) < 60) return "just now"
if (Math.abs(diffMin) < 60) {
Expand All @@ -25,7 +22,7 @@ export function formatRelative(date: Date, now: Date = new Date()): string {
const h = Math.round(Math.abs(diffHours))
return diffMs > 0 ? `${h} hour${h !== 1 ? "s" : ""} ago` : `in ${h} hour${h !== 1 ? "s" : ""}`
}
const d = Math.abs(diffDays)
const d = diffDays
return diffMs > 0 ? `${d} day${d !== 1 ? "s" : ""} ago` : `in ${d} day${d !== 1 ? "s" : ""}`
}

Expand Down
20 changes: 14 additions & 6 deletions src/string-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ 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.
export function truncate(str: string, maxLength: number): string {
throw new Error("not implemented")
if (str.length <= maxLength) return str
const ellipsis = "..."
const available = maxLength - ellipsis.length
if (available <= 0) return ellipsis.slice(0, Math.max(0, maxLength))
let cut = str.slice(0, available)
if (!/\s/.test(str.charAt(available))) {
const lastSpace = cut.search(/\s\S*$/)
if (lastSpace > 0) cut = cut.slice(0, lastSpace)
}
const trimmed = cut.trimEnd()
return (trimmed || str.slice(0, available)) + ellipsis
}

export function slugify(str: string): string {
Expand All @@ -24,8 +32,8 @@ 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
const trimmed = str.trim()
if (!trimmed) return 0
return trimmed.split(/\s+/).length
}
80 changes: 75 additions & 5 deletions test/date-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ describe("date-utils", () => {
expect(formatRelative(d, base)).toBe("3 hours ago")
})

test("1 day ago at exactly 36 hours", () => {
// 36 hours ago should be "2 days ago" with Math.round, "1 day ago" with Math.floor
test("rounds 36 hours up to 2 days ago", () => {
const d = new Date(base.getTime() - 36 * 3600_000)
expect(formatRelative(d, base)).toBe("2 days ago")
})
Expand All @@ -42,24 +41,95 @@ describe("date-utils", () => {
})
})

describe("formatRelative extras", () => {
test("just now for identical dates", () => {
expect(formatRelative(base, base)).toBe("just now")
})
test("just now for small future offset", () => {
expect(formatRelative(new Date(base.getTime() + 30_000), base)).toBe("just now")
})
test("in N minutes (future)", () => {
expect(formatRelative(new Date(base.getTime() + 5 * 60_000), base)).toBe("in 5 minutes")
})
test("in 1 minute (singular, future)", () => {
expect(formatRelative(new Date(base.getTime() + 61_000), base)).toBe("in 1 minute")
})
test("1 hour ago (singular)", () => {
expect(formatRelative(new Date(base.getTime() - 3600_000), base)).toBe("1 hour ago")
})
test("in 1 hour (singular, future)", () => {
expect(formatRelative(new Date(base.getTime() + 3600_000), base)).toBe("in 1 hour")
})
test("1 day ago at exactly 24 hours", () => {
expect(formatRelative(new Date(base.getTime() - 86400_000), base)).toBe("1 day ago")
})
test("in 2 days (future)", () => {
expect(formatRelative(new Date(base.getTime() + 2 * 86400_000), base)).toBe("in 2 days")
})
test("defaults now to current time", () => {
expect(formatRelative(new Date())).toBe("just now")
})
})

describe("isSameDay", () => {
test("same day returns true", () => {
expect(isSameDay(new Date("2024-06-15T09:00:00"), new Date("2024-06-15T23:59:59"))).toBe(true)
})
test("different day returns false", () => {
expect(isSameDay(new Date("2024-06-15"), new Date("2024-06-16"))).toBe(false)
expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 5, 16))).toBe(false)
})
})

describe("isSameDay extras", () => {
test("same day-of-month in different months is false", () => {
expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 6, 15))).toBe(false)
})
test("same day-of-month in different years is false", () => {
expect(isSameDay(new Date(2024, 5, 15), new Date(2025, 5, 15))).toBe(false)
})
test("midnight boundary is a different day", () => {
expect(isSameDay(new Date(2024, 5, 15, 23, 59, 59), new Date(2024, 5, 16, 0, 0, 0))).toBe(false)
})
test("a date is the same day as itself", () => {
const d = new Date(2024, 5, 15)
expect(isSameDay(d, d)).toBe(true)
})
})

describe("addDays", () => {
test("adds days", () => {
const result = addDays(new Date("2024-06-15"), 3)
const result = addDays(new Date(2024, 5, 15), 3)
expect(result.getDate()).toBe(18)
})
test("does not mutate original", () => {
const d = new Date("2024-06-15")
const d = new Date(2024, 5, 15)
addDays(d, 5)
expect(d.getDate()).toBe(15)
})
test("subtracts days with negative value", () => {
expect(addDays(new Date(2024, 5, 15), -5).getDate()).toBe(10)
})
test("zero days returns equal date as a new object", () => {
const d = new Date(2024, 5, 15)
const r = addDays(d, 0)
expect(r.getTime()).toBe(d.getTime())
expect(r).not.toBe(d)
})
test("rolls over month boundary", () => {
const r = addDays(new Date(2024, 0, 30), 3)
expect(r.getMonth()).toBe(1)
expect(r.getDate()).toBe(2)
})
test("rolls over year boundary", () => {
const r = addDays(new Date(2024, 11, 30), 3)
expect(r.getFullYear()).toBe(2025)
expect(r.getMonth()).toBe(0)
expect(r.getDate()).toBe(2)
})
test("handles leap day", () => {
const r = addDays(new Date(2024, 1, 28), 1)
expect(r.getMonth()).toBe(1)
expect(r.getDate()).toBe(29)
})
})
})
75 changes: 73 additions & 2 deletions test/string-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,36 @@ describe("string-utils", () => {
test("handles empty string", () => {
expect(capitalize("")).toBe("")
})
test("leaves already-capitalized string unchanged", () => {
expect(capitalize("Hello")).toBe("Hello")
})
test("only changes the first character", () => {
expect(capitalize("hELLO")).toBe("HELLO")
})
test("handles single character", () => {
expect(capitalize("a")).toBe("A")
})
test("leaves non-letter first character unchanged", () => {
expect(capitalize("1abc")).toBe("1abc")
})
})

describe("reverse", () => {
test("reverses a string", () => {
expect(reverse("hello")).toBe("olleh")
})
test("handles empty string", () => {
expect(reverse("")).toBe("")
})
test("handles single character", () => {
expect(reverse("a")).toBe("a")
})
test("palindrome is unchanged", () => {
expect(reverse("racecar")).toBe("racecar")
})
test("preserves spaces and punctuation", () => {
expect(reverse("a b!")).toBe("!b a")
})
})

describe("slugify", () => {
Expand All @@ -24,12 +48,33 @@ describe("string-utils", () => {
test("strips leading/trailing hyphens", () => {
expect(slugify(" hello ")).toBe("hello")
})
test("collapses runs of special characters into one hyphen", () => {
expect(slugify("Hello, World!!")).toBe("hello-world")
})
test("handles empty string", () => {
expect(slugify("")).toBe("")
})
test("returns empty string when no alphanumerics", () => {
expect(slugify("!!!")).toBe("")
})
test("keeps digits", () => {
expect(slugify("Top 10 Tips")).toBe("top-10-tips")
})
})

describe("wordCount", () => {
test("counts words", () => {
expect(wordCount("hello world")).toBe(2)
})
test("splits on tabs and newlines", () => {
expect(wordCount("a\tb\nc")).toBe(3)
})
test("counts a single word", () => {
expect(wordCount("hello")).toBe(1)
})
test("ignores leading and trailing single space", () => {
expect(wordCount(" hello world ")).toBe(2)
})
test("handles multiple consecutive spaces", () => {
expect(wordCount("hello world")).toBe(2)
})
Expand All @@ -49,11 +94,37 @@ describe("string-utils", () => {
expect(truncate("hello world foo", 11)).toBe("hello...")
})
test("ellipsis counts toward maxLength", () => {
// "hello..." is 8 chars — fits in maxLength 8
expect(truncate("hello world", 8).length).toBeLessThanOrEqual(8)
expect(truncate("hello world", 8)).toBe("hello...")
})
test("handles string shorter than ellipsis", () => {
expect(truncate("hi", 5)).toBe("hi")
})
test("returns string unchanged at exactly maxLength", () => {
expect(truncate("hello", 5)).toBe("hello")
})
test("hard-cuts a single long word", () => {
expect(truncate("abcdefghij", 8)).toBe("abcde...")
})
test("result never exceeds maxLength", () => {
const input = "the quick brown fox jumps over the lazy dog"
for (let n = 0; n <= 30; n++) {
expect(truncate(input, n).length).toBeLessThanOrEqual(n)
}
})
test("maxLength 1 yields truncated ellipsis", () => {
expect(truncate("hello", 1)).toBe(".")
})
test("maxLength 2 yields truncated ellipsis", () => {
expect(truncate("hello", 2)).toBe("..")
})
test("maxLength 3 yields just the ellipsis", () => {
expect(truncate("hello", 3)).toBe("...")
})
test("falls back to hard cut when leading whitespace leaves empty word cut", () => {
expect(truncate(" hello world", 8)).toBe(" hel...")
})
test("handles empty string", () => {
expect(truncate("", 5)).toBe("")
})
})
})
Loading