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/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",

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
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) {
selectedPasskey = passkey;
renameDialogOpen = true;

View File

@@ -8,6 +8,7 @@ import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props {
class?: string;
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();

View File

@@ -6,6 +6,7 @@ import { page } from "$app/stores";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props {
class?: string;
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();

View File

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

View File

@@ -5,6 +5,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { getUserInitials } from "@reviq/common";
// Get optional org context (undefined outside org routes)
const orgContext = getContext<{ currentUserRole: string | null } | undefined>(
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
// Clear all cached queries
queryClient.clear();
goto(resolve("/auth/login"));
} catch (error) {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
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 { resolve } from "$app/paths";
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";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { formatDate, formatRelativeDate } from "@reviq/common";
const queryClient = useQueryClient();
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
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) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
· Last used {formatRelativeDate(token.lastUsedAt)}
{:else}
· Never used
{/if}

View File

@@ -8,9 +8,9 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -54,31 +54,6 @@ function formatLocation(device: {
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) {
const nameLower = name.toLowerCase();
if (

View File

@@ -31,6 +31,7 @@ import {
} from "$lib/components/ui/card";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator";
import { formatLongDate, formatRole } from "@reviq/common";
const inviteId = $derived(Number(page.params.inviteId));
@@ -48,10 +49,8 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => {
toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
} else {
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => {
toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
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)
*/
@@ -187,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div>
<div>
<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 class="flex items-center gap-3">
@@ -197,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div>
<p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))}
{formatLongDate(invite.expiresAt)}
</p>
</div>
</div>
@@ -207,7 +187,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert>
<Clock class="h-4 w-4" />
<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>
</Alert>
{/if}

View File

@@ -10,6 +10,7 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
@@ -56,36 +57,6 @@ function formatLocation(session: {
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): {
browser: string;
os: string;

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import {
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { formatRelativeDate, formatRole } from "@reviq/common";
/**
* 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>
<svelte:head>
@@ -133,7 +99,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))}
From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p>
</CardContent>
</Card>
@@ -216,7 +182,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))}
Created {formatRelativeDate(org.createdAt)}
</p>
</CardContent>
</Card>