Add packages/common for shared utilities
Create new @reviq/common package with environment-agnostic utilities: - Date formatting: formatDate, formatDateTime, formatLongDate, formatRelativeDate, formatRelativeTime - User utilities: getUserInitials, formatRole Consolidate date formatting from publisher-dashboard into shared package. All utilities include comprehensive test coverage with bun:test. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
139
packages/common/src/format-date.test.ts
Normal file
139
packages/common/src/format-date.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatLongDate,
|
||||
formatRelativeDate,
|
||||
formatRelativeTime,
|
||||
} from "./format-date.js";
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("formats a Date object", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
expect(formatDate(date)).toBe("Jan 15, 2024");
|
||||
});
|
||||
|
||||
test("formats a date string", () => {
|
||||
expect(formatDate("2024-01-15T12:00:00Z")).toBe("Jan 15, 2024");
|
||||
});
|
||||
|
||||
test("formats different months correctly", () => {
|
||||
expect(formatDate("2024-06-01T12:00:00Z")).toBe("Jun 1, 2024");
|
||||
expect(formatDate("2024-12-25T12:00:00Z")).toBe("Dec 25, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateTime", () => {
|
||||
test("formats date with time", () => {
|
||||
const date = new Date("2024-01-15T15:30:00Z");
|
||||
const result = formatDateTime(date);
|
||||
// Contains date parts
|
||||
expect(result).toContain("Jan");
|
||||
expect(result).toContain("15");
|
||||
expect(result).toContain("2024");
|
||||
// Contains time (format may vary by locale)
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("formats a date string with time", () => {
|
||||
const result = formatDateTime("2024-01-15T08:00:00Z");
|
||||
expect(result).toContain("Jan");
|
||||
expect(result).toContain("15");
|
||||
expect(result).toContain("2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLongDate", () => {
|
||||
test("formats date in long form", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
expect(formatLongDate(date)).toBe("January 15, 2024");
|
||||
});
|
||||
|
||||
test("formats a date string in long form", () => {
|
||||
expect(formatLongDate("2024-06-01T12:00:00Z")).toBe("June 1, 2024");
|
||||
});
|
||||
|
||||
test("formats December correctly", () => {
|
||||
expect(formatLongDate("2024-12-25T12:00:00Z")).toBe("December 25, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeDate", () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z");
|
||||
|
||||
test("returns 'Today' for same day", () => {
|
||||
const today = new Date("2024-01-15T08:00:00Z");
|
||||
expect(formatRelativeDate(today, { now })).toBe("Today");
|
||||
});
|
||||
|
||||
test("returns 'Yesterday' for previous day", () => {
|
||||
const yesterday = new Date("2024-01-14T12:00:00Z");
|
||||
expect(formatRelativeDate(yesterday, { now })).toBe("Yesterday");
|
||||
});
|
||||
|
||||
test("returns 'X days ago' for 2-6 days", () => {
|
||||
expect(formatRelativeDate("2024-01-13T12:00:00Z", { now })).toBe(
|
||||
"2 days ago",
|
||||
);
|
||||
expect(formatRelativeDate("2024-01-12T12:00:00Z", { now })).toBe(
|
||||
"3 days ago",
|
||||
);
|
||||
expect(formatRelativeDate("2024-01-09T12:00:00Z", { now })).toBe(
|
||||
"6 days ago",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns '1 week ago' for exactly 7 days", () => {
|
||||
const oneWeekAgo = new Date("2024-01-08T12:00:00Z");
|
||||
expect(formatRelativeDate(oneWeekAgo, { now })).toBe("1 week ago");
|
||||
});
|
||||
|
||||
test("returns 'X weeks ago' for 2-4 weeks", () => {
|
||||
expect(formatRelativeDate("2024-01-01T12:00:00Z", { now })).toBe(
|
||||
"2 weeks ago",
|
||||
);
|
||||
expect(formatRelativeDate("2023-12-25T12:00:00Z", { now })).toBe(
|
||||
"3 weeks ago",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns formatted date for older dates in same year", () => {
|
||||
// Use a "now" later in the year to test same-year formatting
|
||||
const laterNow = new Date("2024-06-15T12:00:00Z");
|
||||
const result = formatRelativeDate("2024-01-15T12:00:00Z", { now: laterNow });
|
||||
expect(result).toBe("Jan 15");
|
||||
});
|
||||
|
||||
test("returns formatted date with year for different year", () => {
|
||||
const result = formatRelativeDate("2023-06-15T12:00:00Z", { now });
|
||||
expect(result).toBe("Jun 15, 2023");
|
||||
});
|
||||
|
||||
test("accepts string input", () => {
|
||||
expect(formatRelativeDate("2024-01-15T08:00:00Z", { now })).toBe("Today");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z");
|
||||
|
||||
test("returns 'Never' for null", () => {
|
||||
expect(formatRelativeTime(null)).toBe("Never");
|
||||
});
|
||||
|
||||
test("returns 'Never' for undefined", () => {
|
||||
expect(formatRelativeTime(undefined)).toBe("Never");
|
||||
});
|
||||
|
||||
test("returns relative date for valid input", () => {
|
||||
expect(formatRelativeTime("2024-01-15T08:00:00Z", { now })).toBe("Today");
|
||||
expect(formatRelativeTime("2024-01-14T12:00:00Z", { now })).toBe(
|
||||
"Yesterday",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Date objects", () => {
|
||||
const date = new Date("2024-01-13T12:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("2 days ago");
|
||||
});
|
||||
});
|
||||
128
packages/common/src/format-date.ts
Normal file
128
packages/common/src/format-date.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Date formatting utilities for consistent display across the app.
|
||||
* Works in all JavaScript environments (browser, Node.js, Bun, etc.)
|
||||
*/
|
||||
|
||||
type DateInput = string | Date;
|
||||
|
||||
/**
|
||||
* Safely convert a date input to a Date object.
|
||||
*/
|
||||
function toDate(date: DateInput): Date {
|
||||
return typeof date === "string" ? new Date(date) : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference in days between two dates.
|
||||
*/
|
||||
function daysDiff(from: Date, to: Date): number {
|
||||
const diffMs = to.getTime() - from.getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display in tables and lists.
|
||||
* @example formatDate("2024-01-15") // "Jan 15, 2024"
|
||||
*/
|
||||
export function formatDate(date: DateInput): string {
|
||||
const d = toDate(date);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date with time for detailed views.
|
||||
* @example formatDateTime("2024-01-15T15:30:00") // "Jan 15, 2024, 3:30 PM"
|
||||
*/
|
||||
export function formatDateTime(date: DateInput): string {
|
||||
const d = toDate(date);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date in long form.
|
||||
* @example formatLongDate("2024-01-15") // "January 15, 2024"
|
||||
*/
|
||||
export function formatLongDate(date: DateInput): string {
|
||||
const d = toDate(date);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for relative date formatting.
|
||||
*/
|
||||
export interface FormatRelativeDateOptions {
|
||||
/**
|
||||
* Reference date to compare against. Defaults to current date.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a relative time string.
|
||||
* @example
|
||||
* formatRelativeDate("2024-01-15") // "Today" (if today is Jan 15)
|
||||
* formatRelativeDate("2024-01-14") // "Yesterday" (if today is Jan 15)
|
||||
* formatRelativeDate("2024-01-10") // "5 days ago" (if today is Jan 15)
|
||||
* formatRelativeDate("2024-01-01") // "2 weeks ago" (if today is Jan 15)
|
||||
* formatRelativeDate("2023-06-15") // "Jun 15, 2023" (older dates)
|
||||
*/
|
||||
export function formatRelativeDate(
|
||||
date: DateInput,
|
||||
options?: FormatRelativeDateOptions,
|
||||
): string {
|
||||
const d = toDate(date);
|
||||
const now = options?.now ?? new Date();
|
||||
const diffDays = daysDiff(d, now);
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${String(diffDays)} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return weeks === 1 ? "1 week ago" : `${String(weeks)} weeks ago`;
|
||||
}
|
||||
|
||||
// For older dates, show the actual date
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a relative time string, with "Never" for null values.
|
||||
* Useful for displaying "last used" timestamps.
|
||||
* @example
|
||||
* formatRelativeTime("2024-01-15") // "Today"
|
||||
* formatRelativeTime(null) // "Never"
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: DateInput | null | undefined,
|
||||
options?: FormatRelativeDateOptions,
|
||||
): string {
|
||||
if (date === null || date === undefined) {
|
||||
return "Never";
|
||||
}
|
||||
return formatRelativeDate(date, options);
|
||||
}
|
||||
9
packages/common/src/index.ts
Normal file
9
packages/common/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatLongDate,
|
||||
formatRelativeDate,
|
||||
formatRelativeTime,
|
||||
type FormatRelativeDateOptions,
|
||||
} from "./format-date.js";
|
||||
export { formatRole, getUserInitials } from "./user.js";
|
||||
84
packages/common/src/user.test.ts
Normal file
84
packages/common/src/user.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatRole, getUserInitials } from "./user.js";
|
||||
|
||||
describe("getUserInitials", () => {
|
||||
test("returns '??' for null", () => {
|
||||
expect(getUserInitials(null)).toBe("??");
|
||||
});
|
||||
|
||||
test("returns '??' for undefined", () => {
|
||||
expect(getUserInitials(undefined)).toBe("??");
|
||||
});
|
||||
|
||||
test("returns initials from display name with two words", () => {
|
||||
expect(
|
||||
getUserInitials({ displayName: "John Doe", email: "john@example.com" }),
|
||||
).toBe("JD");
|
||||
});
|
||||
|
||||
test("returns initials from display name with multiple words", () => {
|
||||
expect(
|
||||
getUserInitials({
|
||||
displayName: "John Michael Doe",
|
||||
email: "john@example.com",
|
||||
}),
|
||||
).toBe("JD");
|
||||
});
|
||||
|
||||
test("returns first two characters for single word display name", () => {
|
||||
expect(
|
||||
getUserInitials({ displayName: "John", email: "john@example.com" }),
|
||||
).toBe("JO");
|
||||
});
|
||||
|
||||
test("returns uppercase initials", () => {
|
||||
expect(
|
||||
getUserInitials({
|
||||
displayName: "john doe",
|
||||
email: "john@example.com",
|
||||
}),
|
||||
).toBe("JD");
|
||||
});
|
||||
|
||||
test("falls back to email when no display name", () => {
|
||||
expect(getUserInitials({ email: "john@example.com" })).toBe("JO");
|
||||
});
|
||||
|
||||
test("handles null display name", () => {
|
||||
expect(
|
||||
getUserInitials({ displayName: null, email: "alice@example.com" }),
|
||||
).toBe("AL");
|
||||
});
|
||||
|
||||
test("handles empty display name", () => {
|
||||
expect(
|
||||
getUserInitials({ displayName: "", email: "bob@example.com" }),
|
||||
).toBe("BO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRole", () => {
|
||||
test("capitalizes 'admin'", () => {
|
||||
expect(formatRole("admin")).toBe("Admin");
|
||||
});
|
||||
|
||||
test("capitalizes 'member'", () => {
|
||||
expect(formatRole("member")).toBe("Member");
|
||||
});
|
||||
|
||||
test("capitalizes 'owner'", () => {
|
||||
expect(formatRole("owner")).toBe("Owner");
|
||||
});
|
||||
|
||||
test("handles already capitalized roles", () => {
|
||||
expect(formatRole("Admin")).toBe("Admin");
|
||||
});
|
||||
|
||||
test("handles single character", () => {
|
||||
expect(formatRole("a")).toBe("A");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(formatRole("")).toBe("");
|
||||
});
|
||||
});
|
||||
51
packages/common/src/user.ts
Normal file
51
packages/common/src/user.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* User-related utility functions
|
||||
*/
|
||||
|
||||
interface UserLike {
|
||||
displayName?: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initials from a user's display name or email.
|
||||
* - For display names with 2+ words: first and last initials (e.g., "John Doe" -> "JD")
|
||||
* - For single word names: first 2 characters (e.g., "John" -> "JO")
|
||||
* - Falls back to first 2 characters of email if no display name
|
||||
* - Returns "??" if user is null/undefined
|
||||
*
|
||||
* @example
|
||||
* getUserInitials({ displayName: "John Doe", email: "john@example.com" }) // "JD"
|
||||
* getUserInitials({ displayName: "John", email: "john@example.com" }) // "JO"
|
||||
* getUserInitials({ email: "john@example.com" }) // "JO"
|
||||
* getUserInitials(null) // "??"
|
||||
*/
|
||||
export function getUserInitials(user: UserLike | null | undefined): string {
|
||||
if (!user) {
|
||||
return "??";
|
||||
}
|
||||
|
||||
if (user.displayName) {
|
||||
const parts = user.displayName.split(" ");
|
||||
const firstPart = parts[0];
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (parts.length >= 2 && firstPart && lastPart) {
|
||||
return (firstPart.charAt(0) + lastPart.charAt(0)).toUpperCase();
|
||||
}
|
||||
return user.displayName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return user.email.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a role string for display (capitalizes first letter).
|
||||
*
|
||||
* @example
|
||||
* formatRole("admin") // "Admin"
|
||||
* formatRole("member") // "Member"
|
||||
* formatRole("owner") // "Owner"
|
||||
*/
|
||||
export function formatRole(role: string): string {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
Reference in New Issue
Block a user