QueueTimelineView.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import { useState, useMemo, useEffect } from 'react';
  2. import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';
  3. import { formatDuration, parseUTCDate } from '../utils/date';
  4. import type { PrintQueueItem } from '../api/client';
  5. import { api } from '../api/client';
  6. import { Button } from './Button';
  7. type FilterMode = 'all' | 'printing' | 'queued';
  8. interface ScheduleEvent {
  9. item: PrintQueueItem;
  10. estimatedEnd: Date;
  11. estimatedStart: Date;
  12. progress?: number;
  13. type: 'printing' | 'queued';
  14. }
  15. interface QueueTimelineViewProps {
  16. queueItems: PrintQueueItem[];
  17. printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
  18. onItemClick: (item: PrintQueueItem) => void;
  19. t: (key: string, options?: Record<string, unknown>) => string;
  20. }
  21. function getStartOfDay(date: Date): Date {
  22. const d = new Date(date);
  23. d.setHours(0, 0, 0, 0);
  24. return d;
  25. }
  26. function formatDateLabel(date: Date): string {
  27. return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
  28. }
  29. function formatTimeOnly(date: Date): string {
  30. return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
  31. }
  32. function formatTimeLeft(ms: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
  33. if (ms <= 0) return t('queue.timeline.time.anyMoment');
  34. const totalMin = Math.round(ms / 60000);
  35. if (totalMin < 60) return t('queue.timeline.time.minutesLeft', { minutes: totalMin });
  36. const hours = Math.floor(totalMin / 60);
  37. const mins = totalMin % 60;
  38. if (mins === 0) return t('queue.timeline.time.hoursLeft', { hours });
  39. return t('queue.timeline.time.hoursMinutesLeft', { hours, minutes: mins });
  40. }
  41. function getHourLabel(hour: number): string {
  42. const date = new Date();
  43. date.setHours(hour, 0, 0, 0);
  44. return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
  45. }
  46. function ScheduleCard({
  47. event,
  48. now,
  49. onItemClick,
  50. t,
  51. }: {
  52. event: ScheduleEvent;
  53. now: Date;
  54. onItemClick: (item: PrintQueueItem) => void;
  55. t: (key: string, opts?: Record<string, unknown>) => string;
  56. }) {
  57. const item = event.item;
  58. const displayName = item.archive_name || item.library_file_name || t('common.unknown');
  59. const printerName = item.printer_name || (item.target_model ? `${t('queue.filter.any')} ${item.target_model}` : t('queue.timeline.unassigned'));
  60. const isPrinting = event.type === 'printing';
  61. const timeLeft = event.estimatedEnd.getTime() - now.getTime();
  62. const thumbnailUrl = item.archive_thumbnail
  63. ? api.getArchiveThumbnail(item.archive_id!)
  64. : item.library_file_thumbnail
  65. ? api.getLibraryFileThumbnailUrl(item.library_file_id!)
  66. : null;
  67. return (
  68. <div
  69. className={`flex items-center gap-3 px-3 sm:px-4 py-3 bg-bambu-dark-secondary rounded-xl border cursor-pointer transition-all hover:border-bambu-green/40
  70. ${isPrinting ? 'border-blue-500/30' : 'border-bambu-dark-tertiary'}`}
  71. onClick={() => onItemClick(item)}
  72. >
  73. {/* Left accent */}
  74. <div className={`w-1 self-stretch rounded-full shrink-0 ${isPrinting ? 'bg-blue-500' : 'bg-bambu-green/40'}`} />
  75. {/* Thumbnail */}
  76. <div className="w-10 h-10 shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  77. {thumbnailUrl ? (
  78. <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
  79. ) : (
  80. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  81. <Layers className="w-5 h-5" />
  82. </div>
  83. )}
  84. </div>
  85. {/* Info */}
  86. <div className="flex-1 min-w-0">
  87. <p className="text-sm text-white font-medium truncate">{displayName}</p>
  88. <div className="flex items-center gap-2 mt-0.5">
  89. <span className="flex items-center gap-1 text-xs text-bambu-gray">
  90. <PrinterIcon className="w-3 h-3" />
  91. <span className="truncate max-w-[120px] sm:max-w-none">{printerName}</span>
  92. </span>
  93. {item.print_time_seconds && (
  94. <span className="hidden sm:inline text-xs text-bambu-gray">
  95. {formatDuration(item.print_time_seconds)}
  96. </span>
  97. )}
  98. </div>
  99. {/* Progress bar for active prints */}
  100. {isPrinting && event.progress != null && (
  101. <div className="flex items-center gap-2 mt-1.5">
  102. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5">
  103. <div
  104. className="bg-blue-500 h-1.5 rounded-full transition-all"
  105. style={{ width: `${event.progress}%` }}
  106. />
  107. </div>
  108. <span className="text-xs text-blue-400 shrink-0">{Math.round(event.progress)}%</span>
  109. </div>
  110. )}
  111. </div>
  112. {/* Time info */}
  113. <div className="text-right shrink-0">
  114. <p className="text-sm text-white font-medium">{formatTimeOnly(event.estimatedEnd)}</p>
  115. <p className={`text-xs mt-0.5 ${isPrinting ? 'text-blue-400' : 'text-bambu-gray'}`}>
  116. {formatTimeLeft(timeLeft, t)}
  117. </p>
  118. </div>
  119. </div>
  120. );
  121. }
  122. export function QueueTimelineView({
  123. queueItems,
  124. printerStatuses,
  125. onItemClick,
  126. t,
  127. }: QueueTimelineViewProps) {
  128. const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date()));
  129. const [now, setNow] = useState(() => new Date());
  130. const [filter, setFilter] = useState<FilterMode>('all');
  131. // Update "now" every 60 seconds
  132. useEffect(() => {
  133. const interval = setInterval(() => setNow(new Date()), 60000);
  134. return () => clearInterval(interval);
  135. }, []);
  136. const nowMs = now.getTime();
  137. const isToday = getStartOfDay(new Date()).getTime() === getStartOfDay(viewDate).getTime();
  138. // Build schedule events with ETA chaining
  139. const events = useMemo(() => {
  140. const result: ScheduleEvent[] = [];
  141. // Group pending items by printer for chaining
  142. const pendingByPrinter = new Map<number | null, PrintQueueItem[]>();
  143. for (const item of queueItems) {
  144. if (item.status === 'printing') {
  145. const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined;
  146. const start = parseUTCDate(item.started_at) || new Date();
  147. let endTime: Date;
  148. if (status?.remaining_time != null && status.remaining_time > 0) {
  149. endTime = new Date(nowMs + status.remaining_time * 60 * 1000);
  150. } else if (item.print_time_seconds) {
  151. const progress = status?.progress || 0;
  152. const remainingFraction = Math.max(0, 1 - progress / 100);
  153. endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);
  154. } else {
  155. endTime = new Date(nowMs + 3600000);
  156. }
  157. result.push({
  158. item,
  159. estimatedStart: start,
  160. estimatedEnd: endTime,
  161. progress: status?.progress ?? undefined,
  162. type: 'printing',
  163. });
  164. } else if (item.status === 'pending') {
  165. const pid = item.printer_id;
  166. if (!pendingByPrinter.has(pid)) pendingByPrinter.set(pid, []);
  167. pendingByPrinter.get(pid)!.push(item);
  168. }
  169. }
  170. // Chain pending items per printer
  171. for (const [printerId, items] of pendingByPrinter) {
  172. items.sort((a, b) => a.position - b.position);
  173. // Find when the current active print on this printer ends
  174. let chainEnd = nowMs;
  175. for (const ev of result) {
  176. if (ev.item.printer_id === printerId && ev.type === 'printing') {
  177. chainEnd = Math.max(chainEnd, ev.estimatedEnd.getTime());
  178. }
  179. }
  180. for (const item of items) {
  181. // Respect scheduled_time
  182. const scheduledTime = parseUTCDate(item.scheduled_time);
  183. if (scheduledTime) {
  184. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  185. if (scheduledTime.getTime() <= sixMonthsFromNow) {
  186. chainEnd = Math.max(chainEnd, scheduledTime.getTime());
  187. }
  188. }
  189. const duration = (item.print_time_seconds || 3600) * 1000;
  190. const startTime = new Date(chainEnd);
  191. const endTime = new Date(chainEnd + duration);
  192. result.push({
  193. item,
  194. estimatedStart: startTime,
  195. estimatedEnd: endTime,
  196. type: 'queued',
  197. });
  198. chainEnd = endTime.getTime();
  199. }
  200. }
  201. // Sort by estimated end time
  202. result.sort((a, b) => a.estimatedEnd.getTime() - b.estimatedEnd.getTime());
  203. return result;
  204. }, [queueItems, printerStatuses, nowMs]);
  205. // Filter events for the selected day
  206. const viewDayStart = getStartOfDay(viewDate).getTime();
  207. const viewDayEnd = viewDayStart + 24 * 60 * 60 * 1000 - 1;
  208. const filteredEvents = useMemo(() => {
  209. return events.filter(ev => {
  210. // Event finishes within the viewed day
  211. const endMs = ev.estimatedEnd.getTime();
  212. if (endMs < viewDayStart || endMs > viewDayEnd) return false;
  213. // Filter by type
  214. if (filter === 'printing') return ev.type === 'printing';
  215. if (filter === 'queued') return ev.type === 'queued';
  216. return true;
  217. });
  218. }, [events, viewDayStart, viewDayEnd, filter]);
  219. // Group events by hour for time markers
  220. const groupedByHour = useMemo(() => {
  221. const groups: Map<number, ScheduleEvent[]> = new Map();
  222. for (const ev of filteredEvents) {
  223. const hour = ev.estimatedEnd.getHours();
  224. if (!groups.has(hour)) groups.set(hour, []);
  225. groups.get(hour)!.push(ev);
  226. }
  227. // Sort by hour
  228. return Array.from(groups.entries()).sort(([a], [b]) => a - b);
  229. }, [filteredEvents]);
  230. // Counts for filter tabs
  231. const printingCount = events.filter(ev => ev.type === 'printing' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;
  232. const queuedCount = events.filter(ev => ev.type === 'queued' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;
  233. // Overall completion estimate
  234. const allDoneBy = useMemo(() => {
  235. let latest = 0;
  236. for (const ev of events) {
  237. latest = Math.max(latest, ev.estimatedEnd.getTime());
  238. }
  239. return latest > 0 ? new Date(latest) : null;
  240. }, [events]);
  241. const goToday = () => setViewDate(getStartOfDay(new Date()));
  242. const goPrev = () => {
  243. const d = new Date(viewDate);
  244. d.setDate(d.getDate() - 1);
  245. setViewDate(d);
  246. };
  247. const goNext = () => {
  248. const d = new Date(viewDate);
  249. d.setDate(d.getDate() + 1);
  250. setViewDate(d);
  251. };
  252. const filterTabs: { key: FilterMode; label: string; count: number }[] = [
  253. { key: 'all', label: t('queue.timeline.filterAll'), count: printingCount + queuedCount },
  254. { key: 'printing', label: t('queue.timeline.filterPrinting'), count: printingCount },
  255. { key: 'queued', label: t('queue.timeline.filterQueued'), count: queuedCount },
  256. ];
  257. return (
  258. <div>
  259. {/* Header */}
  260. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
  261. {/* Day navigation */}
  262. <div className="flex items-center gap-2">
  263. <Button variant="ghost" size="sm" onClick={goPrev} className="p-1.5">
  264. <ChevronLeft className="w-4 h-4" />
  265. </Button>
  266. <span className="text-sm font-medium text-white min-w-[140px] text-center">
  267. {formatDateLabel(viewDate)}
  268. </span>
  269. <Button variant="ghost" size="sm" onClick={goNext} className="p-1.5">
  270. <ChevronRight className="w-4 h-4" />
  271. </Button>
  272. {!isToday && (
  273. <Button variant="ghost" size="sm" onClick={goToday} className="text-xs text-bambu-green">
  274. {t('queue.timeline.day.today')}
  275. </Button>
  276. )}
  277. </div>
  278. {allDoneBy && (
  279. <span className="text-xs text-bambu-gray flex items-center gap-1.5">
  280. <Clock className="w-3.5 h-3.5" />
  281. {t('queue.timeline.allDoneBy', {
  282. time: allDoneBy.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),
  283. })}
  284. </span>
  285. )}
  286. </div>
  287. {/* Filter tabs */}
  288. <div className="flex gap-2 mb-5">
  289. {filterTabs.map((tab) => (
  290. <button
  291. key={tab.key}
  292. onClick={() => setFilter(tab.key)}
  293. className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
  294. filter === tab.key
  295. ? 'bg-bambu-green text-white'
  296. : 'bg-bambu-dark-secondary border border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  297. }`}
  298. >
  299. {tab.label}
  300. {tab.count > 0 && (
  301. <span className={`ml-1.5 text-xs ${filter === tab.key ? 'text-white/70' : 'text-bambu-gray'}`}>
  302. {tab.count}
  303. </span>
  304. )}
  305. </button>
  306. ))}
  307. </div>
  308. {/* Schedule feed */}
  309. {groupedByHour.length > 0 ? (
  310. <div className="space-y-6">
  311. {groupedByHour.map(([hour, hourEvents]) => (
  312. <div key={hour}>
  313. {/* Hour marker */}
  314. <div className="flex items-center gap-3 mb-3">
  315. <span className="text-xs font-medium text-bambu-gray w-14 shrink-0">
  316. {getHourLabel(hour)}
  317. </span>
  318. <div className="flex-1 h-px bg-bambu-dark-tertiary" />
  319. </div>
  320. {/* Events in this hour */}
  321. <div className="space-y-2 sm:ml-[68px]">
  322. {hourEvents.map((event) => (
  323. <ScheduleCard
  324. key={event.item.id}
  325. event={event}
  326. now={now}
  327. onItemClick={onItemClick}
  328. t={t}
  329. />
  330. ))}
  331. </div>
  332. </div>
  333. ))}
  334. </div>
  335. ) : (
  336. <div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
  337. <Layers className="w-12 h-12 mb-3 opacity-30" />
  338. <p className="text-sm">{t('queue.timeline.noData')}</p>
  339. </div>
  340. )}
  341. </div>
  342. );
  343. }