This commit is contained in:
jpmvaz
2026-09-13 20:20:06 +01:00
commit 6994063c71
93 changed files with 7613 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import prisma from './db';
/**
* Logs an API request to the database.
* This is intentionally fire-and-forget — it never blocks the response path.
*/
export function logApiRequest(
endpoint: string,
status: number,
duration: number
): void {
prisma.apiRequestLog
.create({ data: { endpoint, status, duration } })
.catch((error) => console.error('Failed to log API request:', error));
// Prune old logs in the background (keep last 7 days)
pruneOldLogs();
}
let lastPruneAt = 0;
const PRUNE_INTERVAL = 60 * 60 * 1000; // prune at most once per hour
function pruneOldLogs(): void {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL) return;
lastPruneAt = Date.now();
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
prisma.apiRequestLog
.deleteMany({ where: { createdAt: { lt: cutoff } } })
.catch((error) => console.error('Failed to prune API logs:', error));
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { createContext, useContext } from 'react';
export interface AppSettings {
siteName: string;
selectedLeague: string;
primaryColor: string;
secondaryColor: string;
showLiveMatches: boolean;
showStandings: boolean;
showTopScorers: boolean;
showRecentResults: boolean;
showUpcoming: boolean;
refreshInterval: number;
}
export const defaultSettings: AppSettings = {
siteName: 'Primeira Liga Stats',
selectedLeague: 'primeira_liga',
primaryColor: '#E42518',
secondaryColor: '#006600',
showLiveMatches: true,
showStandings: true,
showTopScorers: true,
showRecentResults: true,
showUpcoming: true,
refreshInterval: 300,
};
export const SettingsContext = createContext<AppSettings>(defaultSettings);
export function useSettings() {
return useContext(SettingsContext);
}
+94
View File
@@ -0,0 +1,94 @@
import prisma from './db';
export interface AppSettings {
id: string;
apiKey: string | null;
selectedLeague: string;
leagueId: number;
season: number;
siteName: string;
primaryColor: string;
secondaryColor: string;
showLiveMatches: boolean;
showStandings: boolean;
showTopScorers: boolean;
showRecentResults: boolean;
showUpcoming: boolean;
refreshInterval: number;
}
const defaultSettings: AppSettings = {
id: 'main',
apiKey: null,
selectedLeague: 'primeira_liga',
leagueId: 94,
season: 2024,
siteName: 'Primeira Liga Stats',
primaryColor: '#E42518',
secondaryColor: '#006600',
showLiveMatches: true,
showStandings: true,
showTopScorers: true,
showRecentResults: true,
showUpcoming: true,
refreshInterval: 300,
};
// In-memory cache to avoid a DB round-trip on every API call
let settingsCache: AppSettings | null = null;
let settingsCacheAt = 0;
const SETTINGS_CACHE_TTL = 30_000; // 30 seconds
export async function getSettings(): Promise<AppSettings> {
// Return from cache if still fresh
if (settingsCache && Date.now() - settingsCacheAt < SETTINGS_CACHE_TTL) {
return settingsCache;
}
try {
let settings = await prisma.settings.findUnique({
where: { id: 'main' },
});
if (!settings) {
// Create default settings with API key from env
settings = await prisma.settings.create({
data: {
...defaultSettings,
apiKey: process.env.API_FOOTBALL_API_KEY || null,
},
});
}
settingsCache = settings as AppSettings;
settingsCacheAt = Date.now();
return settingsCache;
} catch (error) {
console.error('Failed to get settings:', error);
return {
...defaultSettings,
apiKey: process.env.API_FOOTBALL_API_KEY || null,
};
}
}
/** Call this after any settings update to bust the cache. */
export function invalidateSettingsCache() {
settingsCache = null;
settingsCacheAt = 0;
}
export function getLeagueConfig(selectedLeague: string) {
if (selectedLeague === 'euro_2026') {
return {
leagueId: 4, // EURO Championship
season: 2024, // Euro 2024 (most recent)
name: 'UEFA Euro 2024',
};
}
return {
leagueId: 94, // Portuguese Primeira Liga
season: 2024,
name: 'Primeira Liga',
};
}
+28
View File
@@ -0,0 +1,28 @@
export type Expense = {
id: string
amount: number
category: string
description: string
date: Date
}
export type ExpenseFormData = Omit<Expense, 'id' | 'date'> & {
date: string
}
export const EXPENSE_CATEGORIES = [
'Food',
'Transportation',
'Housing',
'Utilities',
'Entertainment',
'Healthcare',
'Shopping',
'Education',
'Other'
] as const
export type DateRange = {
from: Date | undefined
to: Date | undefined
}
+14
View File
@@ -0,0 +1,14 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}