refactor: consolidate formatting utilities and add testing infrastructure

- Replace 8 duplicate formatCurrency functions with shared utility
- Add comprehensive formatter utilities (currency, date, number, etc.)
- Set up Vitest for unit testing with 38 passing tests
- Set up Playwright for E2E testing
- Consolidate database connection to single source (src/utils/db.ts)
- Add auth middleware helpers to reduce code duplication
- Fix display bug: remove spurious minus sign in recent activity amounts
- Add path aliases for cleaner imports ($utils, $models)
- Add project documentation (CODEMAP.md, REFACTORING_PLAN.md)

Test coverage: 38 unit tests passing
Build: successful with no breaking changes
This commit is contained in:
2025-11-18 15:24:22 +01:00
parent d09dc2dfed
commit 10ee2e81ae
58 changed files with 11127 additions and 131 deletions

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency } from '$lib/utils/formatters';
let debtData = {
whoOwesMe: [],
@@ -10,9 +11,9 @@
};
let loading = true;
let error = null;
$: shouldHide = getShouldHide();
function getShouldHide() {
const totalUsers = debtData.whoOwesMe.length + debtData.whoIOwe.length;
return totalUsers <= 1; // Hide if 0 or 1 user (1 user is handled by enhanced balance)
@@ -37,13 +38,6 @@
}
}
function formatCurrency(amount) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
}
// Export refresh method for parent components to call
export async function refresh() {
await fetchDebtBreakdown();
@@ -64,7 +58,7 @@
<div class="debt-section owed-to-me">
<h3>Who owes you</h3>
<div class="total-amount positive">
Total: {formatCurrency(debtData.totalOwedToMe)}
Total: {formatCurrency(debtData.totalOwedToMe, 'CHF', 'de-CH')}
</div>
<div class="debt-list">
@@ -74,7 +68,7 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="amount positive">{formatCurrency(debt.netAmount)}</span>
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
</div>
</div>
<div class="transaction-count">
@@ -90,7 +84,7 @@
<div class="debt-section owe-to-others">
<h3>You owe</h3>
<div class="total-amount negative">
Total: {formatCurrency(debtData.totalIOwe)}
Total: {formatCurrency(debtData.totalIOwe, 'CHF', 'de-CH')}
</div>
<div class="debt-list">
@@ -100,7 +94,7 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="amount negative">{formatCurrency(debt.netAmount)}</span>
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
</div>
</div>
<div class="transaction-count">

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
export let initialBalance = null;
export let initialDebtData = null;
@@ -101,10 +102,7 @@
}
function formatCurrency(amount) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(Math.abs(amount));
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
}
// Export refresh method for parent components to call

View File

@@ -5,7 +5,8 @@
import ProfilePicture from './ProfilePicture.svelte';
import EditButton from './EditButton.svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
export let paymentId;
// Get session from page store
@@ -63,10 +64,7 @@
}
function formatCurrency(amount) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(Math.abs(amount));
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
}
function formatDate(dateString) {

View File

@@ -1,44 +0,0 @@
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/recipes';
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}
/**
* Global is used here to maintain a cached connection across hot reloads
* in development. This prevents connections growing exponentially
* during API Route usage.
*/
let cached = (global as any).mongoose;
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null };
}
export async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose;
});
}
cached.conn = await cached.promise;
return cached.conn;
}
export async function dbDisconnect() {
if (cached.conn) {
await cached.conn.disconnect();
cached.conn = null;
cached.promise = null;
}
}

View File

@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
/**
* User session information extracted from Auth.js
*/
export interface AuthenticatedUser {
nickname: string;
name?: string;
email?: string;
image?: string;
}
/**
* Require authentication for an API route.
* Returns the authenticated user or throws an unauthorized response.
*
* @param locals - The RequestEvent locals object containing auth()
* @returns The authenticated user
* @throws Response with 401 status if not authenticated
*
* @example
* ```ts
* export const GET: RequestHandler = async ({ locals }) => {
* const user = await requireAuth(locals);
* // user.nickname is guaranteed to exist here
* return json({ message: `Hello ${user.nickname}` });
* };
* ```
*/
export async function requireAuth(
locals: RequestEvent['locals']
): Promise<AuthenticatedUser> {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
throw json({ error: 'Unauthorized' }, { status: 401 });
}
return {
nickname: session.user.nickname,
name: session.user.name,
email: session.user.email,
image: session.user.image
};
}
/**
* Optional authentication - returns user if authenticated, null otherwise.
* Useful for routes that have different behavior for authenticated users.
*
* @param locals - The RequestEvent locals object containing auth()
* @returns The authenticated user or null
*
* @example
* ```ts
* export const GET: RequestHandler = async ({ locals }) => {
* const user = await optionalAuth(locals);
* if (user) {
* return json({ message: `Hello ${user.nickname}`, isAuthenticated: true });
* }
* return json({ message: 'Hello guest', isAuthenticated: false });
* };
* ```
*/
export async function optionalAuth(
locals: RequestEvent['locals']
): Promise<AuthenticatedUser | null> {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return null;
}
return {
nickname: session.user.nickname,
name: session.user.name,
email: session.user.email,
image: session.user.image
};
}

212
src/lib/utils/formatters.ts Normal file
View File

@@ -0,0 +1,212 @@
/**
* Shared formatting utilities for both client and server
*/
/**
* Format a number as currency with proper symbol and locale
*
* @param amount - The amount to format
* @param currency - The currency code (EUR, USD, etc.)
* @param locale - The locale for formatting (default: 'de-DE')
* @returns Formatted currency string
*
* @example
* ```ts
* formatCurrency(1234.56, 'EUR') // "1.234,56 €"
* formatCurrency(1234.56, 'USD', 'en-US') // "$1,234.56"
* ```
*/
export function formatCurrency(
amount: number,
currency: string = 'EUR',
locale: string = 'de-DE'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount);
}
/**
* Format a date with customizable style
*
* @param date - The date to format (Date object, ISO string, or timestamp)
* @param locale - The locale for formatting (default: 'de-DE')
* @param options - Intl.DateTimeFormat options
* @returns Formatted date string
*
* @example
* ```ts
* formatDate(new Date()) // "18.11.2025"
* formatDate(new Date(), 'de-DE', { dateStyle: 'long' }) // "18. November 2025"
* formatDate('2025-11-18') // "18.11.2025"
* ```
*/
export function formatDate(
date: Date | string | number,
locale: string = 'de-DE',
options: Intl.DateTimeFormatOptions = { dateStyle: 'short' }
): string {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
if (isNaN(dateObj.getTime())) {
return 'Invalid Date';
}
return new Intl.DateTimeFormat(locale, options).format(dateObj);
}
/**
* Format a date and time with customizable style
*
* @param date - The date to format (Date object, ISO string, or timestamp)
* @param locale - The locale for formatting (default: 'de-DE')
* @param options - Intl.DateTimeFormat options
* @returns Formatted datetime string
*
* @example
* ```ts
* formatDateTime(new Date()) // "18.11.2025, 14:30"
* formatDateTime(new Date(), 'de-DE', { dateStyle: 'medium', timeStyle: 'short' })
* // "18. Nov. 2025, 14:30"
* ```
*/
export function formatDateTime(
date: Date | string | number,
locale: string = 'de-DE',
options: Intl.DateTimeFormatOptions = { dateStyle: 'short', timeStyle: 'short' }
): string {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
if (isNaN(dateObj.getTime())) {
return 'Invalid Date';
}
return new Intl.DateTimeFormat(locale, options).format(dateObj);
}
/**
* Format a number with customizable decimal places and locale
*
* @param num - The number to format
* @param decimals - Number of decimal places (default: 2)
* @param locale - The locale for formatting (default: 'de-DE')
* @returns Formatted number string
*
* @example
* ```ts
* formatNumber(1234.5678) // "1.234,57"
* formatNumber(1234.5678, 0) // "1.235"
* formatNumber(1234.5678, 3) // "1.234,568"
* ```
*/
export function formatNumber(
num: number,
decimals: number = 2,
locale: string = 'de-DE'
): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(num);
}
/**
* Format a relative time (e.g., "2 days ago", "in 3 hours")
*
* @param date - The date to compare
* @param baseDate - The base date to compare against (default: now)
* @param locale - The locale for formatting (default: 'de-DE')
* @returns Formatted relative time string
*
* @example
* ```ts
* formatRelativeTime(new Date(Date.now() - 86400000)) // "vor 1 Tag"
* formatRelativeTime(new Date(Date.now() + 3600000)) // "in 1 Stunde"
* ```
*/
export function formatRelativeTime(
date: Date | string | number,
baseDate: Date = new Date(),
locale: string = 'de-DE'
): string {
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
if (isNaN(dateObj.getTime())) {
return 'Invalid Date';
}
const diffMs = dateObj.getTime() - baseDate.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (Math.abs(diffYears) >= 1) return rtf.format(diffYears, 'year');
if (Math.abs(diffMonths) >= 1) return rtf.format(diffMonths, 'month');
if (Math.abs(diffWeeks) >= 1) return rtf.format(diffWeeks, 'week');
if (Math.abs(diffDays) >= 1) return rtf.format(diffDays, 'day');
if (Math.abs(diffHours) >= 1) return rtf.format(diffHours, 'hour');
if (Math.abs(diffMinutes) >= 1) return rtf.format(diffMinutes, 'minute');
return rtf.format(diffSeconds, 'second');
}
/**
* Format bytes into human-readable file size
*
* @param bytes - Number of bytes
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted file size string
*
* @example
* ```ts
* formatFileSize(1024) // "1.00 KB"
* formatFileSize(1234567) // "1.18 MB"
* formatFileSize(1234567890) // "1.15 GB"
* ```
*/
export function formatFileSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Format a percentage with customizable decimal places
*
* @param value - The value to format as percentage (0-1 or 0-100)
* @param decimals - Number of decimal places (default: 0)
* @param isDecimal - Whether the value is between 0-1 (true) or 0-100 (false)
* @param locale - The locale for formatting (default: 'de-DE')
* @returns Formatted percentage string
*
* @example
* ```ts
* formatPercentage(0.456, 1, true) // "45,6 %"
* formatPercentage(45.6, 1, false) // "45,6 %"
* formatPercentage(0.75, 0, true) // "75 %"
* ```
*/
export function formatPercentage(
value: number,
decimals: number = 0,
isDecimal: boolean = true,
locale: string = 'de-DE'
): string {
const percentage = isDecimal ? value : value / 100;
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(percentage);
}