From f390852705baff29256eb6d00eed36ce1edebab2 Mon Sep 17 00:00:00 2001 From: Ju Date: Mon, 27 Jul 2026 16:34:52 +0000 Subject: [PATCH 1/4] feat: enhance TimerCard with label editing and improve TimerPanel to manage timer labels --- src/components/panels/TimerCard.tsx | 57 +++++++++++++++++++++++++--- src/components/panels/TimerPanel.tsx | 22 +++++++++-- src/hooks/useCountdown.ts | 22 ++++++++++- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/components/panels/TimerCard.tsx b/src/components/panels/TimerCard.tsx index f0cea50..a5a2ef6 100644 --- a/src/components/panels/TimerCard.tsx +++ b/src/components/panels/TimerCard.tsx @@ -26,17 +26,20 @@ function parseMmSs(text: string): number | null { interface TimerCardProps { label: string; + onLabelChange?: (label: string) => void; onRemove?: () => void; } -const TimerCard: React.FC = ({ label, onRemove }) => { - const { status, remainingMs, start, reset } = useCountdown(); +const TimerCard: React.FC = ({ label, onLabelChange, onRemove }) => { + const { status, remainingMs, start, pause, resume, reset } = useCountdown(); const [presetMs, setPresetMs] = useState(DEFAULT_PRESET_MS); const [inputText, setInputText] = useState(formatMmSs(DEFAULT_PRESET_MS)); const [inputInvalid, setInputInvalid] = useState(false); const isIdle = status === 'idle'; + const isRunning = status === 'running'; + const isPaused = status === 'paused'; const isFinished = status === 'finished'; const countdownColor = isFinished @@ -68,7 +71,13 @@ const TimerCard: React.FC = ({ label, onRemove }) => { return (
-

{label}

+ onLabelChange?.(e.target.value)} + /> {onRemove && ( - ) : ( + )} + {isRunning && ( + + )} + {isPaused && ( + + )} + {!isIdle && ( @@ -142,11 +162,28 @@ const TimerCard: React.FC = ({ label, onRemove }) => { align-items: center; } - h4 { + .label-input { margin: 0; + padding: 0.15rem 0.3rem; font-size: 0.95rem; font-weight: 600; color: #eaeaea; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + width: 100%; + min-width: 0; + font-family: inherit; + } + + .label-input:hover { + border-color: #444; + } + + .label-input:focus { + outline: none; + background: #111; + border-color: #0070f3; } .remove-btn { @@ -229,6 +266,14 @@ const TimerCard: React.FC = ({ label, onRemove }) => { button.reset:hover { background: #666; } + + button.pause { + background: #d9a441; + } + + button.pause:hover { + background: #c4923a; + } `}
); diff --git a/src/components/panels/TimerPanel.tsx b/src/components/panels/TimerPanel.tsx index 6151929..8bba273 100644 --- a/src/components/panels/TimerPanel.tsx +++ b/src/components/panels/TimerPanel.tsx @@ -5,7 +5,7 @@ import TimerCard from './TimerCard'; const DEFAULT_TIMER_COUNT = 5; const MIN_TIMERS = 1; -const MAX_TIMERS = 8; +const MAX_TIMERS = 20; function smallestFreeId(ids: number[]): number { const used = new Set(ids); @@ -14,21 +14,34 @@ function smallestFreeId(ids: number[]): number { return id; } +const initialIds = Array.from({ length: DEFAULT_TIMER_COUNT }, (_, i) => i + 1); + const TimerPanel: React.FC = () => { - const [timerIds, setTimerIds] = useState( - Array.from({ length: DEFAULT_TIMER_COUNT }, (_, i) => i + 1), + const [timerIds, setTimerIds] = useState(initialIds); + const [labels, setLabels] = useState>(() => + Object.fromEntries(initialIds.map((id) => [id, `Timer ${id}`])), ); const addTimer = () => { setTimerIds((prev) => { if (prev.length >= MAX_TIMERS) return prev; const id = smallestFreeId(prev); + setLabels((prevLabels) => ({ ...prevLabels, [id]: `Timer ${id}` })); return [...prev, id].sort((a, b) => a - b); }); }; const removeTimer = (id: number) => { setTimerIds((prev) => (prev.length > MIN_TIMERS ? prev.filter((t) => t !== id) : prev)); + setLabels((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const renameTimer = (id: number, label: string) => { + setLabels((prev) => ({ ...prev, [id]: label })); }; return ( @@ -49,7 +62,8 @@ const TimerPanel: React.FC = () => { {timerIds.map((id) => ( renameTimer(id, label)} onRemove={timerIds.length > MIN_TIMERS ? () => removeTimer(id) : undefined} /> ))} diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts index 225f323..c64bf63 100644 --- a/src/hooks/useCountdown.ts +++ b/src/hooks/useCountdown.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; -export type CountdownStatus = 'idle' | 'running' | 'finished'; +export type CountdownStatus = 'idle' | 'running' | 'paused' | 'finished'; const TICK_INTERVAL_MS = 200; @@ -10,6 +10,8 @@ export interface UseCountdownResult { status: CountdownStatus; remainingMs: number; start: (durationMs: number) => void; + pause: () => void; + resume: () => void; reset: () => void; } @@ -43,11 +45,27 @@ export function useCountdown(): UseCountdownResult { setStatus('running'); }; + const pause = () => { + setStatus((prev) => { + if (prev !== 'running') return prev; + endTimeRef.current = null; + return 'paused'; + }); + }; + + const resume = () => { + setStatus((prev) => { + if (prev !== 'paused') return prev; + endTimeRef.current = Date.now() + remainingMs; + return 'running'; + }); + }; + const reset = () => { endTimeRef.current = null; setRemainingMs(0); setStatus('idle'); }; - return { status, remainingMs, start, reset }; + return { status, remainingMs, start, pause, resume, reset }; } From 4071461f4fb664b29c6a3866162b46aeea711240 Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 28 Jul 2026 18:07:10 +0000 Subject: [PATCH 2/4] feat: refactor TimerPanel to support multiple timer groups and improve timer management --- src/components/panels/TimerPanel.tsx | 120 ++++++++++++++++++++------- 1 file changed, 92 insertions(+), 28 deletions(-) diff --git a/src/components/panels/TimerPanel.tsx b/src/components/panels/TimerPanel.tsx index 8bba273..48e2be3 100644 --- a/src/components/panels/TimerPanel.tsx +++ b/src/components/panels/TimerPanel.tsx @@ -3,9 +3,17 @@ import React, { useState } from 'react'; import TimerCard from './TimerCard'; -const DEFAULT_TIMER_COUNT = 5; -const MIN_TIMERS = 1; -const MAX_TIMERS = 20; +type TimerGroup = 'orders' | 'other'; + +const DEFAULT_ORDER_COUNT = 3; +const DEFAULT_OTHER_COUNT = 2; +const MIN_TIMERS_PER_GROUP = 1; +const MAX_TIMERS_PER_GROUP = 20; + +const GROUPS: { key: TimerGroup; title: string; addLabel: string }[] = [ + { key: 'orders', title: 'Orders', addLabel: '+ Add Order Timer' }, + { key: 'other', title: 'Cooking', addLabel: '+ Add Timer' }, +]; function smallestFreeId(ids: number[]): number { const used = new Set(ids); @@ -14,30 +22,50 @@ function smallestFreeId(ids: number[]): number { return id; } -const initialIds = Array.from({ length: DEFAULT_TIMER_COUNT }, (_, i) => i + 1); +const initialOrderIds = Array.from({ length: DEFAULT_ORDER_COUNT }, (_, i) => i + 1); +const initialOtherIds = Array.from( + { length: DEFAULT_OTHER_COUNT }, + (_, i) => DEFAULT_ORDER_COUNT + i + 1, +); +const initialIds = [...initialOrderIds, ...initialOtherIds]; const TimerPanel: React.FC = () => { const [timerIds, setTimerIds] = useState(initialIds); + const [groups, setGroups] = useState>(() => ({ + ...Object.fromEntries(initialOrderIds.map((id) => [id, 'orders' as TimerGroup])), + ...Object.fromEntries(initialOtherIds.map((id) => [id, 'other' as TimerGroup])), + })); const [labels, setLabels] = useState>(() => Object.fromEntries(initialIds.map((id) => [id, `Timer ${id}`])), ); - const addTimer = () => { + const addTimer = (group: TimerGroup) => { setTimerIds((prev) => { - if (prev.length >= MAX_TIMERS) return prev; + const countInGroup = prev.filter((id) => groups[id] === group).length; + if (countInGroup >= MAX_TIMERS_PER_GROUP) return prev; const id = smallestFreeId(prev); setLabels((prevLabels) => ({ ...prevLabels, [id]: `Timer ${id}` })); + setGroups((prevGroups) => ({ ...prevGroups, [id]: group })); return [...prev, id].sort((a, b) => a - b); }); }; const removeTimer = (id: number) => { - setTimerIds((prev) => (prev.length > MIN_TIMERS ? prev.filter((t) => t !== id) : prev)); + const group = groups[id]; + const countInGroup = timerIds.filter((t) => groups[t] === group).length; + if (countInGroup <= MIN_TIMERS_PER_GROUP) return; + + setTimerIds((prev) => prev.filter((t) => t !== id)); setLabels((prev) => { const next = { ...prev }; delete next[id]; return next; }); + setGroups((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); }; const renameTimer = (id: number, label: string) => { @@ -48,26 +76,37 @@ const TimerPanel: React.FC = () => {

Multi-Timer

-
-
- {timerIds.map((id) => ( - renameTimer(id, label)} - onRemove={timerIds.length > MIN_TIMERS ? () => removeTimer(id) : undefined} - /> - ))} -
+ {GROUPS.map(({ key, title, addLabel }) => { + const idsInGroup = timerIds.filter((id) => groups[id] === key); + return ( +
+
+

{title}

+ +
+ +
+ {idsInGroup.map((id) => ( + renameTimer(id, label)} + onRemove={idsInGroup.length > MIN_TIMERS_PER_GROUP ? () => removeTimer(id) : undefined} + /> + ))} +
+
+ ); + })}
From 524190a36cdb8b641c02e087a9cf2b5954033615 Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 4 Aug 2026 00:27:34 +0000 Subject: [PATCH 4/4] feat: add Stopwatch component and useStopwatch hook for timer functionality --- src/components/Stopwatch.tsx | 87 ++++++++++++++++ src/components/panels/TimerCard.tsx | 35 +++++-- src/components/panels/TimerPanel.tsx | 144 ++++++++++++++++++++++++++- src/hooks/useStopwatch.ts | 59 +++++++++++ 4 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 src/components/Stopwatch.tsx create mode 100644 src/hooks/useStopwatch.ts diff --git a/src/components/Stopwatch.tsx b/src/components/Stopwatch.tsx new file mode 100644 index 0000000..2f6f0b3 --- /dev/null +++ b/src/components/Stopwatch.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { useStopwatch } from '@/hooks/useStopwatch'; + +function formatStopwatch(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +const Stopwatch: React.FC = () => { + const { status, elapsedMs, start, pause, reset } = useStopwatch(); + const isRunning = status === 'running'; + + return ( +
+
{formatStopwatch(elapsedMs)}
+
+ + +
+ + +
+ ); +}; + +export default Stopwatch; diff --git a/src/components/panels/TimerCard.tsx b/src/components/panels/TimerCard.tsx index 4dd03da..815f52e 100644 --- a/src/components/panels/TimerCard.tsx +++ b/src/components/panels/TimerCard.tsx @@ -5,6 +5,30 @@ import { useCountdown } from '@/hooks/useCountdown'; const DEFAULT_PRESET_MS = 4 * 60 * 1000; +type TimerVariant = 'order' | 'ingredient'; + +const DEFAULT_COLOR = '#00ffcc'; +const FINISHED_COLOR = '#ff5566'; + +// Ordered smallest-threshold-first so the first match wins. +const COLOR_THRESHOLDS_MS: Record = { + order: [ + { maxMs: 42 * 1000, color: '#ff5566' }, // red + { maxMs: 90 * 1000, color: '#ff8c1a' }, // orange + { maxMs: 4 * 60 * 1000, color: '#ffcc00' }, // yellow + ], + ingredient: [ + { maxMs: 35 * 1000, color: '#ff5566' }, // red + { maxMs: 60 * 1000, color: '#ffcc00' }, // yellow + ], +}; + +function countdownColorFor(variant: TimerVariant, remainingMs: number, isFinished: boolean): string { + if (isFinished) return FINISHED_COLOR; + const threshold = COLOR_THRESHOLDS_MS[variant].find((t) => remainingMs <= t.maxMs); + return threshold?.color ?? DEFAULT_COLOR; +} + function formatMmSs(ms: number): string { const totalSeconds = Math.max(0, Math.round(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); @@ -28,9 +52,10 @@ interface TimerCardProps { label: string; onLabelChange?: (label: string) => void; onRemove?: () => void; + variant?: TimerVariant; } -const TimerCard: React.FC = ({ label, onLabelChange, onRemove }) => { +const TimerCard: React.FC = ({ label, onLabelChange, onRemove, variant = 'ingredient' }) => { const { status, remainingMs, start, pause, resume, reset } = useCountdown(); const [presetMs, setPresetMs] = useState(DEFAULT_PRESET_MS); @@ -42,13 +67,7 @@ const TimerCard: React.FC = ({ label, onLabelChange, onRemove }) const isPaused = status === 'paused'; const isFinished = status === 'finished'; - const countdownColor = isFinished - ? '#ff5566' - : remainingMs <= 30 * 1000 - ? '#ff5566' - : remainingMs <= 60 * 1000 - ? '#ffcc00' - : '#00ffcc'; + const countdownColor = countdownColorFor(variant, remainingMs, isFinished); const handleGo = () => { const parsedMs = parseMmSs(inputText); diff --git a/src/components/panels/TimerPanel.tsx b/src/components/panels/TimerPanel.tsx index 630c7f5..4b95f1b 100644 --- a/src/components/panels/TimerPanel.tsx +++ b/src/components/panels/TimerPanel.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import TimerCard from './TimerCard'; +import Stopwatch from '@/components/Stopwatch'; const DEFAULT_COLUMN_NAMES = ['Orders', 'Cooking']; const DEFAULT_TIMERS_PER_COLUMN = [3, 2]; @@ -52,6 +53,9 @@ const TimerPanel: React.FC = () => { }); return all; }); + const [notes, setNotes] = useState>({}); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const addColumn = () => { setColumns((prev) => { @@ -72,12 +76,39 @@ const TimerPanel: React.FC = () => { delete next[columnId]; return next; }); + setNotes((prev) => { + const next = { ...prev }; + delete next[columnId]; + return next; + }); }; const renameColumn = (columnId: number, name: string) => { setColumns((prev) => prev.map((c) => (c.id === columnId ? { ...c, name } : c))); }; + const setNote = (columnId: number, note: string) => { + setNotes((prev) => ({ ...prev, [columnId]: note })); + }; + + const reorderColumns = (fromIndex: number, toIndex: number) => { + setColumns((prev) => { + if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || toIndex >= prev.length) { + return prev; + } + const next = [...prev]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; + }); + }; + + const handleColumnDrop = (index: number) => { + if (draggedIndex !== null) reorderColumns(draggedIndex, index); + setDraggedIndex(null); + setDragOverIndex(null); + }; + const addTimer = (columnId: number) => { setTimerIdsByColumn((prev) => { const idsInColumn = prev[columnId] ?? []; @@ -119,17 +150,70 @@ const TimerPanel: React.FC = () => { {columns.map((column, index) => { const idsInColumn = timerIdsByColumn[column.id] ?? []; const columnStyle = index > 0 ? { borderLeft: '1px solid #333', paddingLeft: '1rem' } : undefined; + const isDragTarget = draggedIndex !== null && draggedIndex !== index; return ( -
+
{ + if (isDragTarget) e.preventDefault(); + }} + onDragEnter={() => { + if (isDragTarget) setDragOverIndex(index); + }} + onDragLeave={() => { + setDragOverIndex((prev) => (prev === index ? null : prev)); + }} + onDrop={(e) => { + e.preventDefault(); + handleColumnDrop(index); + }} + > +
{ + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + > + ⠿ +
renameColumn(column.id, name)} onRemove={columns.length > MIN_COLUMNS ? () => removeColumn(column.id) : undefined} + variant="order" + /> +