PrintCalendar.tsx 6.5 KB

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