PrintCalendar.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { useMemo, useRef, useState, useEffect } from 'react';
  2. import { localDateKey } from '../utils/date';
  3. interface PrintCalendarProps {
  4. printDates: string[]; // Array of ISO date strings
  5. months?: number; // How many months to show (default 3)
  6. }
  7. export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
  8. const containerRef = useRef<HTMLDivElement>(null);
  9. const [containerWidth, setContainerWidth] = useState(0);
  10. // Measure container width
  11. useEffect(() => {
  12. const container = containerRef.current;
  13. if (!container) return;
  14. const observer = new ResizeObserver((entries) => {
  15. const width = entries[0]?.contentRect.width || 0;
  16. setContainerWidth(width);
  17. });
  18. observer.observe(container);
  19. return () => observer.disconnect();
  20. }, []);
  21. const { weeks, monthLabels, printCounts } = useMemo(() => {
  22. // Count prints per day. Bucket by local-tz YYYY-MM-DD so an evening
  23. // print in a negative-UTC-offset region (e.g. CDT) doesn't get
  24. // attributed to "tomorrow's" UTC cell while its label renders as
  25. // today (#1446).
  26. const counts: Record<string, number> = {};
  27. printDates.forEach((date) => {
  28. const day = localDateKey(date);
  29. if (day) counts[day] = (counts[day] || 0) + 1;
  30. });
  31. // Generate weeks for the last N months
  32. const today = new Date();
  33. const startDate = new Date(today);
  34. startDate.setMonth(startDate.getMonth() - months);
  35. startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday
  36. const weeks: Date[][] = [];
  37. const monthLabels: { month: string; weekIndex: number }[] = [];
  38. let currentWeek: Date[] = [];
  39. let lastMonth = -1;
  40. const current = new Date(startDate);
  41. let weekIndex = 0;
  42. while (current <= today) {
  43. if (current.getDay() === 0 && currentWeek.length > 0) {
  44. weeks.push(currentWeek);
  45. currentWeek = [];
  46. weekIndex++;
  47. }
  48. // Track month labels
  49. if (current.getMonth() !== lastMonth) {
  50. monthLabels.push({
  51. month: current.toLocaleDateString('en-US', { month: 'short' }),
  52. weekIndex,
  53. });
  54. lastMonth = current.getMonth();
  55. }
  56. currentWeek.push(new Date(current));
  57. current.setDate(current.getDate() + 1);
  58. }
  59. if (currentWeek.length > 0) {
  60. weeks.push(currentWeek);
  61. }
  62. return { weeks, monthLabels, printCounts: counts };
  63. }, [printDates, months]);
  64. const maxCount = Math.max(1, ...Object.values(printCounts));
  65. const getColor = (count: number) => {
  66. if (count === 0) return 'bg-bambu-dark';
  67. const intensity = count / maxCount;
  68. if (intensity <= 0.25) return 'bg-bambu-green/30';
  69. if (intensity <= 0.5) return 'bg-bambu-green/50';
  70. if (intensity <= 0.75) return 'bg-bambu-green/75';
  71. return 'bg-bambu-green';
  72. };
  73. const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  74. // Calculate cell size based on container width
  75. const numWeeks = weeks.length;
  76. const dayLabelWidth = 32; // Space for day labels (Mon, Wed, Fri)
  77. const gap = 2; // Gap between cells
  78. const availableWidth = containerWidth - dayLabelWidth - 16; // 16px padding
  79. const calculatedCellSize = numWeeks > 0 ? Math.floor((availableWidth - (numWeeks - 1) * gap) / numWeeks) : 12;
  80. // Clamp cell size between 8 and 20 pixels
  81. const cellSize = Math.max(8, Math.min(20, calculatedCellSize));
  82. const fontSize = cellSize <= 10 ? 10 : 12;
  83. return (
  84. <div ref={containerRef} className="w-full flex justify-center">
  85. {containerWidth > 0 && (
  86. <div>
  87. {/* Month labels */}
  88. <div className="flex mb-1" style={{ marginLeft: dayLabelWidth + 4 }}>
  89. {monthLabels.map(({ month, weekIndex }, i) => (
  90. <div
  91. key={i}
  92. className="text-bambu-gray"
  93. style={{
  94. fontSize,
  95. marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * (cellSize + gap) - 24}px`,
  96. }}
  97. >
  98. {month}
  99. </div>
  100. ))}
  101. </div>
  102. <div className="flex" style={{ gap }}>
  103. {/* Day labels */}
  104. <div className="flex flex-col" style={{ gap, marginRight: 4, width: dayLabelWidth }}>
  105. {dayLabels.map((day, i) => (
  106. <div
  107. key={day}
  108. className="text-bambu-gray flex items-center"
  109. style={{
  110. width: dayLabelWidth,
  111. height: cellSize,
  112. fontSize,
  113. visibility: i % 2 === 1 ? 'visible' : 'hidden',
  114. }}
  115. >
  116. {day}
  117. </div>
  118. ))}
  119. </div>
  120. {/* Calendar grid */}
  121. {weeks.map((week, weekIdx) => (
  122. <div key={weekIdx} className="flex flex-col" style={{ gap }}>
  123. {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
  124. const day = week.find((d) => d.getDay() === dayOfWeek);
  125. if (!day) {
  126. return (
  127. <div
  128. key={dayOfWeek}
  129. style={{ width: cellSize, height: cellSize }}
  130. />
  131. );
  132. }
  133. // Keep lookup + "today" comparison on the same local-tz
  134. // axis as the buckets — toISOString() would shift these
  135. // cells to UTC and break the join (#1446).
  136. const dateStr = localDateKey(day);
  137. const count = printCounts[dateStr] || 0;
  138. const isToday = dateStr === localDateKey(new Date());
  139. return (
  140. <div
  141. key={dayOfWeek}
  142. className={`rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
  143. style={{ width: cellSize, height: cellSize }}
  144. title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
  145. />
  146. );
  147. })}
  148. </div>
  149. ))}
  150. </div>
  151. {/* Legend */}
  152. <div className="flex items-center gap-2 mt-3 text-bambu-gray" style={{ fontSize }}>
  153. <span>Less</span>
  154. <div className="flex" style={{ gap }}>
  155. <div className="rounded-sm bg-bambu-dark" style={{ width: cellSize, height: cellSize }} />
  156. <div className="rounded-sm bg-bambu-green/30" style={{ width: cellSize, height: cellSize }} />
  157. <div className="rounded-sm bg-bambu-green/50" style={{ width: cellSize, height: cellSize }} />
  158. <div className="rounded-sm bg-bambu-green/75" style={{ width: cellSize, height: cellSize }} />
  159. <div className="rounded-sm bg-bambu-green" style={{ width: cellSize, height: cellSize }} />
  160. </div>
  161. <span>More</span>
  162. </div>
  163. </div>
  164. )}
  165. </div>
  166. );
  167. }