mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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.
194 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|