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}
-
+
+
{
+ if (isDragTarget) e.preventDefault();
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ handleColumnDrop(index);
+ }}
+ >
+
+
+
+ {idsInColumn.map((id) => (
+ renameTimer(id, label)}
+ onRemove={idsInColumn.length > MIN_TIMERS_PER_COLUMN ? () => removeTimer(column.id, id) : undefined}
+ variant="ingredient"
+ />
+ ))}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
diff --git a/src/hooks/useStopwatch.ts b/src/hooks/useStopwatch.ts
new file mode 100644
index 0000000..eefaa7d
--- /dev/null
+++ b/src/hooks/useStopwatch.ts
@@ -0,0 +1,59 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+
+export type StopwatchStatus = 'idle' | 'running' | 'paused';
+
+const TICK_INTERVAL_MS = 200;
+
+export interface UseStopwatchResult {
+ status: StopwatchStatus;
+ elapsedMs: number;
+ start: () => void;
+ pause: () => void;
+ reset: () => void;
+}
+
+export function useStopwatch(): UseStopwatchResult {
+ const [status, setStatus] = useState('idle');
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const startTimeRef = useRef(null);
+ const baseElapsedRef = useRef(0);
+
+ useEffect(() => {
+ if (status !== 'running') return;
+
+ const tick = () => {
+ const now = Date.now();
+ setElapsedMs(baseElapsedRef.current + (now - (startTimeRef.current ?? now)));
+ };
+
+ tick();
+ const intervalId = window.setInterval(tick, TICK_INTERVAL_MS);
+
+ return () => window.clearInterval(intervalId);
+ }, [status]);
+
+ const start = () => {
+ startTimeRef.current = Date.now();
+ setStatus('running');
+ };
+
+ const pause = () => {
+ setStatus((prev) => {
+ if (prev !== 'running') return prev;
+ baseElapsedRef.current += Date.now() - (startTimeRef.current ?? Date.now());
+ startTimeRef.current = null;
+ return 'paused';
+ });
+ };
+
+ const reset = () => {
+ startTimeRef.current = null;
+ baseElapsedRef.current = 0;
+ setElapsedMs(0);
+ setStatus('idle');
+ };
+
+ return { status, elapsedMs, start, pause, reset };
+}