diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8102f7a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv +instance/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +.DS_Store +*.swp +*.swo +*~ diff --git a/main.py b/main.py index e69de29b..db5a71ad 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,11 @@ +from flask import Flask, render_template + +app = Flask(__name__) + +@app.route("/") +def index(): + return render_template("index.html") + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5000) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..047e9501 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==3.0.0 diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 00000000..8b0f3615 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,217 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; +} + +.container { + width: 100%; + max-width: 450px; +} + +.timer-card { + background: #f8f9fa; + border-radius: 20px; + padding: 30px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.title { + font-size: 20px; + font-weight: 600; + color: #2d3748; +} + +.window-controls { + display: flex; + gap: 10px; +} + +.control { + color: #a0aec0; + font-size: 18px; + cursor: pointer; + user-select: none; +} + +.control:hover { + color: #4a5568; +} + +.timer-section { + text-align: center; + margin-bottom: 30px; +} + +.status-text { + font-size: 16px; + color: #718096; + margin-bottom: 20px; +} + +.timer-display { + position: relative; + width: 300px; + height: 300px; + margin: 0 auto 30px; +} + +.progress-ring { + transform: rotate(-90deg); +} + +.progress-ring-bg { + fill: none; + stroke: #e2e8f0; + stroke-width: 20; +} + +.progress-ring-circle { + fill: none; + stroke: #667eea; + stroke-width: 20; + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease; +} + +.time-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 56px; + font-weight: 700; + color: #2d3748; +} + +.controls { + display: flex; + gap: 15px; + justify-content: center; +} + +.btn { + padding: 12px 32px; + border: none; + border-radius: 25px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + background: #a0aec0; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: white; + color: #667eea; + border: 2px solid #667eea; +} + +.btn-secondary:hover { + background: #f7fafc; +} + +.progress-section { + background: #edf2f7; + border-radius: 15px; + padding: 20px; +} + +.progress-title { + font-size: 16px; + font-weight: 600; + color: #2d3748; + margin-bottom: 15px; +} + +.progress-stats { + display: flex; + justify-content: space-around; + gap: 20px; +} + +.stat { + text-align: center; +} + +.stat-value { + font-size: 32px; + font-weight: 700; + color: #667eea; + margin-bottom: 5px; +} + +.stat-label { + font-size: 14px; + color: #718096; +} + +/* State-specific styling */ +.timer-card.working .progress-ring-circle { + stroke: #667eea; +} + +.timer-card.working .stat-value { + color: #667eea; +} + +.timer-card.break .progress-ring-circle { + stroke: #48bb78; +} + +.timer-card.break .stat-value { + color: #48bb78; +} + +.timer-card.break .status-text { + color: #48bb78; +} + +.timer-card.long-break .progress-ring-circle { + stroke: #ed8936; +} + +.timer-card.long-break .stat-value { + color: #ed8936; +} + +.timer-card.long-break .status-text { + color: #ed8936; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 00000000..4b13e937 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,185 @@ +// Timer configuration +const CONFIG = { + WORK_TIME: 25 * 60, // 25 minutes in seconds + BREAK_TIME: 5 * 60, // 5 minutes in seconds + LONG_BREAK_TIME: 15 * 60, // 15 minutes in seconds + SESSIONS_BEFORE_LONG_BREAK: 4 +}; + +// Timer states +const STATE = { + IDLE: 'idle', + RUNNING: 'running', + PAUSED: 'paused' +}; + +const MODE = { + WORK: 'work', + BREAK: 'break', + LONG_BREAK: 'long-break' +}; + +// Timer class +class PomodoroTimer { + constructor() { + this.timeLeft = CONFIG.WORK_TIME; + this.totalTime = CONFIG.WORK_TIME; + this.state = STATE.IDLE; + this.mode = MODE.WORK; + this.completedSessions = 0; + this.totalFocusTime = 0; // in seconds + this.timerInterval = null; + + this.initElements(); + this.initProgressRing(); + this.updateDisplay(); + this.updateProgress(); + } + + initElements() { + this.timeText = document.getElementById('timeText'); + this.statusText = document.getElementById('statusText'); + this.startBtn = document.getElementById('startBtn'); + this.resetBtn = document.getElementById('resetBtn'); + this.completedCount = document.getElementById('completedCount'); + this.focusTime = document.getElementById('focusTime'); + this.progressCircle = document.getElementById('progressCircle'); + this.timerCard = document.querySelector('.timer-card'); + + this.startBtn.addEventListener('click', () => this.toggleTimer()); + this.resetBtn.addEventListener('click', () => this.reset()); + } + + initProgressRing() { + const radius = 130; + const circumference = 2 * Math.PI * radius; + this.progressCircle.style.strokeDasharray = circumference; + this.progressCircle.style.strokeDashoffset = 0; + this.circumference = circumference; + } + + toggleTimer() { + if (this.state === STATE.RUNNING) { + this.pause(); + } else { + this.start(); + } + } + + start() { + this.state = STATE.RUNNING; + this.startBtn.textContent = '一時停止'; + + this.timerInterval = setInterval(() => { + this.tick(); + }, 1000); + } + + pause() { + this.state = STATE.PAUSED; + this.startBtn.textContent = '再開'; + clearInterval(this.timerInterval); + } + + tick() { + if (this.timeLeft > 0) { + this.timeLeft--; + this.updateDisplay(); + } else { + this.complete(); + } + } + + complete() { + clearInterval(this.timerInterval); + + // Update stats based on mode + if (this.mode === MODE.WORK) { + this.completedSessions++; + this.totalFocusTime += CONFIG.WORK_TIME; + this.updateProgress(); + } + + // Transition to next mode + this.transitionToNextMode(); + } + + transitionToNextMode() { + if (this.mode === MODE.WORK) { + // After work, go to break + if (this.completedSessions % CONFIG.SESSIONS_BEFORE_LONG_BREAK === 0) { + this.mode = MODE.LONG_BREAK; + this.timeLeft = CONFIG.LONG_BREAK_TIME; + this.totalTime = CONFIG.LONG_BREAK_TIME; + this.updateStatusText('長休憩'); + this.timerCard.classList.remove('working', 'break'); + this.timerCard.classList.add('long-break'); + } else { + this.mode = MODE.BREAK; + this.timeLeft = CONFIG.BREAK_TIME; + this.totalTime = CONFIG.BREAK_TIME; + this.updateStatusText('休憩中'); + this.timerCard.classList.remove('working', 'long-break'); + this.timerCard.classList.add('break'); + } + } else { + // After break, go back to work + this.mode = MODE.WORK; + this.timeLeft = CONFIG.WORK_TIME; + this.totalTime = CONFIG.WORK_TIME; + this.updateStatusText('作業中'); + this.timerCard.classList.remove('break', 'long-break'); + this.timerCard.classList.add('working'); + } + + this.state = STATE.IDLE; + this.startBtn.textContent = '開始'; + this.updateDisplay(); + } + + reset() { + clearInterval(this.timerInterval); + this.state = STATE.IDLE; + this.mode = MODE.WORK; + this.timeLeft = CONFIG.WORK_TIME; + this.totalTime = CONFIG.WORK_TIME; + this.startBtn.textContent = '開始'; + this.updateStatusText('作業中'); + this.timerCard.classList.remove('break', 'long-break'); + this.timerCard.classList.add('working'); + this.updateDisplay(); + } + + updateDisplay() { + const minutes = Math.floor(this.timeLeft / 60); + const seconds = this.timeLeft % 60; + this.timeText.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + // Update progress ring + const progress = (this.totalTime - this.timeLeft) / this.totalTime; + const offset = this.circumference * (1 - progress); + this.progressCircle.style.strokeDashoffset = offset; + } + + updateStatusText(text) { + this.statusText.textContent = text; + } + + updateProgress() { + this.completedCount.textContent = this.completedSessions; + + const hours = Math.floor(this.totalFocusTime / 3600); + const minutes = Math.floor((this.totalFocusTime % 3600) / 60); + + if (hours > 0) { + this.focusTime.textContent = `${hours}時間${minutes}分`; + } else { + this.focusTime.textContent = `${minutes}分`; + } + } +} + +// Initialize timer when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + const timer = new PomodoroTimer(); +}); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..991d9aba --- /dev/null +++ b/templates/index.html @@ -0,0 +1,56 @@ + + +
+ + +