Files
ALwrity/frontend/src/components/billing/DailyCostHeatmap.tsx

232 lines
7.7 KiB
TypeScript

import React, { Suspense, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import { Calendar } from 'lucide-react';
import { formatCurrency } from '../../services/billingService';
import { UsageLog } from '../../types/billing';
interface DailyCostHeatmapProps {
usageLogs: UsageLog[];
currentMonth: number;
currentYear: number;
}
/**
* DailyCostHeatmap - Calendar-style heatmap showing cost patterns by day
*
* Visualizes daily spending patterns to identify high-cost days
*/
const DailyCostHeatmap: React.FC<DailyCostHeatmapProps> = ({
usageLogs,
currentMonth,
currentYear
}) => {
// Aggregate logs by day
const dailyCosts = useMemo(() => {
const costs: Record<number, number> = {};
usageLogs.forEach(log => {
const logDate = new Date(log.timestamp);
if (logDate.getMonth() === currentMonth && logDate.getFullYear() === currentYear) {
const day = logDate.getDate();
costs[day] = (costs[day] || 0) + (log.cost_total || 0);
}
});
return costs;
}, [usageLogs, currentMonth, currentYear]);
// Get days in month
const daysInMonth = useMemo(() => {
return new Date(currentYear, currentMonth + 1, 0).getDate();
}, [currentMonth, currentYear]);
// Calculate max cost for color scaling
const maxCost = useMemo(() => {
return Math.max(...Object.values(dailyCosts), 0.01);
}, [dailyCosts]);
// Get color intensity based on cost
const getColorIntensity = (cost: number) => {
if (cost === 0) return 'rgba(255,255,255,0.05)';
const intensity = Math.min(cost / maxCost, 1);
// Green (low) to Red (high)
if (intensity < 0.3) {
return `rgba(34, 197, 94, ${0.3 + intensity * 0.4})`; // Green
} else if (intensity < 0.7) {
return `rgba(234, 179, 8, ${0.5 + (intensity - 0.3) * 0.3})`; // Yellow
} else {
return `rgba(239, 68, 68, ${0.6 + (intensity - 0.7) * 0.4})`; // Red
}
};
// Generate calendar grid
const calendarDays = useMemo(() => {
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
const days: Array<{ day: number; cost: number; date: Date | null }> = [];
// Add empty cells for days before month starts
for (let i = 0; i < firstDay; i++) {
days.push({ day: 0, cost: 0, date: null });
}
// Add days of month
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(currentYear, currentMonth, day);
days.push({
day,
cost: dailyCosts[day] || 0,
date
});
}
return days;
}, [currentMonth, currentYear, daysInMonth, dailyCosts]);
if (usageLogs.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Calendar size={20} color="#4ade80" />
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
Daily Cost Heatmap
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 1 }}>
Cost intensity by day of month
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', fontSize: '0.75rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(34, 197, 94, 0.5)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Low</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(234, 179, 8, 0.7)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Medium</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>High</Typography>
</Box>
</Box>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1,
mb: 1
}}
>
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<Typography
key={day}
variant="caption"
sx={{
textAlign: 'center',
color: 'rgba(255,255,255,0.6)',
fontWeight: 600,
fontSize: '0.7rem'
}}
>
{day}
</Typography>
))}
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1
}}
>
{calendarDays.map((item, index) => (
<MuiTooltip
key={index}
title={
item.date ? (
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</Typography>
<Typography variant="body2">
Cost: {formatCurrency(item.cost)}
</Typography>
</Box>
) : (
'No data'
)
}
arrow
placement="top"
>
<Box
sx={{
aspectRatio: '1',
backgroundColor: getColorIntensity(item.cost),
borderRadius: 1,
border: item.cost > 0 ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: item.date ? 'pointer' : 'default',
transition: 'all 0.2s ease',
'&:hover': item.date ? {
transform: 'scale(1.1)',
zIndex: 1,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
} : {},
position: 'relative'
}}
>
{item.day > 0 && (
<Typography
variant="caption"
sx={{
color: item.cost > maxCost * 0.5 ? '#ffffff' : 'rgba(255,255,255,0.8)',
fontSize: '0.65rem',
fontWeight: item.cost > 0 ? 600 : 400
}}
>
{item.day}
</Typography>
)}
</Box>
</MuiTooltip>
))}
</Box>
</CardContent>
</Card>
</motion.div>
);
};
export default DailyCostHeatmap;