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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
81
src/lib/server/middleware/auth.ts
Normal file
81
src/lib/server/middleware/auth.ts
Normal 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
212
src/lib/utils/formatters.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user