Files
bambuddy/frontend/src/components/PrintCalendar.tsx
T
maziggy fcee1a6f7e Fix: Print Activity heatmap buckets by local date, not UTC date (#1446)
PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:

  1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
     while the cell tooltip rendered local via `toLocaleDateString`. Same
     data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
     saw evening prints jump to "tomorrow's" cell.

  2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
     `day` Date objects produced by the calendar-generation loop are
     local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
     shifted them back to UTC before the lookup — would have re-broken the
     join even after the bucketing fix.

  3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
     too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.

  Fix adds a `localDateKey(input: string | Date): string` helper to
  utils/date.ts that wraps parseUTCDate and formats via the local-tz
  getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
  returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
  all three spots so the buckets, the cell join, and the today ring share
  the same local-tz axis as the user's tooltip label.

  Backend stays UTC. Bucketing is a presentation concern and the browser
  already knows the user's tz.

  Stragglers flagged for follow-up: StatsPage.computeDateRange builds
  dateFrom/dateTo for backend stats queries using getUTC* getters, so a
  "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
  ranges to the backend. Fixing it properly also needs the backend to
  filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
  model today. localDateKey is in place for reuse when that work lands.
2026-05-20 09:45:28 +02:00

194 lines
6.9 KiB
TypeScript

import { useMemo, useRef, useState, useEffect } from 'react';
import { localDateKey } from '../utils/date';
interface PrintCalendarProps {
printDates: string[]; // Array of ISO date strings
months?: number; // How many months to show (default 3)
}
export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
// Measure container width
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width || 0;
setContainerWidth(width);
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const { weeks, monthLabels, printCounts } = useMemo(() => {
// Count prints per day. Bucket by local-tz YYYY-MM-DD so an evening
// print in a negative-UTC-offset region (e.g. CDT) doesn't get
// attributed to "tomorrow's" UTC cell while its label renders as
// today (#1446).
const counts: Record<string, number> = {};
printDates.forEach((date) => {
const day = localDateKey(date);
if (day) counts[day] = (counts[day] || 0) + 1;
});
// Generate weeks for the last N months
const today = new Date();
const startDate = new Date(today);
startDate.setMonth(startDate.getMonth() - months);
startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday
const weeks: Date[][] = [];
const monthLabels: { month: string; weekIndex: number }[] = [];
let currentWeek: Date[] = [];
let lastMonth = -1;
const current = new Date(startDate);
let weekIndex = 0;
while (current <= today) {
if (current.getDay() === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
weekIndex++;
}
// Track month labels
if (current.getMonth() !== lastMonth) {
monthLabels.push({
month: current.toLocaleDateString('en-US', { month: 'short' }),
weekIndex,
});
lastMonth = current.getMonth();
}
currentWeek.push(new Date(current));
current.setDate(current.getDate() + 1);
}
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return { weeks, monthLabels, printCounts: counts };
}, [printDates, months]);
const maxCount = Math.max(1, ...Object.values(printCounts));
const getColor = (count: number) => {
if (count === 0) return 'bg-bambu-dark';
const intensity = count / maxCount;
if (intensity <= 0.25) return 'bg-bambu-green/30';
if (intensity <= 0.5) return 'bg-bambu-green/50';
if (intensity <= 0.75) return 'bg-bambu-green/75';
return 'bg-bambu-green';
};
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Calculate cell size based on container width
const numWeeks = weeks.length;
const dayLabelWidth = 32; // Space for day labels (Mon, Wed, Fri)
const gap = 2; // Gap between cells
const availableWidth = containerWidth - dayLabelWidth - 16; // 16px padding
const calculatedCellSize = numWeeks > 0 ? Math.floor((availableWidth - (numWeeks - 1) * gap) / numWeeks) : 12;
// Clamp cell size between 8 and 20 pixels
const cellSize = Math.max(8, Math.min(20, calculatedCellSize));
const fontSize = cellSize <= 10 ? 10 : 12;
return (
<div ref={containerRef} className="w-full flex justify-center">
{containerWidth > 0 && (
<div>
{/* Month labels */}
<div className="flex mb-1" style={{ marginLeft: dayLabelWidth + 4 }}>
{monthLabels.map(({ month, weekIndex }, i) => (
<div
key={i}
className="text-bambu-gray"
style={{
fontSize,
marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * (cellSize + gap) - 24}px`,
}}
>
{month}
</div>
))}
</div>
<div className="flex" style={{ gap }}>
{/* Day labels */}
<div className="flex flex-col" style={{ gap, marginRight: 4, width: dayLabelWidth }}>
{dayLabels.map((day, i) => (
<div
key={day}
className="text-bambu-gray flex items-center"
style={{
width: dayLabelWidth,
height: cellSize,
fontSize,
visibility: i % 2 === 1 ? 'visible' : 'hidden',
}}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
{weeks.map((week, weekIdx) => (
<div key={weekIdx} className="flex flex-col" style={{ gap }}>
{[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
const day = week.find((d) => d.getDay() === dayOfWeek);
if (!day) {
return (
<div
key={dayOfWeek}
style={{ width: cellSize, height: cellSize }}
/>
);
}
// Keep lookup + "today" comparison on the same local-tz
// axis as the buckets — toISOString() would shift these
// cells to UTC and break the join (#1446).
const dateStr = localDateKey(day);
const count = printCounts[dateStr] || 0;
const isToday = dateStr === localDateKey(new Date());
return (
<div
key={dayOfWeek}
className={`rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
style={{ width: cellSize, height: cellSize }}
title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
/>
);
})}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-bambu-gray" style={{ fontSize }}>
<span>Less</span>
<div className="flex" style={{ gap }}>
<div className="rounded-sm bg-bambu-dark" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/30" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/50" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green/75" style={{ width: cellSize, height: cellSize }} />
<div className="rounded-sm bg-bambu-green" style={{ width: cellSize, height: cellSize }} />
</div>
<span>More</span>
</div>
</div>
)}
</div>
);
}