Skip to content

Commit 8a6183b

Browse files
committed
Add pomodoro timer
1 parent a9e5e3e commit 8a6183b

1 file changed

Lines changed: 272 additions & 0 deletions

File tree

pomodoro-timer/index.html

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
<!DOCTYPE html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6+
<title>🍅 番茄工作计时器</title>
7+
<style>
8+
*{margin:0;padding:0;box-sizing:border-box}
9+
:root{
10+
--focus-from:#ff9a9e;--focus-to:#fad0c4;
11+
--break-from:#a1c4fd;--break-to:#c2e9fb;
12+
--bg-from:var(--focus-from);--bg-to:var(--focus-to);
13+
--card:rgba(255,255,255,0.55);--card-border:rgba(255,255,255,0.7);
14+
--text:#2d3436;--text-light:#636e72;--accent:#e17055;--accent-light:#fab1a0;
15+
--green:#00b894;--shadow:0 8px 32px rgba(0,0,0,0.08);
16+
}
17+
body{
18+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
19+
min-height:100vh;background:linear-gradient(135deg,var(--bg-from),var(--bg-to));
20+
color:var(--text);transition:background .6s ease;display:flex;flex-direction:column;align-items:center;
21+
padding:20px 16px 40px;
22+
}
23+
h1{font-size:1.4rem;font-weight:700;margin-bottom:16px;letter-spacing:-.5px;text-align:center}
24+
.mode-tabs{display:flex;gap:6px;margin-bottom:20px;background:var(--card);border-radius:14px;padding:4px;backdrop-filter:blur(12px);border:1px solid var(--card-border)}
25+
.mode-tabs button{
26+
padding:8px 20px;border:none;border-radius:11px;font-size:.9rem;font-weight:600;
27+
cursor:pointer;background:transparent;color:var(--text-light);transition:all .25s;
28+
}
29+
.mode-tabs button.active{background:white;color:var(--accent);box-shadow:var(--shadow)}
30+
.timer-ring{position:relative;width:240px;height:240px;margin-bottom:24px}
31+
.timer-ring svg{transform:rotate(-90deg);width:100%;height:100%}
32+
.timer-ring .bg-circle{fill:none;stroke:rgba(255,255,255,0.5);stroke-width:8}
33+
.timer-ring .fg-circle{fill:none;stroke:white;stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset .5s linear;filter:drop-shadow(0 0 8px rgba(255,255,255,0.4))}
34+
.timer-display{
35+
position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
36+
}
37+
.timer-display .time{font-size:3.2rem;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-1px}
38+
.timer-display .label{font-size:.85rem;color:var(--text-light);margin-top:2px}
39+
.controls{display:flex;gap:12px;margin-bottom:24px}
40+
.controls button{
41+
width:52px;height:52px;border:none;border-radius:50%;font-size:1.3rem;cursor:pointer;
42+
background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border);
43+
color:var(--text);box-shadow:var(--shadow);transition:all .2s;display:flex;align-items:center;justify-content:center;
44+
}
45+
.controls button:hover{transform:scale(1.08);background:rgba(255,255,255,0.8)}
46+
.controls button:active{transform:scale(.95)}
47+
.controls button.primary{background:white;color:var(--accent);width:60px;height:60px;font-size:1.5rem}
48+
.task-input-area{width:100%;max-width:400px;margin-bottom:20px}
49+
.task-input-area input{
50+
width:100%;padding:12px 16px;border:none;border-radius:12px;font-size:.95rem;
51+
background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border);
52+
color:var(--text);outline:none;transition:border .2s;
53+
}
54+
.task-input-area input:focus{border-color:var(--accent)}
55+
.task-input-area input::placeholder{color:var(--text-light)}
56+
.stats{width:100%;max-width:400px;margin-bottom:24px}
57+
.stats-row{display:flex;gap:10px}
58+
.stat-card{
59+
flex:1;padding:14px 12px;background:var(--card);backdrop-filter:blur(12px);
60+
border:1px solid var(--card-border);border-radius:14px;text-align:center;box-shadow:var(--shadow);
61+
}
62+
.stat-card .val{font-size:1.8rem;font-weight:800;color:var(--accent)}
63+
.stat-card .lbl{font-size:.75rem;color:var(--text-light);margin-top:2px}
64+
.task-list{width:100%;max-width:400px}
65+
.task-list h2{font-size:1rem;font-weight:700;margin-bottom:10px}
66+
.task-item{
67+
display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:8px;
68+
background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border);
69+
border-radius:12px;box-shadow:var(--shadow);transition:all .2s;
70+
}
71+
.task-item .dot{width:8px;height:8px;border-radius:50%;background:var(--accent-light);flex-shrink:0}
72+
.task-item .dot.done{background:var(--green)}
73+
.task-item .name{flex:1;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
74+
.task-item .count{font-size:.8rem;color:var(--text-light);font-weight:600;white-space:nowrap}
75+
.task-item .del{background:none;border:none;font-size:1.1rem;cursor:pointer;color:var(--text-light);padding:0 2px}
76+
.task-item .del:hover{color:#d63031}
77+
.empty{color:var(--text-light);font-size:.85rem;text-align:center;padding:16px 0}
78+
@media(max-width:420px){
79+
.timer-ring{width:200px;height:200px}
80+
.timer-display .time{font-size:2.6rem}
81+
.stat-card .val{font-size:1.4rem}
82+
h1{font-size:1.2rem}
83+
}
84+
</style>
85+
</head>
86+
<body>
87+
<h1>🍅 番茄工作计时器</h1>
88+
<div class="mode-tabs">
89+
<button class="active" onclick="setMode('focus')">专注 25:00</button>
90+
<button onclick="setMode('break')">休息 05:00</button>
91+
</div>
92+
<div class="timer-ring">
93+
<svg viewBox="0 0 240 240">
94+
<circle class="bg-circle" cx="120" cy="120" r="108"/>
95+
<circle class="fg-circle" id="fgCircle" cx="120" cy="120" r="108"
96+
stroke-dasharray="678.58" stroke-dashoffset="0"/>
97+
</svg>
98+
<div class="timer-display">
99+
<div class="time" id="timerText">25:00</div>
100+
<div class="label" id="modeLabel">专注时间</div>
101+
</div>
102+
</div>
103+
<div class="controls">
104+
<button onclick="resetTimer()" title="重置"></button>
105+
<button class="primary" id="playBtn" onclick="toggleTimer()" title="开始/暂停"></button>
106+
<button onclick="skipTimer()" title="跳过"></button>
107+
</div>
108+
<div class="task-input-area">
109+
<input id="taskInput" type="text" placeholder="输入当前任务,回车添加…" maxlength="40"
110+
onkeydown="if(event.key==='Enter')addTask()">
111+
</div>
112+
<div class="stats">
113+
<div class="stats-row">
114+
<div class="stat-card"><div class="val" id="statPomos">0</div><div class="lbl">今日番茄</div></div>
115+
<div class="stat-card"><div class="val" id="statMins">0</div><div class="lbl">专注分钟</div></div>
116+
<div class="stat-card"><div class="val" id="statTasks">0</div><div class="lbl">完成任务</div></div>
117+
</div>
118+
</div>
119+
<div class="task-list">
120+
<h2>📋 今日任务</h2>
121+
<div id="taskContainer"></div>
122+
</div>
123+
124+
<script>
125+
const FOCUS_SEC=25*60, BREAK_SEC=5*60, CIRCUM=2*Math.PI*108;
126+
let mode='focus', remaining=FOCUS_SEC, total=FOCUS_SEC, running=false, interval=null;
127+
let tasks=[], currentTaskIdx=-1;
128+
129+
// --- Audio ---
130+
function playSound(type){
131+
const ac=new (window.AudioContext||window.webkitAudioContext)();
132+
if(type==='complete'){
133+
[523.25,659.25,783.99].forEach((f,i)=>{
134+
const o=ac.createOscillator(),g=ac.createGain();
135+
o.type='sine';o.frequency.value=f;
136+
g.gain.setValueAtTime(.3,ac.currentTime+i*.18);
137+
g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+i*.18+.5);
138+
o.connect(g);g.connect(ac.destination);
139+
o.start(ac.currentTime+i*.18);o.stop(ac.currentTime+i*.18+.5);
140+
});
141+
} else {
142+
const o=ac.createOscillator(),g=ac.createGain();
143+
o.type='sine';o.frequency.value=880;
144+
g.gain.setValueAtTime(.15,ac.currentTime);
145+
g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+.15);
146+
o.connect(g);g.connect(ac.destination);
147+
o.start();o.stop(ac.currentTime+.15);
148+
}
149+
}
150+
151+
// --- Persistence ---
152+
function todayKey(){return new Date().toISOString().slice(0,10)}
153+
function loadData(){
154+
try{
155+
const d=JSON.parse(localStorage.getItem('pomo_'+todayKey()));
156+
if(d){tasks=d.tasks||[];return d}
157+
}catch(e){}
158+
tasks=[];return null;
159+
}
160+
function saveData(){
161+
const d={tasks,ts:Date.now()};
162+
localStorage.setItem('pomo_'+todayKey(),JSON.stringify(d));
163+
updateStats();
164+
}
165+
166+
// --- Timer ---
167+
function fmt(s){const m=Math.floor(s/60),sec=s%60;return String(m).padStart(2,'0')+':'+String(sec).padStart(2,'0')}
168+
function renderTimer(){
169+
document.getElementById('timerText').textContent=fmt(remaining);
170+
const pct=1-remaining/total;
171+
document.getElementById('fgCircle').style.strokeDashoffset=CIRCUM*pct;
172+
}
173+
function updateTheme(){
174+
const r=document.documentElement.style;
175+
if(mode==='focus'){r.setProperty('--bg-from','#ff9a9e');r.setProperty('--bg-to','#fad0c4')}
176+
else{r.setProperty('--bg-from','#a1c4fd');r.setProperty('--bg-to','#c2e9fb')}
177+
}
178+
function setMode(m){
179+
mode=m;total=m==='focus'?FOCUS_SEC:BREAK_SEC;remaining=total;
180+
document.querySelectorAll('.mode-tabs button').forEach((b,i)=>{
181+
b.classList.toggle('active',(i===0&&m==='focus')||(i===1&&m==='break'));
182+
});
183+
document.getElementById('modeLabel').textContent=m==='focus'?'专注时间':'休息时间';
184+
updateTheme();renderTimer();if(running)stopInterval();
185+
document.getElementById('playBtn').textContent='▶';running=false;
186+
}
187+
function stopInterval(){if(interval){clearInterval(interval);interval=null}}
188+
function tick(){
189+
remaining--;renderTimer();
190+
if(remaining<=0){
191+
stopInterval();running=false;document.getElementById('playBtn').textContent='▶';
192+
playSound('complete');
193+
if(mode==='focus'){
194+
if(currentTaskIdx>=0&&tasks[currentTaskIdx])tasks[currentTaskIdx].pomos=(tasks[currentTaskIdx].pomos||0)+1;
195+
saveData();renderTasks();
196+
if(Notification.permission==='granted')new Notification('🍅 番茄完成!',{body:'休息一下吧'});
197+
setTimeout(()=>setMode('break'),800);
198+
}else{
199+
if(Notification.permission==='granted')new Notification('☕ 休息结束!',{body:'继续专注吧'});
200+
setTimeout(()=>setMode('focus'),800);
201+
}
202+
}
203+
}
204+
function toggleTimer(){
205+
if(running){
206+
stopInterval();running=false;document.getElementById('playBtn').textContent='▶';playSound('click');
207+
}else{
208+
if(remaining<=0){remaining=total;renderTimer()}
209+
running=true;document.getElementById('playBtn').textContent='⏸';
210+
interval=setInterval(tick,1000);playSound('click');
211+
}
212+
}
213+
function resetTimer(){
214+
stopInterval();running=false;remaining=total;renderTimer();
215+
document.getElementById('playBtn').textContent='▶';playSound('click');
216+
}
217+
function skipTimer(){
218+
remaining=0;tick();
219+
}
220+
221+
// --- Tasks ---
222+
function addTask(){
223+
const inp=document.getElementById('taskInput'),name=inp.value.trim();
224+
if(!name)return;
225+
tasks.push({name,pomos:0,done:false});
226+
inp.value='';saveData();renderTasks();
227+
playSound('click');
228+
}
229+
function toggleTask(i){
230+
tasks[i].done=!tasks[i].done;
231+
if(tasks[i].done&&currentTaskIdx===i)currentTaskIdx=-1;
232+
saveData();renderTasks();playSound('click');
233+
}
234+
function deleteTask(i){
235+
tasks.splice(i,1);
236+
if(currentTaskIdx===i)currentTaskIdx=-1;
237+
else if(currentTaskIdx>i)currentTaskIdx--;
238+
saveData();renderTasks();
239+
}
240+
function selectTask(i){
241+
currentTaskIdx=i;renderTasks();
242+
document.querySelectorAll('.task-item').forEach((el,idx)=>el.style.background=idx===i?'rgba(255,255,255,0.75)':'');
243+
}
244+
function renderTasks(){
245+
const c=document.getElementById('taskContainer');
246+
if(!tasks.length){c.innerHTML='<div class="empty">暂无任务,输入任务开始专注 🎯</div>';return}
247+
c.innerHTML=tasks.map((t,i)=>`
248+
<div class="task-item" onclick="selectTask(${i})" style="${i===currentTaskIdx?'background:rgba(255,255,255,0.75)':''}">
249+
<div class="dot${t.done?' done':''}" onclick="event.stopPropagation();toggleTask(${i})"></div>
250+
<div class="name" style="${t.done?'text-decoration:line-through;opacity:.5':''}">${esc(t.name)}</div>
251+
<div class="count">🍅${t.pomos||0}</div>
252+
<button class="del" onclick="event.stopPropagation();deleteTask(${i})">×</button>
253+
</div>
254+
`).join('');
255+
}
256+
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
257+
function updateStats(){
258+
const totalPomos=tasks.reduce((s,t)=>s+(t.pomos||0),0);
259+
const doneTasks=tasks.filter(t=>t.done).length;
260+
document.getElementById('statPomos').textContent=totalPomos;
261+
document.getElementById('statMins').textContent=totalPomos*25;
262+
document.getElementById('statTasks').textContent=doneTasks;
263+
}
264+
265+
// --- Init ---
266+
(function init(){
267+
loadData();renderTasks();updateStats();renderTimer();
268+
if('Notification' in window && Notification.permission==='default')Notification.requestPermission();
269+
})();
270+
</script>
271+
</body>
272+
</html>

0 commit comments

Comments
 (0)