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:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} · {formatDate(new Date(invite.createdAt))}
|
From {invite.invitedBy} · {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>
|
||||||
|
|||||||
14
bun.lock
14
bun.lock
@@ -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
134
packages/common/README.md
Normal 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
|
||||||
15
packages/common/eslint.config.js
Normal file
15
packages/common/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...configs.fast,
|
||||||
|
{
|
||||||
|
ignores: ["**/*.test.ts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
26
packages/common/package.json
Normal file
26
packages/common/package.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
6
packages/common/tsconfig.json
Normal file
6
packages/common/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user