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 a5a2ef6..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); @@ -165,7 +184,7 @@ const TimerCard: React.FC = ({ label, onLabelChange, onRemove }) .label-input { margin: 0; padding: 0.15rem 0.3rem; - font-size: 0.95rem; + font-size: 1.5rem; font-weight: 600; color: #eaeaea; background: transparent; diff --git a/src/components/panels/TimerPanel.tsx b/src/components/panels/TimerPanel.tsx index 48e2be3..4b95f1b 100644 --- a/src/components/panels/TimerPanel.tsx +++ b/src/components/panels/TimerPanel.tsx @@ -2,18 +2,14 @@ import React, { useState } from 'react'; import TimerCard from './TimerCard'; +import Stopwatch from '@/components/Stopwatch'; -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' }, -]; +const DEFAULT_COLUMN_NAMES = ['Orders', 'Cooking']; +const DEFAULT_TIMERS_PER_COLUMN = [3, 2]; +const MIN_TIMERS_PER_COLUMN = 1; +const MAX_TIMERS_PER_COLUMN = 20; +const MIN_COLUMNS = 1; +const MAX_COLUMNS = 10; function smallestFreeId(ids: number[]): number { const used = new Set(ids); @@ -22,46 +18,115 @@ function smallestFreeId(ids: number[]): number { return id; } -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]; +let columnSeq = 0; +function nextColumnId(): number { + columnSeq += 1; + return columnSeq; +} + +const initialColumns = DEFAULT_COLUMN_NAMES.map((name, i) => ({ + id: nextColumnId(), + name, + count: DEFAULT_TIMERS_PER_COLUMN[i] ?? 1, +})); + +let idCounter = 0; +const initialTimerIds: Record = {}; +initialColumns.forEach((column) => { + const ids: number[] = []; + for (let i = 0; i < column.count; i += 1) { + idCounter += 1; + ids.push(idCounter); + } + initialTimerIds[column.id] = ids; +}); 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 [columns, setColumns] = useState(initialColumns.map(({ id, name }) => ({ id, name }))); + const [timerIdsByColumn, setTimerIdsByColumn] = useState>(initialTimerIds); + const [labels, setLabels] = useState>(() => { + const all: Record = {}; + Object.values(initialTimerIds) + .flat() + .forEach((id) => { + all[id] = `Timer ${id}`; + }); + return all; + }); + const [notes, setNotes] = useState>({}); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); - const addTimer = (group: TimerGroup) => { - setTimerIds((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 addColumn = () => { + setColumns((prev) => { + if (prev.length >= MAX_COLUMNS) return prev; + const id = nextColumnId(); + setTimerIdsByColumn((prevTimers) => ({ ...prevTimers, [id]: [] })); + return [...prev, { id, name: `Column ${prev.length + 1}` }]; }); }; - const removeTimer = (id: number) => { - 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 removeColumn = (columnId: number) => { + setColumns((prev) => { + if (prev.length <= MIN_COLUMNS) return prev; + return prev.filter((c) => c.id !== columnId); + }); + setTimerIdsByColumn((prev) => { const next = { ...prev }; - delete next[id]; + 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; }); - setGroups((prev) => { + }; + + const handleColumnDrop = (index: number) => { + if (draggedIndex !== null) reorderColumns(draggedIndex, index); + setDraggedIndex(null); + setDragOverIndex(null); + }; + + const addTimer = (columnId: number) => { + setTimerIdsByColumn((prev) => { + const idsInColumn = prev[columnId] ?? []; + if (idsInColumn.length >= MAX_TIMERS_PER_COLUMN) return prev; + const allIds = Object.values(prev).flat(); + const id = smallestFreeId(allIds); + setLabels((prevLabels) => ({ ...prevLabels, [id]: `Timer ${id}` })); + return { ...prev, [columnId]: [...idsInColumn, id] }; + }); + }; + + const removeTimer = (columnId: number, id: number) => { + setTimerIdsByColumn((prev) => { + const idsInColumn = prev[columnId] ?? []; + if (idsInColumn.length <= MIN_TIMERS_PER_COLUMN) return prev; + return { ...prev, [columnId]: idsInColumn.filter((t) => t !== id) }; + }); + setLabels((prev) => { const next = { ...prev }; delete next[id]; return next; @@ -78,38 +143,120 @@ const TimerPanel: React.FC = () => {

Multi-Timer

- {GROUPS.map(({ key, title, addLabel }) => { - const idsInGroup = timerIds.filter((id) => groups[id] === key); - return ( -
-
-

{title}

- -
- -
- {idsInGroup.map((id) => ( +
{ + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + > + ⠿ +
renameTimer(id, label)} - onRemove={idsInGroup.length > MIN_TIMERS_PER_GROUP ? () => removeTimer(id) : undefined} + label={column.name} + onLabelChange={(name) => renameColumn(column.id, name)} + onRemove={columns.length > MIN_COLUMNS ? () => removeColumn(column.id) : undefined} + variant="order" + /> +