This commit is contained in:
Michael Freno
2025-12-19 22:41:13 -05:00
parent 3e606d2354
commit ee9db9f674
14 changed files with 136 additions and 443 deletions

View File

@@ -31,12 +31,6 @@ function AppLayout(props: { children: any }) {
let lastScrollY = 0;
const SCROLL_THRESHOLD = 100;
// Compute left margin reactively
const leftMargin = () => {
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return isMobile ? 0 : leftBarSize();
};
createEffect(() => {
const handleResize = () => {
const isMobile = window.innerWidth < 768; // md breakpoint
@@ -47,9 +41,7 @@ function AppLayout(props: { children: any }) {
setRightBarVisible(true);
}
// On mobile, leftbar overlays (don't subtract its size)
const leftOffset = isMobile ? 0 : leftBarSize();
const newWidth = window.innerWidth - leftOffset - rightBarSize();
const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
setCenterWidth(newWidth);
};
@@ -63,10 +55,7 @@ function AppLayout(props: { children: any }) {
// Recalculate when bar sizes change (visibility or actual resize)
createEffect(() => {
const isMobile = window.innerWidth < 768;
// On mobile, leftbar overlays (don't subtract its size)
const leftOffset = isMobile ? 0 : leftBarSize();
const newWidth = window.innerWidth - leftOffset - rightBarSize();
const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
setCenterWidth(newWidth);
});
@@ -170,31 +159,13 @@ function AppLayout(props: { children: any }) {
return (
<>
<Show when={!barsInitialized()}>
<div class="bg-base fixed inset-0 z-100">
<TerminalSplash />
</div>
</Show>
<div
class="flex max-w-screen flex-row"
style={{
opacity: barsInitialized() ? "1" : "0",
transition: "opacity 0.3s ease-in-out"
}}
>
<div class="flex max-w-screen flex-row">
<LeftBar />
<div
class="bg-base relative min-h-screen rounded-t-lg shadow-2xl"
style={{
width: `${centerWidth()}px`,
"margin-left": `${leftMargin()}px`
}}
onClick={() => {
const isMobile = window.innerWidth < 768;
if (isMobile && leftBarVisible()) {
setLeftBarVisible(false);
}
"margin-left": `${leftBarSize()}px`
}}
>
<Show when={barsInitialized()} fallback={<TerminalSplash />}>

View File

@@ -74,15 +74,15 @@ export const ActivityHeatmap: Component<{
{() => (
<div class="flex flex-col gap-[2px]">
<For each={Array(7)}>
{() => <SkeletonBox class="h-2 w-2 rounded-[2px]" />}
{() => <div class="bg-surface0 h-2 w-2 rounded-[2px]" />}
</For>
</div>
)}
</For>
</div>
{/* Centered spinner overlay */}
<div class="bg-base/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<SkeletonBox class="h-8 w-8" />
<div class="absolute inset-0 top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2">
<SkeletonBox class="-ml-2 h-8 w-8" />
</div>
</div>
}

View File

@@ -9,7 +9,6 @@ import {
onCleanup
} from "solid-js";
import { api } from "~/lib/api";
import { TerminalSplash } from "./TerminalSplash";
import { insertSoftHyphens } from "~/lib/client-utils";
import GitHub from "./icons/GitHub";
import LinkedIn from "./icons/LinkedIn";
@@ -397,13 +396,15 @@ export function LeftBar() {
fallback={
<For each={[1, 2, 3]}>
{() => (
<div class="flex flex-col gap-2">
<div class="relative overflow-hidden">
<SkeletonBox class="float-right mb-1 ml-2 h-12 w-16" />
<SkeletonText class="mb-1 w-full" />
<SkeletonText class="w-3/4" />
<div class="flex flex-col">
<div class="flex items-start gap-2">
<div class="flex flex-1 flex-col gap-2">
<SkeletonText class="h-6 w-full" />
<SkeletonText class="h-6 w-full" />
</div>
<SkeletonText class="clear-both w-24 text-xs" />
<SkeletonBox class="h-14 w-16 shrink-0" />
</div>
<SkeletonText class="mt-2 h-6 w-full" />
</div>
)}
</For>

View File

@@ -1,7 +1,6 @@
import { Component, For, Show } from "solid-js";
import { Typewriter } from "./Typewriter";
import { SkeletonText, SkeletonBox } from "./SkeletonLoader";
import { formatRelativeDate } from "~/lib/date-utils";
interface Commit {
sha: string;
@@ -17,6 +16,28 @@ export const RecentCommits: Component<{
title: string;
loading?: boolean;
}> = (props) => {
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric"
});
}
};
return (
<div class="flex flex-col gap-3">
<h3 class="text-subtext0 text-sm font-semibold">{props.title}</h3>
@@ -69,7 +90,7 @@ export const RecentCommits: Component<{
</span>
</div>
<span class="text-subtext1 shrink-0 text-[10px]">
{formatRelativeDate(commit.date)}
{formatDate(commit.date)}
</span>
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span class="bg-surface1 shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">

View File

@@ -1,5 +1,6 @@
import { Accessor, createContext, useContext, createMemo } from "solid-js";
import { createSignal } from "solid-js";
import { hapticFeedback } from "~/lib/client-utils";
const BarsContext = createContext<{
leftBarSize: Accessor<number>;
@@ -97,11 +98,14 @@ export function BarsProvider(props: { children: any }) {
return barsInitialized() ? syncedBarSize() : naturalSize;
});
// Wrap visibility setters with haptic feedback
const setLeftBarVisible = (visible: boolean) => {
hapticFeedback(50);
_setLeftBarVisible(visible);
};
const setRightBarVisible = (visible: boolean) => {
hapticFeedback(50);
_setRightBarVisible(visible);
};
return (

View File

@@ -205,8 +205,7 @@ export function debounce<T extends (...args: any[]) => any>(
// ============================================================================
/**
* Validates that a comment body meets requirements (client-side UX only)
* Server validation is in src/server/api/schemas/validation.ts
* Validates that a comment body meets requirements
*/
export function isValidCommentBody(body: string): boolean {
return body.trim().length > 0 && body.length <= 10000;

View File

@@ -1,62 +1,18 @@
/**
* Client-side Date Utilities
*
* ⚠️ DEPRECATED: For new code, use server-side date utilities in src/server/date-utils.ts
*
* This function is kept for backward compatibility with existing client code.
* Server-side code should use src/server/date-utils.ts instead.
*
* Note: This previously had a hardcoded +4 hour offset which was incorrect.
* Now properly returns UTC time.
*/
/**
* Get current UTC date/time formatted for SQL insert
*
* @deprecated Use server-side getSQLFormattedDate from ~/server/utils instead
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
* Formats current date to match SQL datetime format
* Note: Adds 4 hours to match server timezone (EST)
* Returns format: YYYY-MM-DD HH:MM:SS
*/
export function getSQLFormattedDate(): string {
const date = new Date();
date.setHours(date.getHours() + 4);
const year = date.getUTCFullYear();
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
const hours = `${date.getUTCHours()}`.padStart(2, "0");
const minutes = `${date.getUTCMinutes()}`.padStart(2, "0");
const seconds = `${date.getUTCSeconds()}`.padStart(2, "0");
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
const hours = `${date.getHours()}`.padStart(2, "0");
const minutes = `${date.getMinutes()}`.padStart(2, "0");
const seconds = `${date.getSeconds()}`.padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Format a date for relative display (client-side)
* e.g., "5m ago", "2h ago", "Dec 19"
*
* @param dateString SQL date string or ISO string
* @returns Formatted relative time string
*/
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return "just now";
} else if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: diffDays > 365 ? "numeric" : undefined
});
}
}

View File

@@ -1,18 +1,9 @@
/**
* Client-Side Validation Utilities (UX Only - NOT Security)
*
* ⚠️ IMPORTANT: These functions are for user experience only!
*
* Server-side validation in src/server/api/schemas/validation.ts is the
* source of truth and security boundary. These client functions provide
* instant feedback before submission but can be bypassed.
*
* Always rely on server validation for security.
* Form validation utilities
*/
/**
* Validate email format (client-side UX only)
* Server validation is in src/server/api/schemas/validation.ts
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -20,8 +11,7 @@ export function isValidEmail(email: string): boolean {
}
/**
* Validate password strength (client-side UX only)
* Server validation is in src/server/api/schemas/validation.ts
* Validate password strength
*/
export function validatePassword(password: string): {
isValid: boolean;
@@ -51,8 +41,7 @@ export function validatePassword(password: string): {
}
/**
* Check if two passwords match (client-side UX only)
* Server validation is in src/server/api/schemas/validation.ts
* Check if two passwords match
*/
export function passwordsMatch(
password: string,
@@ -62,8 +51,7 @@ export function passwordsMatch(
}
/**
* Validate display name (client-side UX only)
* Server validation is in src/server/api/schemas/validation.ts
* Validate display name
*/
export function isValidDisplayName(name: string): boolean {
return name.trim().length >= 1 && name.trim().length <= 50;

View File

@@ -42,9 +42,6 @@ export default function Home() {
here (github).
</a>
</Typewriter>
<Typewriter speed={100} keepAlive={2000}>
Check the side bar(s) for more info and important links.
</Typewriter>
<div class="pt-8 text-center">
<div class="pb-4">Some of my recent projects:</div>
<div class="flex flex-col items-center gap-2 2xl:flex-row 2xl:items-start 2xl:justify-center">
@@ -92,7 +89,7 @@ export default function Home() {
</div>
{/* Life and Lineage */}
<div class="border-surface0 flex w-full max-w-5/6 flex-col gap-2 rounded-md border-2 p-4 text-center 2xl:mr-4">
<div class="border-surface0 flex w-full max-w-3/4 flex-col gap-2 rounded-md border-2 p-4 text-center 2xl:mr-4">
<div>My mobile game:</div>
<a
class="text-blue hover-underline-animation mx-auto w-fit"

View File

@@ -7,13 +7,6 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user";
import {
emailSchema,
passwordSchema,
registrationSchema,
loginSchema,
passwordResetSchema
} from "~/server/api/schemas/validation";
// Helper to create JWT token
async function createJWT(
@@ -246,7 +239,7 @@ export const authRouter = createTRPCRouter({
emailLogin: publicProcedure
.input(
z.object({
email: emailSchema,
email: z.string().email(),
token: z.string(),
rememberMe: z.boolean().optional()
})
@@ -324,7 +317,7 @@ export const authRouter = createTRPCRouter({
emailVerification: publicProcedure
.input(
z.object({
email: emailSchema,
email: z.string().email(),
token: z.string()
})
)
@@ -367,9 +360,22 @@ export const authRouter = createTRPCRouter({
// Email/password registration
emailRegistration: publicProcedure
.input(registrationSchema)
.input(
z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const { email, password } = input;
const { email, password, passwordConfirmation } = input;
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch"
});
}
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
@@ -405,7 +411,13 @@ export const authRouter = createTRPCRouter({
// Email/password login
emailPasswordLogin: publicProcedure
.input(loginSchema)
.input(
z.object({
email: z.string().email(),
password: z.string(),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
@@ -465,7 +477,7 @@ export const authRouter = createTRPCRouter({
requestEmailLinkLogin: publicProcedure
.input(
z.object({
email: emailSchema,
email: z.string().email(),
rememberMe: z.boolean().optional()
})
)
@@ -570,7 +582,7 @@ export const authRouter = createTRPCRouter({
// Request password reset
requestPasswordReset: publicProcedure
.input(z.object({ email: emailSchema }))
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -669,9 +681,22 @@ export const authRouter = createTRPCRouter({
// Reset password with token
resetPassword: publicProcedure
.input(passwordResetSchema)
.input(
z.object({
token: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const { token, newPassword } = input;
const { token, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
try {
// Verify JWT token
@@ -718,7 +743,7 @@ export const authRouter = createTRPCRouter({
// Resend email verification
resendEmailVerification: publicProcedure
.input(z.object({ email: emailSchema }))
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -827,3 +852,4 @@ export const authRouter = createTRPCRouter({
return { success: true };
})
});

View File

@@ -11,13 +11,6 @@ import {
import { setCookie } from "vinxi/http";
import type { User } from "~/types/user";
import { toUserProfile } from "~/types/user";
import {
updateEmailSchema,
updateDisplayNameSchema,
passwordChangeSchema,
passwordSetSchema,
deleteAccountSchema
} from "~/server/api/schemas/validation";
export const userRouter = createTRPCRouter({
// Get current user profile
@@ -50,7 +43,7 @@ export const userRouter = createTRPCRouter({
// Update email
updateEmail: publicProcedure
.input(updateEmailSchema)
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -87,7 +80,7 @@ export const userRouter = createTRPCRouter({
// Update display name
updateDisplayName: publicProcedure
.input(updateDisplayNameSchema)
.input(z.object({ displayName: z.string().min(1).max(50) }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -118,9 +111,7 @@ export const userRouter = createTRPCRouter({
// Update profile image
updateProfileImage: publicProcedure
.input(
z.object({ imageUrl: z.string().url().optional().or(z.literal("")) })
)
.input(z.object({ imageUrl: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -151,7 +142,13 @@ export const userRouter = createTRPCRouter({
// Change password (requires old password)
changePassword: publicProcedure
.input(passwordChangeSchema)
.input(
z.object({
oldPassword: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -162,7 +159,14 @@ export const userRouter = createTRPCRouter({
});
}
const { oldPassword, newPassword } = input;
const { oldPassword, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -220,7 +224,12 @@ export const userRouter = createTRPCRouter({
// Set password (for OAuth users who don't have password)
setPassword: publicProcedure
.input(passwordSetSchema)
.input(
z.object({
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -231,7 +240,14 @@ export const userRouter = createTRPCRouter({
});
}
const { password } = input;
const { newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch"
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -256,7 +272,7 @@ export const userRouter = createTRPCRouter({
}
// Set password
const passwordHash = await hashPassword(password);
const passwordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, userId]
@@ -277,7 +293,7 @@ export const userRouter = createTRPCRouter({
// Delete account (anonymize data)
deleteAccount: publicProcedure
.input(deleteAccountSchema)
.input(z.object({ password: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);

View File

@@ -1,153 +0,0 @@
import { z } from "zod";
/**
* Validation Schemas for tRPC Procedures
*
* These schemas are the source of truth for server-side validation.
* Client-side validation (src/lib/validation.ts) is optional for UX only.
*/
// ============================================================================
// Base Schemas
// ============================================================================
/**
* Email validation schema
* - Must be valid email format
* - Min 3 chars, max 255 chars
* - Trimmed and lowercased automatically
*/
export const emailSchema = z
.string()
.trim()
.toLowerCase()
.email("Invalid email address")
.min(3, "Email too short")
.max(255, "Email too long");
/**
* Password validation schema
* - Minimum 8 characters
* - Maximum 128 characters
* - Can add additional complexity requirements if needed
*/
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password too long");
/**
* Display name validation schema
* - Minimum 1 character (after trim)
* - Maximum 50 characters
*/
export const displayNameSchema = z
.string()
.trim()
.min(1, "Display name is required")
.max(50, "Display name too long");
/**
* Comment body validation schema
* - Minimum 1 character (after trim)
* - Maximum 10,000 characters
*/
export const commentBodySchema = z
.string()
.trim()
.min(1, "Comment cannot be empty")
.max(10000, "Comment too long (max 10,000 characters)");
// ============================================================================
// Composed Schemas
// ============================================================================
/**
* Email/password login schema
*/
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
rememberMe: z.boolean().optional()
});
/**
* Email/password registration schema with password confirmation
*/
export const registrationSchema = z
.object({
email: emailSchema,
password: passwordSchema,
passwordConfirmation: passwordSchema,
displayName: displayNameSchema.optional()
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
path: ["passwordConfirmation"]
});
/**
* Password change schema (requires old password)
*/
export const passwordChangeSchema = z
.object({
oldPassword: passwordSchema,
newPassword: passwordSchema,
newPasswordConfirmation: passwordSchema
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "New passwords do not match",
path: ["newPasswordConfirmation"]
})
.refine((data) => data.oldPassword !== data.newPassword, {
message: "New password must be different from old password",
path: ["newPassword"]
});
/**
* Password reset schema (no old password required)
*/
export const passwordResetSchema = z
.object({
token: z.string(),
newPassword: passwordSchema,
newPasswordConfirmation: passwordSchema
})
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match",
path: ["newPasswordConfirmation"]
});
/**
* Password set schema (for OAuth users setting password first time)
*/
export const passwordSetSchema = z
.object({
password: passwordSchema,
passwordConfirmation: passwordSchema
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
path: ["passwordConfirmation"]
});
/**
* Update email schema
*/
export const updateEmailSchema = z.object({
email: emailSchema
});
/**
* Update display name schema
*/
export const updateDisplayNameSchema = z.object({
displayName: displayNameSchema
});
/**
* Account deletion schema
*/
export const deleteAccountSchema = z.object({
password: passwordSchema
});

View File

@@ -1,124 +0,0 @@
/**
* Server-side Date/Time Utilities
*
* All dates are handled in UTC to avoid timezone issues.
* Database should store dates in UTC format.
*/
/**
* Get current UTC date/time formatted for SQL insert
*
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
* @example
* getSQLFormattedDate() // "2024-12-19 15:30:45"
*/
export function getSQLFormattedDate(): string {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
const hours = String(now.getUTCHours()).padStart(2, "0");
const minutes = String(now.getUTCMinutes()).padStart(2, "0");
const seconds = String(now.getUTCSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Format a Date object for SQL insert
*
* @param date Date object to format
* @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC
* @example
* formatDateForSQL(new Date()) // "2024-12-19 15:30:45"
*/
export function formatDateForSQL(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Parse a client-provided date string to Date object
*
* @param dateString ISO 8601 date string from client
* @returns Date object or null if invalid
* @example
* parseClientDate("2024-12-19T15:30:45.000Z") // Date object
* parseClientDate("invalid") // null
*/
export function parseClientDate(dateString: string): Date | null {
try {
const date = new Date(dateString);
return isNaN(date.getTime()) ? null : date;
} catch {
return null;
}
}
/**
* Format a date for display with relative time
* Handles "X minutes ago", "X hours ago", etc.
*
* @param dateString SQL date string or ISO string
* @returns Formatted relative time string
* @example
* formatRelativeDate("2024-12-19 15:00:00") // "30m ago" (if now is 15:30)
*/
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return "just now";
} else if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
// For older dates, return formatted date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: diffDays > 365 ? "numeric" : undefined
});
}
}
/**
* Format a date for display
*
* @param dateString SQL date string or ISO string
* @returns Formatted date string (e.g., "Dec 19, 2024")
* @example
* formatDateForDisplay("2024-12-19 15:30:45") // "Dec 19, 2024"
*/
export function formatDateForDisplay(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
});
}
/**
* Get timestamp in milliseconds (UTC)
*
* @returns Current timestamp in ms
*/
export function getCurrentTimestamp(): number {
return Date.now();
}

View File

@@ -17,12 +17,3 @@ export {
export { hashPassword, checkPassword } from "./password";
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
export {
getSQLFormattedDate,
formatDateForSQL,
parseClientDate,
formatRelativeDate,
formatDateForDisplay,
getCurrentTimestamp
} from "./date-utils";