diff --git a/apps/publisher-dashboard/package.json b/apps/publisher-dashboard/package.json index ec796e4..95fb8df 100644 --- a/apps/publisher-dashboard/package.json +++ b/apps/publisher-dashboard/package.json @@ -15,6 +15,7 @@ "@orpc/client": "^1.13.2", "@orpc/contract": "^1.13.2", "@reviq/api-contract": "workspace:*", + "@reviq/common": "workspace:*", "@simplewebauthn/browser": "^13.2.2", "@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query-devtools": "^6.0.3", diff --git a/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte b/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte index d017dda..672e9e4 100644 --- a/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte +++ b/apps/publisher-dashboard/src/lib/components/account/passkey-list.svelte @@ -1,5 +1,6 @@ @@ -133,7 +99,7 @@ function formatRole(role: string): string { - From {invite.invitedBy} · {formatDate(new Date(invite.createdAt))} + From {invite.invitedBy} · {formatRelativeDate(invite.createdAt)} @@ -216,7 +182,7 @@ function formatRole(role: string): string { - Created {formatDate(new Date(org.createdAt))} + Created {formatRelativeDate(org.createdAt)} diff --git a/bun.lock b/bun.lock index d305dc7..992ceaa 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,7 @@ "@orpc/client": "^1.13.2", "@orpc/contract": "^1.13.2", "@reviq/api-contract": "workspace:*", + "@reviq/common": "workspace:*", "@simplewebauthn/browser": "^13.2.2", "@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query-devtools": "^6.0.3", @@ -129,6 +130,17 @@ "typescript": "catalog:", }, }, + "packages/common": { + "name": "@reviq/common", + "version": "0.0.1", + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + }, + }, "packages/db": { "name": "@reviq/db", "version": "0.0.1", @@ -406,6 +418,8 @@ "@reviq/cli": ["@reviq/cli@workspace:apps/cli"], + "@reviq/common": ["@reviq/common@workspace:packages/common"], + "@reviq/db": ["@reviq/db@workspace:packages/db"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], diff --git a/packages/common/README.md b/packages/common/README.md new file mode 100644 index 0000000..fa14564 --- /dev/null +++ b/packages/common/README.md @@ -0,0 +1,134 @@ +# @reviq/common + +Shared utilities for all RevIQ applications. This package contains environment-agnostic code that works in browsers, Node.js, Bun, and other JavaScript runtimes. + +## Installation + +This package is used internally within the monorepo: + +```bash +# Add to your app's package.json +"dependencies": { + "@reviq/common": "workspace:*" +} +``` + +## Date Formatting + +Consistent date formatting utilities for displaying dates across the application. + +### Functions + +#### `formatDate(date)` + +Format a date for display in tables and lists. + +```typescript +import { formatDate } from "@reviq/common"; + +formatDate("2024-01-15"); // "Jan 15, 2024" +formatDate(new Date()); // "Jan 15, 2024" +``` + +#### `formatDateTime(date)` + +Format a date with time for detailed views. + +```typescript +import { formatDateTime } from "@reviq/common"; + +formatDateTime("2024-01-15T15:30:00"); // "Jan 15, 2024, 3:30 PM" +``` + +#### `formatLongDate(date)` + +Format a date in long form. + +```typescript +import { formatLongDate } from "@reviq/common"; + +formatLongDate("2024-01-15"); // "January 15, 2024" +``` + +#### `formatRelativeDate(date, options?)` + +Format a date as a relative time string. + +```typescript +import { formatRelativeDate } from "@reviq/common"; + +formatRelativeDate("2024-01-15"); // "Today" (if today is Jan 15) +formatRelativeDate("2024-01-14"); // "Yesterday" +formatRelativeDate("2024-01-10"); // "5 days ago" +formatRelativeDate("2024-01-01"); // "2 weeks ago" +formatRelativeDate("2023-06-15"); // "Jun 15, 2023" + +// With custom reference date +formatRelativeDate("2024-01-10", { now: new Date("2024-01-15") }); +``` + +#### `formatRelativeTime(date, options?)` + +Same as `formatRelativeDate`, but returns "Never" for null/undefined values. Useful for "last used" timestamps. + +```typescript +import { formatRelativeTime } from "@reviq/common"; + +formatRelativeTime("2024-01-15"); // "Today" +formatRelativeTime(null); // "Never" +formatRelativeTime(undefined); // "Never" +``` + +## User Utilities + +Helper functions for working with user data. + +### Functions + +#### `getUserInitials(user)` + +Generate initials from a user's display name or email. + +```typescript +import { getUserInitials } from "@reviq/common"; + +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); // "??" +``` + +#### `formatRole(role)` + +Format a role string for display (capitalizes first letter). + +```typescript +import { formatRole } from "@reviq/common"; + +formatRole("admin"); // "Admin" +formatRole("member"); // "Member" +formatRole("owner"); // "Owner" +``` + +## Development + +```bash +# Run tests +bun test + +# Build +bun run build + +# Type check +bun run typecheck +``` + +## Adding New Utilities + +When adding new utilities to this package: + +1. Create a new file in `src/` (e.g., `src/my-utility.ts`) +2. Add comprehensive tests in `src/my-utility.test.ts` +3. Export from `src/index.ts` +4. Run `bun test` to verify tests pass +5. Run `bun run build` to compile diff --git a/packages/common/eslint.config.js b/packages/common/eslint.config.js new file mode 100644 index 0000000..6ae3806 --- /dev/null +++ b/packages/common/eslint.config.js @@ -0,0 +1,15 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + ignores: ["**/*.test.ts"], + }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 0000000..862b824 --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,26 @@ +{ + "name": "@reviq/common", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", + "lint": "eslint . --cache", + "test": "bun test" + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/common/src/format-date.test.ts b/packages/common/src/format-date.test.ts new file mode 100644 index 0000000..45e49c9 --- /dev/null +++ b/packages/common/src/format-date.test.ts @@ -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"); + }); +}); diff --git a/packages/common/src/format-date.ts b/packages/common/src/format-date.ts new file mode 100644 index 0000000..1af514e --- /dev/null +++ b/packages/common/src/format-date.ts @@ -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); +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts new file mode 100644 index 0000000..1a86b20 --- /dev/null +++ b/packages/common/src/index.ts @@ -0,0 +1,9 @@ +export { + formatDate, + formatDateTime, + formatLongDate, + formatRelativeDate, + formatRelativeTime, + type FormatRelativeDateOptions, +} from "./format-date.js"; +export { formatRole, getUserInitials } from "./user.js"; diff --git a/packages/common/src/user.test.ts b/packages/common/src/user.test.ts new file mode 100644 index 0000000..0ee9ef0 --- /dev/null +++ b/packages/common/src/user.test.ts @@ -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(""); + }); +}); diff --git a/packages/common/src/user.ts b/packages/common/src/user.ts new file mode 100644 index 0000000..de60874 --- /dev/null +++ b/packages/common/src/user.ts @@ -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); +} diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 0000000..4a1e2b2 --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@macalinao/tsconfig/tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + } +}
- From {invite.invitedBy} · {formatDate(new Date(invite.createdAt))} + From {invite.invitedBy} · {formatRelativeDate(invite.createdAt)}
- Created {formatDate(new Date(org.createdAt))} + Created {formatRelativeDate(org.createdAt)}