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:
igm
2026-01-11 12:34:10 +08:00
parent 7358129802
commit b1d07626f3
25 changed files with 639 additions and 300 deletions

View File

@@ -15,6 +15,7 @@
"@orpc/client": "^1.13.2", "@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte"; import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query"; import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null); let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false); let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) { function openRename(passkey: Passkey) {
selectedPasskey = passkey; selectedPasskey = passkey;
renameDialogOpen = true; renameDialogOpen = true;

View File

@@ -8,6 +8,7 @@ import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet"; import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
function handleNavClick() { function handleNavClick(): void {
open = false; open = false;
} }
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();

View File

@@ -6,6 +6,7 @@ import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();

View File

@@ -9,6 +9,7 @@ import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet"; import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -32,43 +33,17 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
// Nav items depend on whether we're in an org context // Nav items depend on whether we're in an org context
const navItems = $derived.by(() => { const navItems = $derived.by(() => {
if (currentSlug) { if (currentSlug) {
// In org context - org-specific navigation
return [ return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" }, { icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{ { icon: "chart", href: `/dashboard/${currentSlug}/performance`, label: "Performance" },
icon: "chart", { icon: "document", href: `/dashboard/${currentSlug}/reports`, label: "Reports" },
href: `/dashboard/${currentSlug}/performance`,
label: "Performance",
},
{
icon: "document",
href: `/dashboard/${currentSlug}/reports`,
label: "Reports",
},
]; ];
} }
// Outside org context - general navigation
return [ return [
{ icon: "home", href: "/", label: "Home" }, { icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" }, { icon: "building", href: "/dashboard", label: "Organizations" },
@@ -77,11 +52,11 @@ const navItems = $derived.by(() => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
function handleNavClick() { function handleNavClick(): void {
open = false; open = false;
} }
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();

View File

@@ -5,6 +5,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { getUserInitials } from "@reviq/common";
// Get optional org context (undefined outside org routes) // Get optional org context (undefined outside org routes)
const orgContext = getContext<{ currentUserRole: string | null } | undefined>( const orgContext = getContext<{ currentUserRole: string | null } | undefined>(
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
// Clear all cached queries
queryClient.clear(); queryClient.clear();
goto(resolve("/auth/login")); goto(resolve("/auth/login"));
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { Building2, Globe, Settings, Users } from "@lucide/svelte"; import { Globe, Settings, Users } from "@lucide/svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";

View File

@@ -1,31 +0,0 @@
/**
* Date formatting utilities for consistent display across the app
*/
/**
* Format a date for display in tables and lists
* Example: "Jan 15, 2024"
*/
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Format a date with time for detailed views
* Example: "Jan 15, 2024, 3:30 PM"
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View File

@@ -26,6 +26,7 @@ import {
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { formatDate, formatRelativeDate } from "@reviq/common";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null); let newlyCreatedToken = $state<string | null>(null);
let tokenCopied = $state(false); let tokenCopied = $state(false);
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const diffDays = Math.floor(
(Date.now() - new Date(date).getTime()) / 86400000,
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(date);
}
async function handleCreateToken(e: Event) { async function handleCreateToken(e: Event) {
e.preventDefault(); e.preventDefault();
if (!newTokenName.trim() || isCreating) { if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div> <div>
<p class="text-sm font-medium">{token.name}</p> <p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)} Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt} {#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)} · Last used {formatRelativeDate(token.lastUsedAt)}
{:else} {:else}
· Never used · Never used
{/if} {/if}

View File

@@ -8,9 +8,9 @@ import {
Star, Star,
Tablet, Tablet,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -54,31 +54,6 @@ function formatLocation(device: {
return parts.length > 0 ? parts.join(", ") : "Unknown location"; return parts.length > 0 ? parts.join(", ") : "Unknown location";
} }
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDeviceIcon(name: string) { function getDeviceIcon(name: string) {
const nameLower = name.toLowerCase(); const nameLower = name.toLowerCase();
if ( if (

View File

@@ -31,6 +31,7 @@ import {
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { LoadingButton } from "$lib/components/ui/loading-button"; import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { formatLongDate, formatRole } from "@reviq/common";
const inviteId = $derived(Number(page.params.inviteId)); const inviteId = $derived(Number(page.params.inviteId));
@@ -48,10 +49,8 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }), mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => { onSuccess: () => {
toast.success("You've joined the organization!"); toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] }); queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) { if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any)); goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
} else { } else {
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }), mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => { onSuccess: () => {
toast.success("Invitation declined"); toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
}, },
@@ -81,24 +79,6 @@ const declineMutation = createMutation(() => ({
}, },
})); }));
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
/** /**
* Check if invite is expiring soon (within 3 days) * Check if invite is expiring soon (within 3 days)
*/ */
@@ -187,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div> </div>
<div> <div>
<p class="text-sm font-medium">Sent on</p> <p class="text-sm font-medium">Sent on</p>
<p class="text-sm text-muted-foreground">{formatDate(new Date(invite.createdAt))}</p> <p class="text-sm text-muted-foreground">{formatLongDate(invite.createdAt)}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -197,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div> <div>
<p class="text-sm font-medium">Expires on</p> <p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}"> <p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))} {formatLongDate(invite.expiresAt)}
</p> </p>
</div> </div>
</div> </div>
@@ -207,7 +187,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert> <Alert>
<Clock class="h-4 w-4" /> <Clock class="h-4 w-4" />
<AlertDescription> <AlertDescription>
This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization. This invitation will expire soon. Accept it before {formatLongDate(invite.expiresAt)} to join the organization.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/if} {/if}

View File

@@ -10,6 +10,7 @@ import {
Star, Star,
Tablet, Tablet,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
@@ -56,36 +57,6 @@ function formatLocation(session: {
return parts.length > 0 ? parts.join(", ") : "Unknown location"; return parts.length > 0 ? parts.join(", ") : "Unknown location";
} }
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function parseUserAgent(userAgent: string): { function parseUserAgent(userAgent: string): {
browser: string; browser: string;
os: string; os: string;

View File

@@ -22,7 +22,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table/index.js"; } from "$lib/components/ui/table/index.js";
import { formatDate } from "$lib/utils/format-date.js"; import { formatDate } from "@reviq/common";
/** /**
* Admin Organizations list page * Admin Organizations list page

View File

@@ -37,7 +37,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table"; } from "$lib/components/ui/table";
import { formatDate } from "$lib/utils/format-date.js"; import { formatDate } from "@reviq/common";
/** /**
* Admin organization details page * Admin organization details page

View File

@@ -19,6 +19,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { formatRelativeDate, formatRole } from "@reviq/common";
/** /**
* Dashboard page - lists all organizations the user is a member of * Dashboard page - lists all organizations the user is a member of
@@ -47,41 +48,6 @@ $effect(() => {
); );
} }
}); });
/**
* Format date to relative or absolute string
*/
function formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return "Today";
}
if (days === 1) {
return "Yesterday";
}
if (days < 7) {
return `${days} days ago`;
}
if (days < 30) {
return `${Math.floor(days / 7)} weeks ago`;
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -133,7 +99,7 @@ function formatRole(role: string): string {
</CardHeader> </CardHeader>
<CardContent class="pt-0"> <CardContent class="pt-0">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))} From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -216,7 +182,7 @@ function formatRole(role: string): string {
</CardHeader> </CardHeader>
<CardContent class="pt-0"> <CardContent class="pt-0">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))} Created {formatRelativeDate(org.createdAt)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -77,6 +77,7 @@
"@orpc/client": "^1.13.2", "@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",
@@ -129,6 +130,17 @@
"typescript": "catalog:", "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": { "packages/db": {
"name": "@reviq/db", "name": "@reviq/db",
"version": "0.0.1", "version": "0.0.1",
@@ -406,6 +418,8 @@
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"], "@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
"@reviq/common": ["@reviq/common@workspace:packages/common"],
"@reviq/db": ["@reviq/db@workspace:packages/db"], "@reviq/db": ["@reviq/db@workspace:packages/db"],
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],

134
packages/common/README.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -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:"
}
}

View 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");
});
});

View 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);
}

View File

@@ -0,0 +1,9 @@
export {
formatDate,
formatDateTime,
formatLongDate,
formatRelativeDate,
formatRelativeTime,
type FormatRelativeDateOptions,
} from "./format-date.js";
export { formatRole, getUserInitials } from "./user.js";

View 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("");
});
});

View 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);
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}