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 a2df59f11d
commit 8dd1e3852e
58 changed files with 11127 additions and 131 deletions

View File

@@ -0,0 +1,69 @@
# Formatter Replacement Progress
## Components Completed ✅
1. DebtBreakdown.svelte - Replaced formatCurrency function
2. EnhancedBalance.svelte - Replaced formatCurrency function (with Math.abs wrapper)
## Remaining Files to Update
### Components (3 files)
- [ ] PaymentModal.svelte - Has formatCurrency function
- [ ] SplitMethodSelector.svelte - Has inline .toFixed() calls
- [ ] BarChart.svelte - Has inline .toFixed() calls
- [ ] IngredientsPage.svelte - Has .toFixed() for recipe calculations
### Cospend Pages (7 files)
- [ ] routes/cospend/+page.svelte - Has formatCurrency function
- [ ] routes/cospend/payments/+page.svelte - Has formatCurrency function
- [ ] routes/cospend/payments/view/[id]/+page.svelte - Has formatCurrency and .toFixed()
- [ ] routes/cospend/payments/add/+page.svelte - Has .toFixed() and .toLocaleString()
- [ ] routes/cospend/payments/edit/[id]/+page.svelte - Has multiple .toFixed() calls
- [ ] routes/cospend/recurring/+page.svelte - Has formatCurrency function
- [ ] routes/cospend/recurring/edit/[id]/+page.svelte - Has .toFixed() and .toLocaleString()
- [ ] routes/cospend/settle/+page.svelte - Has formatCurrency function
## Replacement Strategy
### Pattern 1: Identical formatCurrency functions
```typescript
// OLD
function formatCurrency(amount) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
}
// NEW
import { formatCurrency } from '$lib/utils/formatters';
// Usage: formatCurrency(amount, 'CHF', 'de-CH')
```
### Pattern 2: .toFixed() for currency display
```typescript
// OLD
{payment.amount.toFixed(2)}
// NEW
import { formatNumber } from '$lib/utils/formatters';
{formatNumber(payment.amount, 2, 'de-CH')}
```
### Pattern 3: .toLocaleString() for dates
```typescript
// OLD
nextDate.toLocaleString('de-CH', { weekday: 'long', ... })
// NEW
import { formatDateTime } from '$lib/utils/formatters';
formatDateTime(nextDate, 'de-CH', { weekday: 'long', ... })
```
### Pattern 4: Exchange rate display (4 decimals)
```typescript
// OLD
{exchangeRate.toFixed(4)}
// NEW
{formatNumber(exchangeRate, 4, 'de-CH')}
```

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Script to replace inline formatCurrency functions with shared formatter utilities
"""
import re
import sys
files_to_update = [
"/home/alex/.local/src/homepage/src/routes/cospend/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/payments/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/payments/view/[id]/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/recurring/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/settle/+page.svelte",
]
def process_file(filepath):
print(f"Processing: {filepath}")
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
# Check if already has the import
has_formatter_import = 'from \'$lib/utils/formatters\'' in content or 'from "$lib/utils/formatters"' in content
# Find the <script> tag
script_match = re.search(r'(<script[^>]*>)', content)
if not script_match:
print(f" ⚠️ No <script> tag found")
return False
# Add import if not present
if not has_formatter_import:
script_tag = script_match.group(1)
# Find where to insert (after <script> tag)
script_end = script_match.end()
# Get existing imports to find the right place
imports_section_match = re.search(r'<script[^>]*>(.*?)(?:\n\n|\n export|\n let)', content, re.DOTALL)
if imports_section_match:
imports_end = imports_section_match.end() - len(imports_section_match.group(0).split('\n')[-1])
insert_pos = imports_end
else:
insert_pos = script_end
new_import = "\n import { formatCurrency } from '$lib/utils/formatters';"
content = content[:insert_pos] + new_import + content[insert_pos:]
print(f" ✓ Added import")
# Remove the formatCurrency function definition
# Pattern for the function with different variations
patterns = [
r'\n function formatCurrency\(amount\) \{\n return new Intl\.NumberFormat\(\'de-CH\',\s*\{\n\s*style:\s*\'currency\',\n\s*currency:\s*\'CHF\'\n\s*\}\)\.format\(amount\);\n \}',
r'\n function formatCurrency\(amount,\s*currency\s*=\s*\'CHF\'\) \{\n return new Intl\.NumberFormat\(\'de-CH\',\s*\{\n\s*style:\s*\'currency\',\n\s*currency:\s*currency\n\s*\}\)\.format\(amount\);\n \}',
]
for pattern in patterns:
if re.search(pattern, content):
content = re.sub(pattern, '', content)
print(f" ✓ Removed formatCurrency function")
break
# Check if content changed
if content != original_content:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f" ✅ Updated successfully")
return True
else:
print(f" ⚠️ No changes needed")
return False
except Exception as e:
print(f" ❌ Error: {e}")
return False
def main():
print("=" * 60)
print("Replacing formatCurrency functions with shared utilities")
print("=" * 60)
success_count = 0
for filepath in files_to_update:
if process_file(filepath):
success_count += 1
print()
print("=" * 60)
print(f"Summary: {success_count}/{len(files_to_update)} files updated")
print("=" * 60)
if __name__ == "__main__":
main()

205
scripts/scrape-exercises.ts Normal file
View File

@@ -0,0 +1,205 @@
import { dbConnect } from '../src/utils/db';
import { Exercise } from '../src/models/Exercise';
// ExerciseDB API configuration
const RAPIDAPI_KEY = process.env.RAPIDAPI_KEY || 'your-rapidapi-key-here';
const RAPIDAPI_HOST = 'exercisedb.p.rapidapi.com';
const BASE_URL = 'https://exercisedb.p.rapidapi.com';
interface ExerciseDBExercise {
id: string;
name: string;
gifUrl: string;
bodyPart: string;
equipment: string;
target: string;
secondaryMuscles: string[];
instructions: string[];
}
async function fetchFromExerciseDB(endpoint: string): Promise<any> {
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
'X-RapidAPI-Key': RAPIDAPI_KEY,
'X-RapidAPI-Host': RAPIDAPI_HOST
}
});
if (!response.ok) {
throw new Error(`Failed to fetch from ExerciseDB: ${response.status} ${response.statusText}`);
}
return response.json();
}
async function scrapeAllExercises(): Promise<void> {
console.log('🚀 Starting ExerciseDB scraping...');
try {
await dbConnect();
console.log('✅ Connected to database');
// Fetch all exercises
console.log('📡 Fetching exercises from ExerciseDB...');
const exercises: ExerciseDBExercise[] = await fetchFromExerciseDB('/exercises?limit=2000');
console.log(`📊 Found ${exercises.length} exercises`);
let imported = 0;
let skipped = 0;
let errors = 0;
for (const exercise of exercises) {
try {
// Check if exercise already exists
const existingExercise = await Exercise.findOne({ exerciseId: exercise.id });
if (existingExercise) {
skipped++;
continue;
}
// Determine difficulty based on equipment and complexity
let difficulty: 'beginner' | 'intermediate' | 'advanced' = 'intermediate';
if (exercise.equipment === 'body weight') {
difficulty = 'beginner';
} else if (exercise.equipment.includes('barbell') || exercise.equipment.includes('olympic')) {
difficulty = 'advanced';
} else if (exercise.equipment.includes('dumbbell') || exercise.equipment.includes('cable')) {
difficulty = 'intermediate';
}
// Create new exercise
const newExercise = new Exercise({
exerciseId: exercise.id,
name: exercise.name,
gifUrl: exercise.gifUrl,
bodyPart: exercise.bodyPart.toLowerCase(),
equipment: exercise.equipment.toLowerCase(),
target: exercise.target.toLowerCase(),
secondaryMuscles: exercise.secondaryMuscles.map(m => m.toLowerCase()),
instructions: exercise.instructions,
difficulty,
isActive: true
});
await newExercise.save();
imported++;
if (imported % 100 === 0) {
console.log(`⏳ Imported ${imported} exercises...`);
}
} catch (error) {
console.error(`❌ Error importing exercise ${exercise.name}:`, error);
errors++;
}
}
console.log('✅ Scraping completed!');
console.log(`📈 Summary: ${imported} imported, ${skipped} skipped, ${errors} errors`);
} catch (error) {
console.error('💥 Scraping failed:', error);
throw error;
}
}
async function updateExistingExercises(): Promise<void> {
console.log('🔄 Updating existing exercises...');
try {
await dbConnect();
const exercises: ExerciseDBExercise[] = await fetchFromExerciseDB('/exercises?limit=2000');
let updated = 0;
for (const exercise of exercises) {
try {
const existingExercise = await Exercise.findOne({ exerciseId: exercise.id });
if (existingExercise) {
// Update with new data from API
existingExercise.name = exercise.name;
existingExercise.gifUrl = exercise.gifUrl;
existingExercise.bodyPart = exercise.bodyPart.toLowerCase();
existingExercise.equipment = exercise.equipment.toLowerCase();
existingExercise.target = exercise.target.toLowerCase();
existingExercise.secondaryMuscles = exercise.secondaryMuscles.map(m => m.toLowerCase());
existingExercise.instructions = exercise.instructions;
await existingExercise.save();
updated++;
if (updated % 100 === 0) {
console.log(`⏳ Updated ${updated} exercises...`);
}
}
} catch (error) {
console.error(`❌ Error updating exercise ${exercise.name}:`, error);
}
}
console.log(`✅ Updated ${updated} exercises`);
} catch (error) {
console.error('💥 Update failed:', error);
throw error;
}
}
async function getExerciseStats(): Promise<void> {
try {
await dbConnect();
const totalExercises = await Exercise.countDocuments();
const activeExercises = await Exercise.countDocuments({ isActive: true });
const bodyParts = await Exercise.distinct('bodyPart');
const equipment = await Exercise.distinct('equipment');
const targets = await Exercise.distinct('target');
console.log('📊 Exercise Database Stats:');
console.log(` Total exercises: ${totalExercises}`);
console.log(` Active exercises: ${activeExercises}`);
console.log(` Body parts: ${bodyParts.length} (${bodyParts.join(', ')})`);
console.log(` Equipment types: ${equipment.length}`);
console.log(` Target muscles: ${targets.length}`);
} catch (error) {
console.error('💥 Stats failed:', error);
}
}
// CLI interface
const command = process.argv[2];
switch (command) {
case 'scrape':
scrapeAllExercises()
.then(() => process.exit(0))
.catch(() => process.exit(1));
break;
case 'update':
updateExistingExercises()
.then(() => process.exit(0))
.catch(() => process.exit(1));
break;
case 'stats':
getExerciseStats()
.then(() => process.exit(0))
.catch(() => process.exit(1));
break;
default:
console.log('Usage: tsx scripts/scrape-exercises.ts [command]');
console.log('Commands:');
console.log(' scrape - Import all exercises from ExerciseDB');
console.log(' update - Update existing exercises with latest data');
console.log(' stats - Show database statistics');
process.exit(0);
}

35
scripts/update-db-imports.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Update all files importing from the legacy $lib/db/db to use $utils/db instead
files=(
"/home/alex/.local/src/homepage/src/routes/mario-kart/[id]/+page.server.ts"
"/home/alex/.local/src/homepage/src/routes/mario-kart/+page.server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/sessions/[id]/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/sessions/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/templates/[id]/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/templates/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/[id]/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/exercises/filters/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/fitness/seed-example/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/groups/[groupId]/scores/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/groups/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/contestants/[contestantId]/dnf/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/contestants/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/bracket/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/[id]/bracket/matches/[matchId]/scores/+server.ts"
"/home/alex/.local/src/homepage/src/routes/api/mario-kart/tournaments/+server.ts"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
echo "Updating $file"
sed -i "s/from '\$lib\/db\/db'/from '\$utils\/db'/g" "$file"
else
echo "File not found: $file"
fi
done
echo "All files updated!"

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Script to update formatCurrency calls to include CHF and de-CH parameters
"""
import re
files_to_update = [
"/home/alex/.local/src/homepage/src/routes/cospend/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/payments/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/payments/view/[id]/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/recurring/+page.svelte",
"/home/alex/.local/src/homepage/src/routes/cospend/settle/+page.svelte",
]
def process_file(filepath):
print(f"Processing: {filepath}")
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
changes = 0
# Pattern 1: formatCurrency(amount) -> formatCurrency(amount, 'CHF', 'de-CH')
# But skip if already has parameters
def replace_single_param(match):
amount = match.group(1)
# Check if amount already contains currency parameter (contains comma followed by quote)
if ", '" in amount or ', "' in amount:
return match.group(0) # Already has parameters, skip
return f"formatCurrency({amount}, 'CHF', 'de-CH')"
content, count1 = re.subn(
r'formatCurrency\(([^)]+)\)',
replace_single_param,
content
)
changes += count1
if changes > 0:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f" ✅ Updated {changes} formatCurrency calls")
return True
else:
print(f" ⚠️ No changes needed")
return False
except Exception as e:
print(f" ❌ Error: {e}")
import traceback
traceback.print_exc()
return False
def main():
print("=" * 60)
print("Updating formatCurrency calls with CHF and de-CH params")
print("=" * 60)
success_count = 0
for filepath in files_to_update:
if process_file(filepath):
success_count += 1
print()
print("=" * 60)
print(f"Summary: {success_count}/{len(files_to_update)} files updated")
print("=" * 60)
if __name__ == "__main__":
main()