Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ npm run dev
- 倒數結束:加提示音(並可開關)
- localStorage:記住上次模式與倒數秒數
- 視覺:波紋更慢、呼吸週期可調
- 統計:今日累積專注時間 / 完成次數
- 統計:今日累積專注時間 / 完成次數
173 changes: 101 additions & 72 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useCountdown, useStopwatch } from "./lib/hooks.js";
export default function App() {
const [mode, setMode] = useState("stopwatch"); // "stopwatch" | "countdown"
const sw = useStopwatch();
const cd = useCountdown(5 * 60 * 1000);
const cd = useCountdown(0);

const [laps, setLaps] = useState([]);

Expand All @@ -25,27 +25,36 @@ export default function App() {
const progress = clamp(cd.remainingMs / total, 0, 1);
return {
ringProgress: progress,
waterLevel: progress,
title: "倒數",
subtitle: `總長 ${formatHMS(cd.totalMs)}`,
waterLevel: 1 - progress,
waterVariant: "countdown",
title: `倒數時長 ${formatHMS(cd.totalMs).slice(0, 5)}`,
subtitle: "",
flashKey: null,
};
}

// Stopwatch: make it feel calm by looping visuals each minute
const loop = 60 * 1000;
const loop = 10 * 1000;
const phase = (sw.elapsedMs % loop) / loop;
return {
ringProgress: phase,
waterLevel: 0.15 + phase * 0.7, // keep some padding (never fully empty/full)
title: "碼表",
subtitle: sw.isRunning ? "流動中…" : "休息一下也很好",
waterLevel: 0.5,
waterVariant: "stopwatch",
title: "碼錶",
subtitle: sw.isRunning ? "流動中…" : "跑跑跑~向前跑~",
flashKey:
sw.isRunning && sw.elapsedMs >= 10000
? Math.floor(sw.elapsedMs / 10000)
: null,
};
}, [mode, sw.elapsedMs, sw.isRunning, cd.remainingMs, cd.totalMs]);

const primaryTime =
mode === "stopwatch"
? formatHMS(sw.elapsedMs, { showMs: true })
: formatHMS(cd.remainingMs);
: formatHMS(cd.remainingMs, { showMs: true });
const isCountdownFinalSeconds =
mode === "countdown" && cd.totalMs > 0 && cd.remainingMs <= 10000;

const onReset = () => {
if (mode === "stopwatch") {
Expand All @@ -62,15 +71,19 @@ export default function App() {
};

const onPickPreset = (ms) => {
cd.setTotalWhileStopped(ms);
cd.addTotalWhileStopped(ms);
};

const onClearPreset = () => {
cd.clearTotalWhileStopped();
};

return (
<div className="app">
<div className={`app mode-${mode}`}>
<header className="header">
<div className="brand">
<div className="brandTitle">Tide Timer</div>
<div className="brandTag">療癒型碼表 / 倒數</div>
<div className="brandTag">療癒型碼錶 / 倒數</div>
</div>
<ModeSwitch mode={mode} setMode={(m) => {
// stop running when switching modes (keeps mental model simple)
Expand All @@ -80,70 +93,86 @@ export default function App() {
}} />
</header>

<main className="main">
<section className="card breathe" aria-label="Timer">
<div className="vizWrap">
<Ring progress={viz.ringProgress} />
<WaterGauge level={viz.waterLevel} />
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay primary={primaryTime} secondary={viz.subtitle} />
</div>
</div>

{mode === "countdown" ? (
<div className="countdownTools">
<CountdownPresets disabled={cd.isRunning} onPick={onPickPreset} />
<div className="hint">
小提示:使用快速鍵,依點擊次數,初始時間可累加,或按 "清除" 重新設定。
<main className={`main mode-${mode}`}>
{mode === "stopwatch" ? (
<div className="stopwatchGrid">
<section className="card breathe timerCard" aria-label="Stopwatch">
<div className="vizWrap">
<Ring progress={viz.ringProgress} size={300} />
<WaterGauge level={viz.waterLevel} variant={viz.waterVariant} size={260} />
{mode === "stopwatch" && viz.flashKey !== null ? (
<div className="ringFlash" key={viz.flashKey} />
) : null}
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay primary={primaryTime} secondary={viz.subtitle} />
</div>
</div>
</div>
) : null}

<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={
mode === "stopwatch" ? (
<button className="btn" onClick={onLap} disabled={!sw.isRunning}>
Lap
</button>
) : null
}
/>

{mode === "stopwatch" ? <LapList laps={laps} /> : null}

{mode === "countdown" && cd.remainingMs === 0 ? (
<div className="done" role="status">
完成 ✨
</div>
) : null}
</section>

<section className="side">
<div className="panel">
<div className="panelTitle">你可以接著練的功能</div>
<ul className="panelList">
<li>倒數自訂:分鐘/秒輸入、滑桿、+10s/-10s</li>
<li>音效開關(完成提示音)</li>
<li>localStorage:記住上次模式與倒數秒數</li>
<li>更療癒:水波更慢、呼吸週期可調</li>
<li>統計:今日累積專注時間 / 完成次數</li>
</ul>

<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={
<button
className="btn btnLap"
onClick={onLap}
disabled={!sw.isRunning}
>
記錄
</button>
}
/>
</section>

<LapList laps={laps} />
</div>
) : (
<div className="stopwatchGrid">
<section className="card breathe timerCard countdownCard" aria-label="Countdown">
<div className={`vizWrap ${isCountdownFinalSeconds ? "countdownAlert" : ""}`}>
<Ring progress={viz.ringProgress} size={300} />
<WaterGauge level={viz.waterLevel} variant={viz.waterVariant} size={260} />
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay
primary={primaryTime}
secondary={viz.subtitle}
primaryClassName={isCountdownFinalSeconds ? "timePrimaryAlert" : ""}
/>
</div>
</div>

<div className="panel">
<div className="panelTitle">部署到 GitHub Pages</div>
<ol className="panelList">
<li>把整包檔案推到你的 GitHub repo</li>
<li>Repo Settings → Pages → Source 選 GitHub Actions</li>
<li>等待 Actions 跑完,就會自動發佈</li>
</ol>
<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={null}
/>

{mode === "countdown" && cd.remainingMs === 0 ? (
<div className="done" role="status">
完成 ✨
</div>
) : null}
</section>

<section className="card sideCard" aria-label="Countdown presets">
<div className="countdownTools">
<div className="hint countdownHint">
按多次可累加時間,或按「清除」重新設定。
</div>
<CountdownPresets
disabled={cd.isRunning}
onPick={onPickPreset}
onClear={onClearPreset}
/>
</div>
</section>
</div>
</section>
)}
</main>

<footer className="footer">
Expand Down
10 changes: 5 additions & 5 deletions src/components/Controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ export default function Controls({
<div className="controlsRow">
{extraLeft}
<button
className="btn btnPrimary"
className={`btn btnPrimary ${isRunning ? "btnPause" : "btnStart"}`}
onClick={isRunning ? onPause : onStart}
aria-label={isRunning ? "Pause" : "Start"}
aria-label={isRunning ? "暫停" : "開始"}
>
{isRunning ? "Pause" : "Start"}
{isRunning ? "暫停" : "開始"}
</button>
<button className="btn" onClick={onReset} aria-label="Reset">
Reset
<button className="btn btnReset" onClick={onReset} aria-label="重新計時">
重新計時
</button>
{extraRight}
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/components/CountdownPresets.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const PRESETS = [
{ label: "25 分", ms: 25 * 60 * 1000 },
];

export default function CountdownPresets({ disabled, onPick }) {
export default function CountdownPresets({ disabled, onPick, onClear }) {
return (
<div className="presets">
{PRESETS.map((p) => (
Expand All @@ -21,6 +21,14 @@ export default function CountdownPresets({ disabled, onPick }) {
{p.label}
</button>
))}
<button
className="chip chipClear"
onClick={onClear}
disabled={disabled}
aria-label="清除預設時間"
>
清除
</button>
</div>
);
}
29 changes: 17 additions & 12 deletions src/components/LapList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ import React from "react";
import { formatHMS } from "../lib/time.js";

export default function LapList({ laps }) {
if (!laps.length) return null;
return (
<div className="laps">
<div className="lapsTitle">Laps</div>
<ol className="lapsList">
{laps.map((ms, idx) => (
<li key={idx} className="lapItem">
<span className="lapIndex">{String(idx + 1).padStart(2, "0")}</span>
<span className="lapTime">{formatHMS(ms, { showMs: true })}</span>
</li>
))}
</ol>
</div>
<section className="card lapCard" aria-label="Laps">
<div className="laps">
<div className="lapsTitle">記錄</div>
{laps.length ? (
<ol className="lapsList">
{laps.map((ms, idx) => (
<li key={idx} className="lapItem">
<span className="lapIndex">{String(idx + 1).padStart(2, "0")}</span>
<span className="lapTime">{formatHMS(ms, { showMs: true })}</span>
</li>
))}
</ol>
) : (
<div className="lapsEmpty">尚無紀錄</div>
)}
</div>
</section>
);
}
2 changes: 1 addition & 1 deletion src/components/ModeSwitch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function ModeSwitch({ mode, setMode }) {
role="tab"
aria-selected={mode === "stopwatch"}
>
碼表
碼錶
</button>
<button
className={`pill ${mode === "countdown" ? "active" : ""}`}
Expand Down
16 changes: 13 additions & 3 deletions src/components/TimeDisplay.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import React from "react";

export default function TimeDisplay({ primary, secondary }) {
export default function TimeDisplay({
primary,
secondary,
className = "",
primaryClassName = "",
}) {
const [main, ms] = String(primary || "").split(".");

return (
<div className="timeDisplay">
<div className="timePrimary">{primary}</div>
<div className={`timeDisplay ${className}`}>
<div className={`timePrimary ${primaryClassName}`}>
<span className="timeMain">{main}</span>
{ms ? <span className="timeMs">.{ms}</span> : null}
</div>
{secondary ? <div className="timeSecondary">{secondary}</div> : null}
</div>
);
Expand Down
33 changes: 26 additions & 7 deletions src/components/WaterGauge.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,38 @@ import React from "react";
import { clamp } from "../lib/time.js";

/**
* WaterGauge: soothing water level + slow wave drift.
* level: 0..1 (1 = full)
* WaterGauge: soothing water level + slow wave drift (countdown),
* or static dark plate for stopwatch.
* level: 0..1 (1 = full) for animated mode
*/
export default function WaterGauge({ level = 0.5 }) {
export default function WaterGauge({ level = 0.5, variant = "countdown", size = 220 }) {
if (variant === "stopwatch") {
return (
<div
className="waterGauge waterGaugeStatic"
aria-hidden="true"
style={{ width: size, height: size }}
>
<div className="waterPlate" />
</div>
);
}

const l = clamp(level, 0, 1);
const y = `${(1 - l) * 100}%`;

return (
<div className="waterGauge" aria-hidden="true">
<div
className={`waterGauge ${variant === "countdown" ? "waterGaugeCountdown" : ""}`}
aria-hidden="true"
style={{ width: size, height: size }}
>
<div className="waterSurface" style={{ top: y }} />
<div className="waterFill" style={{ transform: `translateY(${(1 - l) * 100}%)` }}>
<div className="wave wave1" />
<div className="wave wave2" />
<div className="waterFill" style={{ "--water-shift": `${(1 - l) * 100}%` }}>
<div className="waterFillInner">
<div className="wave wave1" />
<div className="wave wave2" />
</div>
</div>
</div>
);
Expand Down
Loading