Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions src/components/panels/TimerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,20 @@ function parseMmSs(text: string): number | null {

interface TimerCardProps {
label: string;
onLabelChange?: (label: string) => void;
onRemove?: () => void;
}

const TimerCard: React.FC<TimerCardProps> = ({ label, onRemove }) => {
const { status, remainingMs, start, reset } = useCountdown();
const TimerCard: React.FC<TimerCardProps> = ({ 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
Expand Down Expand Up @@ -68,7 +71,13 @@ const TimerCard: React.FC<TimerCardProps> = ({ label, onRemove }) => {
return (
<div className={`timer-card${isFinished ? ' finished' : ''}`}>
<div className="header">
<h4>{label}</h4>
<input
type="text"
className="label-input"
value={label}
aria-label="Timer name"
onChange={(e) => onLabelChange?.(e.target.value)}
/>
{onRemove && (
<button
type="button"
Expand Down Expand Up @@ -98,11 +107,22 @@ const TimerCard: React.FC<TimerCardProps> = ({ label, onRemove }) => {
/>

<div className="buttons">
{isIdle ? (
{isIdle && (
<button type="button" onClick={handleGo}>
Go
</button>
) : (
)}
{isRunning && (
<button type="button" className="pause" onClick={pause}>
Pause
</button>
)}
{isPaused && (
<button type="button" onClick={resume}>
Resume
</button>
)}
{!isIdle && (
<button type="button" className="reset" onClick={handleReset}>
Reset
</button>
Expand Down Expand Up @@ -142,11 +162,28 @@ const TimerCard: React.FC<TimerCardProps> = ({ 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 {
Expand Down Expand Up @@ -229,6 +266,14 @@ const TimerCard: React.FC<TimerCardProps> = ({ label, onRemove }) => {
button.reset:hover {
background: #666;
}

button.pause {
background: #d9a441;
}

button.pause:hover {
background: #c4923a;
}
`}</style>
</div>
);
Expand Down
134 changes: 106 additions & 28 deletions src/components/panels/TimerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 8;
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);
Expand All @@ -14,46 +22,91 @@ 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];

const TimerPanel: React.FC = () => {
const [timerIds, setTimerIds] = useState<number[]>(
Array.from({ length: DEFAULT_TIMER_COUNT }, (_, i) => i + 1),
const [timerIds, setTimerIds] = useState<number[]>(initialIds);
const [groups, setGroups] = useState<Record<number, TimerGroup>>(() => ({
...Object.fromEntries(initialOrderIds.map((id) => [id, 'orders' as TimerGroup])),
...Object.fromEntries(initialOtherIds.map((id) => [id, 'other' as TimerGroup])),
}));
const [labels, setLabels] = useState<Record<number, string>>(() =>
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) => {
setLabels((prev) => ({ ...prev, [id]: label }));
};

return (
<div className="panel">
<div className="panel-header">
<h3>Multi-Timer</h3>
<button
type="button"
className="add-btn"
onClick={addTimer}
disabled={timerIds.length >= MAX_TIMERS}
>
+ Add Timer
</button>
</div>

<div className="timer-grid">
{timerIds.map((id) => (
<TimerCard
key={id}
label={`Timer ${id}`}
onRemove={timerIds.length > MIN_TIMERS ? () => removeTimer(id) : undefined}
/>
))}
</div>
{GROUPS.map(({ key, title, addLabel }) => {
const idsInGroup = timerIds.filter((id) => groups[id] === key);
return (
<div className="timer-section" key={key}>
<div className="section-header">
<h4>{title}</h4>
<button
type="button"
className="add-btn"
onClick={() => addTimer(key)}
disabled={idsInGroup.length >= MAX_TIMERS_PER_GROUP}
>
{addLabel}
</button>
</div>

<div className="timer-grid">
{idsInGroup.map((id) => (
<TimerCard
key={id}
label={labels[id] ?? `Timer ${id}`}
onLabelChange={(label) => renameTimer(id, label)}
onRemove={idsInGroup.length > MIN_TIMERS_PER_GROUP ? () => removeTimer(id) : undefined}
/>
))}
</div>
</div>
);
})}

<style jsx>{`
.panel {
Expand All @@ -67,9 +120,6 @@ const TimerPanel: React.FC = () => {
}

.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #444;
padding-bottom: 0.6rem;
margin-bottom: 1rem;
Expand All @@ -81,6 +131,34 @@ const TimerPanel: React.FC = () => {
letter-spacing: 0.5px;
}

.timer-section {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}

.timer-section + .timer-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #333;
}

.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}

h4 {
margin: 0;
font-size: 0.95rem;
letter-spacing: 0.4px;
color: #bbb;
text-transform: uppercase;
}

.add-btn {
padding: 0.4rem 0.75rem;
border: none;
Expand Down
22 changes: 20 additions & 2 deletions src/hooks/useCountdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import { useEffect, useRef, useState } from 'react';

export type CountdownStatus = 'idle' | 'running' | 'finished';
export type CountdownStatus = 'idle' | 'running' | 'paused' | 'finished';

const TICK_INTERVAL_MS = 200;

export interface UseCountdownResult {
status: CountdownStatus;
remainingMs: number;
start: (durationMs: number) => void;
pause: () => void;
resume: () => void;
reset: () => void;
}

Expand Down Expand Up @@ -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 };
}
Loading