diff --git a/src/app.tsx b/src/app.tsx
index 0afcc90..98dca31 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -31,6 +31,12 @@ 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
@@ -41,7 +47,9 @@ function AppLayout(props: { children: any }) {
setRightBarVisible(true);
}
- const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
+ // On mobile, leftbar overlays (don't subtract its size)
+ const leftOffset = isMobile ? 0 : leftBarSize();
+ const newWidth = window.innerWidth - leftOffset - rightBarSize();
setCenterWidth(newWidth);
};
@@ -55,7 +63,10 @@ function AppLayout(props: { children: any }) {
// Recalculate when bar sizes change (visibility or actual resize)
createEffect(() => {
- const newWidth = window.innerWidth - leftBarSize() - rightBarSize();
+ 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();
setCenterWidth(newWidth);
});
@@ -177,7 +188,13 @@ function AppLayout(props: { children: any }) {
class="bg-base relative min-h-screen rounded-t-lg shadow-2xl"
style={{
width: `${centerWidth()}px`,
- "margin-left": `${leftBarSize()}px`
+ "margin-left": `${leftMargin()}px`
+ }}
+ onClick={() => {
+ const isMobile = window.innerWidth < 768;
+ if (isMobile && leftBarVisible()) {
+ setLeftBarVisible(false);
+ }
}}
>
}>
diff --git a/src/components/RecentCommits.tsx b/src/components/RecentCommits.tsx
index 1a747c7..ebf6c33 100644
--- a/src/components/RecentCommits.tsx
+++ b/src/components/RecentCommits.tsx
@@ -1,6 +1,7 @@
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;
@@ -16,28 +17,6 @@ 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 (
{props.title}
@@ -90,7 +69,7 @@ export const RecentCommits: Component<{
- {formatDate(commit.date)}
+ {formatRelativeDate(commit.date)}
diff --git a/src/context/bars.tsx b/src/context/bars.tsx
index 49063cc..4b2cf06 100644
--- a/src/context/bars.tsx
+++ b/src/context/bars.tsx
@@ -1,6 +1,5 @@
import { Accessor, createContext, useContext, createMemo } from "solid-js";
import { createSignal } from "solid-js";
-import { hapticFeedback } from "~/lib/client-utils";
const BarsContext = createContext<{
leftBarSize: Accessor;
@@ -98,14 +97,11 @@ 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 (
diff --git a/src/lib/comment-utils.ts b/src/lib/comment-utils.ts
index 8dc7118..2f09c4a 100644
--- a/src/lib/comment-utils.ts
+++ b/src/lib/comment-utils.ts
@@ -205,7 +205,8 @@ export function debounce any>(
// ============================================================================
/**
- * Validates that a comment body meets requirements
+ * Validates that a comment body meets requirements (client-side UX only)
+ * Server validation is in src/server/api/schemas/validation.ts
*/
export function isValidCommentBody(body: string): boolean {
return body.trim().length > 0 && body.length <= 10000;
diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts
index 5abbdc6..a0e134a 100644
--- a/src/lib/date-utils.ts
+++ b/src/lib/date-utils.ts
@@ -1,18 +1,62 @@
/**
- * 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
+ * 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
*/
export function getSQLFormattedDate(): string {
const date = new Date();
- date.setHours(date.getHours() + 4);
- 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");
+ 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");
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
+ });
+ }
+}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index 8338f41..d7a064a 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -1,9 +1,18 @@
/**
- * Form validation utilities
+ * 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.
*/
/**
- * Validate email format
+ * Validate email format (client-side UX only)
+ * Server validation is in src/server/api/schemas/validation.ts
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -11,7 +20,8 @@ export function isValidEmail(email: string): boolean {
}
/**
- * Validate password strength
+ * Validate password strength (client-side UX only)
+ * Server validation is in src/server/api/schemas/validation.ts
*/
export function validatePassword(password: string): {
isValid: boolean;
@@ -41,7 +51,8 @@ export function validatePassword(password: string): {
}
/**
- * Check if two passwords match
+ * Check if two passwords match (client-side UX only)
+ * Server validation is in src/server/api/schemas/validation.ts
*/
export function passwordsMatch(
password: string,
@@ -51,7 +62,8 @@ export function passwordsMatch(
}
/**
- * Validate display name
+ * Validate display name (client-side UX only)
+ * Server validation is in src/server/api/schemas/validation.ts
*/
export function isValidDisplayName(name: string): boolean {
return name.trim().length >= 1 && name.trim().length <= 50;
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 4daf2b2..b18ad48 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -42,6 +42,9 @@ export default function Home() {
here (github).
+
+ Check the side bar(s) for more info and important links.
+
Some of my recent projects:
@@ -89,7 +92,7 @@ export default function Home() {
{/* Life and Lineage */}
-
+
My mobile game:
{
- const { email, password, passwordConfirmation } = input;
-
- if (password !== passwordConfirmation) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "passwordMismatch"
- });
- }
+ const { email, password } = input;
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
@@ -411,13 +405,7 @@ export const authRouter = createTRPCRouter({
// Email/password login
emailPasswordLogin: publicProcedure
- .input(
- z.object({
- email: z.string().email(),
- password: z.string(),
- rememberMe: z.boolean().optional()
- })
- )
+ .input(loginSchema)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
@@ -477,7 +465,7 @@ export const authRouter = createTRPCRouter({
requestEmailLinkLogin: publicProcedure
.input(
z.object({
- email: z.string().email(),
+ email: emailSchema,
rememberMe: z.boolean().optional()
})
)
@@ -582,7 +570,7 @@ export const authRouter = createTRPCRouter({
// Request password reset
requestPasswordReset: publicProcedure
- .input(z.object({ email: z.string().email() }))
+ .input(z.object({ email: emailSchema }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -681,22 +669,9 @@ export const authRouter = createTRPCRouter({
// Reset password with token
resetPassword: publicProcedure
- .input(
- z.object({
- token: z.string(),
- newPassword: z.string().min(8),
- newPasswordConfirmation: z.string().min(8)
- })
- )
+ .input(passwordResetSchema)
.mutation(async ({ input, ctx }) => {
- const { token, newPassword, newPasswordConfirmation } = input;
-
- if (newPassword !== newPasswordConfirmation) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Password Mismatch"
- });
- }
+ const { token, newPassword } = input;
try {
// Verify JWT token
@@ -743,7 +718,7 @@ export const authRouter = createTRPCRouter({
// Resend email verification
resendEmailVerification: publicProcedure
- .input(z.object({ email: z.string().email() }))
+ .input(z.object({ email: emailSchema }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
@@ -852,4 +827,3 @@ export const authRouter = createTRPCRouter({
return { success: true };
})
});
-
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts
index 1625e11..3316a72 100644
--- a/src/server/api/routers/user.ts
+++ b/src/server/api/routers/user.ts
@@ -11,6 +11,13 @@ 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
@@ -43,7 +50,7 @@ export const userRouter = createTRPCRouter({
// Update email
updateEmail: publicProcedure
- .input(z.object({ email: z.string().email() }))
+ .input(updateEmailSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -80,7 +87,7 @@ export const userRouter = createTRPCRouter({
// Update display name
updateDisplayName: publicProcedure
- .input(z.object({ displayName: z.string().min(1).max(50) }))
+ .input(updateDisplayNameSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -111,7 +118,9 @@ export const userRouter = createTRPCRouter({
// Update profile image
updateProfileImage: publicProcedure
- .input(z.object({ imageUrl: z.string() }))
+ .input(
+ z.object({ imageUrl: z.string().url().optional().or(z.literal("")) })
+ )
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -142,13 +151,7 @@ export const userRouter = createTRPCRouter({
// Change password (requires old password)
changePassword: publicProcedure
- .input(
- z.object({
- oldPassword: z.string(),
- newPassword: z.string().min(8),
- newPasswordConfirmation: z.string().min(8)
- })
- )
+ .input(passwordChangeSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -159,14 +162,7 @@ export const userRouter = createTRPCRouter({
});
}
- const { oldPassword, newPassword, newPasswordConfirmation } = input;
-
- if (newPassword !== newPasswordConfirmation) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Password Mismatch"
- });
- }
+ const { oldPassword, newPassword } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -224,12 +220,7 @@ export const userRouter = createTRPCRouter({
// Set password (for OAuth users who don't have password)
setPassword: publicProcedure
- .input(
- z.object({
- newPassword: z.string().min(8),
- newPasswordConfirmation: z.string().min(8)
- })
- )
+ .input(passwordSetSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
@@ -240,14 +231,7 @@ export const userRouter = createTRPCRouter({
});
}
- const { newPassword, newPasswordConfirmation } = input;
-
- if (newPassword !== newPasswordConfirmation) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Password Mismatch"
- });
- }
+ const { password } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
@@ -272,7 +256,7 @@ export const userRouter = createTRPCRouter({
}
// Set password
- const passwordHash = await hashPassword(newPassword);
+ const passwordHash = await hashPassword(password);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, userId]
@@ -293,7 +277,7 @@ export const userRouter = createTRPCRouter({
// Delete account (anonymize data)
deleteAccount: publicProcedure
- .input(z.object({ password: z.string() }))
+ .input(deleteAccountSchema)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
diff --git a/src/server/api/schemas/validation.ts b/src/server/api/schemas/validation.ts
new file mode 100644
index 0000000..0ee4e96
--- /dev/null
+++ b/src/server/api/schemas/validation.ts
@@ -0,0 +1,153 @@
+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
+});
diff --git a/src/server/date-utils.ts b/src/server/date-utils.ts
new file mode 100644
index 0000000..634c37d
--- /dev/null
+++ b/src/server/date-utils.ts
@@ -0,0 +1,124 @@
+/**
+ * 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();
+}
diff --git a/src/server/utils.ts b/src/server/utils.ts
index 1279add..b21f6ef 100644
--- a/src/server/utils.ts
+++ b/src/server/utils.ts
@@ -17,3 +17,12 @@ export {
export { hashPassword, checkPassword } from "./password";
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
+
+export {
+ getSQLFormattedDate,
+ formatDateForSQL,
+ parseClientDate,
+ formatRelativeDate,
+ formatDateForDisplay,
+ getCurrentTimestamp
+} from "./date-utils";