diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..bd2f48ab Binary files /dev/null and b/.coverage differ diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..113c1739 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "github-mcp-server": { + "url": "https://api.githubcopilot.com/mcp/", + "type": "http" + } + }, + "inputs": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b242572e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 03c03563..85ab4046 100644 --- a/README.md +++ b/README.md @@ -1 +1,97 @@ +# 🍅 ポモドーロタイマーアプリケーション + +高機能なポモドーロテクニックタイマーWebアプリケーションです。 + +## 🌟 主な機能 + +- **ポモドーロタイマー**: 25分作業 + 5分休憩の集中管理 +- **カスタマイズ可能**: 作業時間・休憩時間の調整 +- **統計・履歴**: 完了したポモドーロの記録と分析 +- **通知機能**: タイマー完了時の音声・ブラウザ通知 +- **レスポンシブデザイン**: PC・モバイル対応 + +## 🚀 クイックスタート + +### サーバー起動(簡単な方法) +```bash +./server_manager.sh start +``` + +### サーバー管理コマンド +```bash +# サーバー状態確認 +./server_manager.sh status + +# サーバー停止 +./server_manager.sh stop + +# サーバー再起動 +./server_manager.sh restart + +# ログ確認 +./server_manager.sh logs +``` + +### 手動でサーバー起動する場合 +```bash +# インタラクティブモード(ログがコンソールに表示) +./start_server.sh + +# または直接実行 +.venv/bin/python app.py +``` + +## 🌐 アクセスURL + +サーバー起動後、以下のURLでアクセスできます: +- **ローカル**: http://127.0.0.1:8000 +- **ネットワーク**: http://10.0.3.246:8000 + +## 🏗️ 技術仕様 + +- **フレームワーク**: Flask (Python) +- **フロントエンド**: HTML5, CSS3, JavaScript (Vanilla) +- **データ保存**: JSON ファイル + LocalStorage +- **テスト**: pytest (25個の包括的テスト) +- **UI**: レスポンシブデザイン + +## 🧪 テスト実行 + +```bash +# 全テスト実行 +.venv/bin/python -m pytest tests/ -v + +# カバレッジ付きテスト実行 +.venv/bin/python -m pytest tests/ --cov=. --cov-report=html +``` + +## 📁 プロジェクト構造 + +``` +├── app.py # メインFlaskアプリケーション +├── server_manager.sh # サーバー管理スクリプト +├── start_server.sh # サーバー起動スクリプト +├── services/ +│ └── timer_service.py # ビジネスロジック +├── templates/ +│ └── index.html # メインHTML +├── static/ +│ ├── css/style.css # スタイルシート +│ └── js/timer.js # JavaScript +└── tests/ # テストファイル + ├── test_app.py + ├── test_static_files.py + └── test_timer_service.py +``` + +## 🎯 使用方法 + +1. **サーバー起動**: `./server_manager.sh start` +2. **ブラウザでアクセス**: http://127.0.0.1:8000 +3. **▶ スタートボタン**でタイマー開始 +4. **⚙️ 設定ボタン**で時間をカスタマイズ +5. **📊 履歴・統計ボタン**で進捗確認 + +--- + ワークショップの手順:https://moulongzhang.github.io/2025-Github-Copilot-Workshop/github-copilot-workshop/#0 diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc new file mode 100644 index 00000000..03984424 Binary files /dev/null and b/__pycache__/app.cpython-311.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 00000000..64d966d5 --- /dev/null +++ b/app.py @@ -0,0 +1,77 @@ +from flask import Flask, render_template, request, jsonify +from services.timer_service import HistoryService, TimerService + +app = Flask(__name__) + +# サービスインスタンス +history_service = HistoryService() +timer_service = TimerService() + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/history', methods=['GET']) +def get_history(): + """履歴データを取得するAPI""" + history = history_service.load_history() + return jsonify(history) + +@app.route('/api/history', methods=['POST']) +def add_history(): + """履歴データを追加するAPI""" + try: + data = request.json + + # 必要なデータの検証 + if not data or 'session_type' not in data or 'duration' not in data: + return jsonify({'error': '必要なデータが不足しています'}), 400 + + # 履歴エントリを追加 + history_entry = history_service.add_history_entry( + session_type=data['session_type'], + duration=data['duration'], + pomodoro_count=data.get('pomodoro_count', 0) + ) + + if history_entry: + return jsonify({'message': '履歴が保存されました', 'entry': history_entry}), 201 + else: + return jsonify({'error': '履歴の保存に失敗しました'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/stats', methods=['GET']) +def get_stats(): + """統計データを取得するAPI""" + try: + stats = history_service.get_statistics() + return jsonify(stats) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/validate-settings', methods=['POST']) +def validate_settings(): + """タイマー設定の検証API""" + try: + data = request.json + work_minutes = data.get('work_minutes', 25) + short_break_minutes = data.get('short_break_minutes', 5) + long_break_minutes = data.get('long_break_minutes', 15) + + is_valid, errors = timer_service.validate_timer_settings( + work_minutes, short_break_minutes, long_break_minutes + ) + + return jsonify({ + 'valid': is_valid, + 'errors': errors + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=8000) \ No newline at end of file diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..2958bf73 --- /dev/null +++ b/architecture.md @@ -0,0 +1,73 @@ +# ポモドーロタイマーWebアプリケーション アーキテクチャ案 + +## 1. 概要 + +本アプリは、Flask(Python)をバックエンドとし、HTML/CSS/JavaScriptによるフロントエンドでポモドーロタイマー機能を提供するWebアプリケーションです。UIは添付画像のモックを参考に設計します。 + +--- + +## 2. 構成 + +### バックエンド(Flask) +- ルーティング:`/`でメイン画面(index.html)を表示 +- 静的ファイル(HTML, CSS, JS, 画像)の配信 +- 必要に応じてAPIエンドポイント(履歴保存、設定値取得・保存など) +- タイマーや履歴管理などのビジネスロジックはサービス層(例:`services/timer_service.py`)に分離 + +### フロントエンド(HTML/CSS/JavaScript) +- タイマーUI(カウントダウン、スタート/ストップ/リセットボタン) +- 状態表示(残り時間、ポモドーロ回数、休憩表示など) +- JavaScriptでタイマー制御(リアルタイム性重視) +- 必要に応じてAPIと連携 + +--- + +## 3. ディレクトリ構成例 + +``` +/workspaces/2025-Github-Copilot-Workshop-Python/ +├── app.py +├── services/ +│ └── timer_service.py +├── tests/ +│ └── test_timer_service.py +├── templates/ +│ └── index.html +├── static/ +│ ├── css/ +│ ├── js/ +│ └── img/ +└── README.md +``` + +--- + +## 4. ユニットテスト対応 + +- バックエンドのロジックはサービス層に分離し、関数・クラス単位でテスト可能にする +- `tests/`ディレクトリで各モジュールごとにテストファイルを作成 +- APIエンドポイントは入出力が明確な関数として実装 +- フロントエンドのロジックも関数化し、必要に応じてJest等でテスト可能な設計 +- 依存性注入やモック・スタブを活用し、テスト容易性を高める + +--- + +## 5. 拡張性 + +- ユーザー認証による履歴管理 +- 設定カスタマイズ(作業/休憩時間) +- 履歴グラフ表示 + +--- + +## 6. 実装ステップ例 + +1. Flaskで基本ルーティングと静的ファイル配信 +2. HTML/CSSでUIモックを再現 +3. JavaScriptでタイマー機能実装 +4. サービス層・テストコード追加 +5. 必要に応じてAPIエンドポイント追加 + +--- + +このアーキテクチャにより、シンプルかつ拡張・テストしやすいWebアプリケーションの構築が可能です。 diff --git a/features.md b/features.md new file mode 100644 index 00000000..aabfd260 --- /dev/null +++ b/features.md @@ -0,0 +1,57 @@ +# ポモドーロタイマーWebアプリケーション 実装機能一覧 + +--- + +## 基本機能 + +1. **タイマー機能** + - 作業時間・休憩時間のカウントダウン + - タイマーのスタート/ストップ/リセット + - ポモドーロ回数のカウント + +2. **UI表示** + - 残り時間の表示 + - 現在の状態(作業中/休憩中)の表示 + - ポモドーロ回数の表示 + - ボタン(スタート/ストップ/リセット)の配置 + +3. **設定機能** + - 作業時間・休憩時間のカスタマイズ + - 設定値の保存(セッション or DB) + +4. **通知機能** + - タイマー終了時の通知(音・ポップアップ等) + +--- + +## 追加・拡張機能(任意) + +5. **履歴管理** + - ポモドーロ実施履歴の保存 + - 履歴の一覧表示 + +6. **ユーザー認証** + - ログイン/ログアウト + - ユーザーごとの履歴管理 + +7. **グラフ表示** + - 履歴データの可視化(グラフ等) + +8. **レスポンシブ対応** + - スマホ・タブレットでも使いやすいUI + +--- + +## バックエンド(Flask)側 + +- ルーティング(画面表示、APIエンドポイント) +- 設定値・履歴の保存/取得API +- 必要に応じてユーザー認証API + +--- + +## フロントエンド(HTML/CSS/JavaScript)側 + +- タイマー制御ロジック +- UI操作・状態管理 +- APIとの通信(設定・履歴保存等) diff --git a/main.py b/main.py index e69de29b..7ee3ae2e 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,9 @@ +# Fibonacci数列を計算する関数 +def fibonacci(n): + if n == 0: + return 0 + elif n == 1: + return 1 + else: + return fibonacci(n - 1) + fibonacci(n - 2) + \ No newline at end of file diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..fef9c881 --- /dev/null +++ b/plan.md @@ -0,0 +1,50 @@ +# ポモドーロタイマーWebアプリケーション 段階的実装計画 + +--- + +## 第1段階:UIと基本ルーティング +- Flaskでルート(`/`)に静的なHTMLを表示 +- UIモック画像に近いレイアウトをHTML/CSSで作成 +- 「スタート」「ストップ」「リセット」ボタンの配置(動作は未実装) + +--- + +## 第2段階:タイマー機能の実装 +- JavaScriptでタイマーのカウントダウン機能を実装 +- ボタン操作でタイマーの開始・停止・リセットが可能に +- 残り時間・状態(作業/休憩)・ポモドーロ回数の表示 + +--- + +## 第3段階:設定機能の追加 +- 作業時間・休憩時間のカスタマイズUI追加 +- 設定値をフロントエンドで保持し、タイマーに反映 + +--- + +## 第4段階:通知機能の追加 +- タイマー終了時に音やポップアップで通知 + +--- + +## 第5段階:履歴管理・API連携 +- 履歴保存用API(Flask)を実装 +- タイマー完了時に履歴をサーバーへ送信 +- 履歴一覧表示(簡易でOK) + +--- + +## 第6段階:テスト・リファクタ・サービス層分離 +- タイマーや履歴管理ロジックをサービス層へ分離 +- バックエンド・フロントエンドのユニットテスト追加 + +--- + +## 第7段階:拡張機能(任意) +- ユーザー認証(履歴の個人管理) +- 履歴グラフ表示 +- レスポンシブ対応(スマホ・タブレット) + +--- + +各段階で「UI→ロジック→データ連携→テスト→拡張」の流れを意識すると、着実に品質を高めながら開発できます。 diff --git a/point.py b/point.py index e69de29b..474cd5f8 100644 --- a/point.py +++ b/point.py @@ -0,0 +1,18 @@ +# 三次元空間の点を表すクラス +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + + def distance_to(self, other): + # TODO: ここに距離計算のコードを追加 + dx = self.x - other.x + dy = self.y - other.y + dz = self.z - other.z + return math.sqrt(dx * dx + dy * dy + dz * dz) + + def __str__(self): + # TODO: 文字列表現を返す + return f"Point3D({self.x}, {self.y}, {self.z})" + \ No newline at end of file diff --git a/pomodoro_history.json b/pomodoro_history.json new file mode 100644 index 00000000..d1530c2d --- /dev/null +++ b/pomodoro_history.json @@ -0,0 +1,16 @@ +[ + { + "id": "20250827_065519", + "session_type": "work", + "duration": 600, + "completed_at": "2025-08-27T06:55:19.934639", + "pomodoro_count": 0 + }, + { + "id": "20250829_094135", + "session_type": "work", + "duration": 600, + "completed_at": "2025-08-29T09:41:35.687346", + "pomodoro_count": 0 + } +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5b892d47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "--disable-warnings", + "--color=yes" +] + +[tool.coverage.run] +source = ["."] +omit = [ + "tests/*", + ".venv/*", + "venv/*", + "*/__pycache__/*" +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:" +] diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 00000000..4fef3fed --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# ポモドーロタイマーアプリケーション テスト実行スクリプト + +echo "=== ポモドーロタイマー ユニットテスト ===" +echo "Python環境: $(python --version)" +echo "pytest バージョン: $(pytest --version)" +echo "" + +echo "=== 全テスト実行 ===" +python -m pytest tests/ -v + +echo "" +echo "=== カバレッジレポート ===" +python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=html + +echo "" +echo "=== テスト実行完了 ===" +echo "HTMLカバレッジレポートが htmlcov/index.html に生成されました" diff --git a/server.log b/server.log new file mode 100644 index 00000000..531160c0 --- /dev/null +++ b/server.log @@ -0,0 +1,19 @@ + * Serving Flask app 'app' + * Debug mode: on +[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:8000 + * Running on http://10.0.0.199:8000 +[33mPress CTRL+C to quit[0m + * Restarting with stat + * Debugger is active! + * Debugger PIN: 103-069-849 +127.0.0.1 - - [29/Aug/2025 09:25:39] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [29/Aug/2025 09:25:39] "[36mGET /static/css/style.css HTTP/1.1[0m" 304 - +127.0.0.1 - - [29/Aug/2025 09:25:39] "[36mGET /static/js/timer.js HTTP/1.1[0m" 304 - +127.0.0.1 - - [29/Aug/2025 09:25:39] "[33mGET /favicon.ico HTTP/1.1[0m" 404 - +127.0.0.1 - - [29/Aug/2025 09:29:34] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [29/Aug/2025 09:29:35] "GET /static/css/style.css HTTP/1.1" 200 - +127.0.0.1 - - [29/Aug/2025 09:29:35] "[36mGET /static/js/timer.js HTTP/1.1[0m" 304 - +127.0.0.1 - - [29/Aug/2025 09:41:35] "[33mGET /favicon.ico HTTP/1.1[0m" 404 - +127.0.0.1 - - [29/Aug/2025 09:41:35] "[35m[1mPOST /api/history HTTP/1.1[0m" 201 - diff --git a/server.pid b/server.pid new file mode 100644 index 00000000..db3db05f --- /dev/null +++ b/server.pid @@ -0,0 +1 @@ +3285 diff --git a/server_manager.sh b/server_manager.sh new file mode 100755 index 00000000..6c0bfe48 --- /dev/null +++ b/server_manager.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# ポモドーロタイマーサーバー管理スクリプト + +APP_NAME="ポモドーロタイマー" +APP_DIR="/workspaces/2025-Github-Copilot-Workshop-Python" +PID_FILE="$APP_DIR/server.pid" +LOG_FILE="$APP_DIR/server.log" + +# 色の定義 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 使用方法を表示 +usage() { + echo -e "${BLUE}使用方法:${NC}" + echo " $0 {start|stop|restart|status|logs}" + echo "" + echo -e "${BLUE}コマンド:${NC}" + echo " start - サーバーを開始" + echo " stop - サーバーを停止" + echo " restart - サーバーを再起動" + echo " status - サーバーの状態を確認" + echo " logs - サーバーログを表示" +} + +# サーバー開始 +start_server() { + if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then + echo -e "${YELLOW}$APP_NAME は既に動作中です (PID: $(cat $PID_FILE))${NC}" + return 1 + fi + + echo -e "${GREEN}$APP_NAME を開始中...${NC}" + cd "$APP_DIR" + + # バックグラウンドでサーバーを起動 + nohup .venv/bin/python app.py > "$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + + sleep 2 + + if kill -0 $(cat "$PID_FILE") 2>/dev/null; then + echo -e "${GREEN}✅ $APP_NAME が正常に開始されました (PID: $(cat $PID_FILE))${NC}" + echo "" + echo -e "${GREEN}🌐 アクセスURL:${NC}" + echo " • ローカル: http://127.0.0.1:8000" + echo " • ネットワーク: http://10.0.3.246:8000" + else + echo -e "${RED}❌ $APP_NAME の開始に失敗しました${NC}" + rm -f "$PID_FILE" + return 1 + fi +} + +# サーバー停止 +stop_server() { + if [ ! -f "$PID_FILE" ]; then + echo -e "${YELLOW}$APP_NAME は動作していません${NC}" + return 1 + fi + + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo -e "${YELLOW}$APP_NAME を停止中... (PID: $PID)${NC}" + kill "$PID" + sleep 2 + + if kill -0 "$PID" 2>/dev/null; then + echo -e "${RED}強制停止中...${NC}" + kill -9 "$PID" + fi + + echo -e "${GREEN}✅ $APP_NAME が停止されました${NC}" + else + echo -e "${YELLOW}プロセス $PID は見つかりません${NC}" + fi + + rm -f "$PID_FILE" +} + +# サーバー状態確認 +check_status() { + if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then + PID=$(cat "$PID_FILE") + echo -e "${GREEN}✅ $APP_NAME は動作中です (PID: $PID)${NC}" + echo "" + echo -e "${BLUE}アクセスURL:${NC}" + echo " • ローカル: http://127.0.0.1:8000" + echo " • ネットワーク: http://10.0.3.246:8000" + return 0 + else + echo -e "${RED}❌ $APP_NAME は停止中です${NC}" + [ -f "$PID_FILE" ] && rm -f "$PID_FILE" + return 1 + fi +} + +# ログ表示 +show_logs() { + if [ -f "$LOG_FILE" ]; then + echo -e "${BLUE}$APP_NAME ログ (最新20行):${NC}" + echo "=================================" + tail -20 "$LOG_FILE" + else + echo -e "${YELLOW}ログファイルが見つかりません${NC}" + fi +} + +# メイン処理 +case "$1" in + start) + start_server + ;; + stop) + stop_server + ;; + restart) + stop_server + sleep 1 + start_server + ;; + status) + check_status + ;; + logs) + show_logs + ;; + *) + usage + exit 1 + ;; +esac diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 00000000..681c662f --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# サービス層パッケージ diff --git a/services/__pycache__/__init__.cpython-311.pyc b/services/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 00000000..3b9174c6 Binary files /dev/null and b/services/__pycache__/__init__.cpython-311.pyc differ diff --git a/services/__pycache__/timer_service.cpython-311.pyc b/services/__pycache__/timer_service.cpython-311.pyc new file mode 100644 index 00000000..421f816b Binary files /dev/null and b/services/__pycache__/timer_service.cpython-311.pyc differ diff --git a/services/timer_service.py b/services/timer_service.py new file mode 100644 index 00000000..4f176a08 --- /dev/null +++ b/services/timer_service.py @@ -0,0 +1,147 @@ +""" +ポモドーロタイマーのサービス層 +ビジネスロジックとデータ処理を担当 +""" +import json +import os +from datetime import datetime +from typing import List, Dict, Optional + + +class HistoryService: + """履歴管理を担当するサービス""" + + def __init__(self, history_file: str = 'pomodoro_history.json'): + self.history_file = history_file + + def load_history(self) -> List[Dict]: + """履歴データを読み込む""" + if os.path.exists(self.history_file): + try: + with open(self.history_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"履歴読み込みエラー: {e}") + return [] + return [] + + def save_history(self, history: List[Dict]) -> bool: + """履歴データを保存する""" + try: + with open(self.history_file, 'w', encoding='utf-8') as f: + json.dump(history, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + print(f"履歴保存エラー: {e}") + return False + + def add_history_entry(self, session_type: str, duration: int, pomodoro_count: int) -> Optional[Dict]: + """履歴エントリを追加する""" + try: + # 履歴エントリを作成 + history_entry = { + 'id': datetime.now().strftime('%Y%m%d_%H%M%S'), + 'session_type': session_type, + 'duration': duration, + 'completed_at': datetime.now().isoformat(), + 'pomodoro_count': pomodoro_count + } + + # 既存の履歴を読み込み + history = self.load_history() + history.append(history_entry) + + # 最新の100件のみ保持 + history = history[-100:] + + if self.save_history(history): + return history_entry + else: + return None + + except Exception as e: + print(f"履歴追加エラー: {e}") + return None + + def get_statistics(self) -> Dict: + """統計データを計算する""" + try: + history = self.load_history() + + # 基本統計 + total_pomodoros = len([h for h in history if h['session_type'] == 'work']) + total_work_time = sum(h['duration'] for h in history if h['session_type'] == 'work') + total_break_time = sum(h['duration'] for h in history if h['session_type'] == 'break') + + # 今日の統計 + today = datetime.now().strftime('%Y-%m-%d') + today_history = [h for h in history if h['completed_at'].startswith(today)] + today_pomodoros = len([h for h in today_history if h['session_type'] == 'work']) + + return { + 'total_pomodoros': total_pomodoros, + 'total_work_time': total_work_time, + 'total_break_time': total_break_time, + 'today_pomodoros': today_pomodoros, + 'total_sessions': len(history) + } + + except Exception as e: + print(f"統計計算エラー: {e}") + return { + 'total_pomodoros': 0, + 'total_work_time': 0, + 'total_break_time': 0, + 'today_pomodoros': 0, + 'total_sessions': 0 + } + + +class TimerService: + """タイマー関連のビジネスロジックを担当するサービス""" + + @staticmethod + def validate_timer_settings(work_minutes: int, short_break_minutes: int, long_break_minutes: int) -> tuple[bool, List[str]]: + """タイマー設定の検証""" + errors = [] + + if not isinstance(work_minutes, int) or work_minutes <= 0: + errors.append("作業時間は正の整数である必要があります") + elif work_minutes > 60: + errors.append("作業時間は60分以下にしてください") + + if not isinstance(short_break_minutes, int) or short_break_minutes <= 0: + errors.append("短い休憩時間は正の整数である必要があります") + elif short_break_minutes > 30: + errors.append("短い休憩時間は30分以下にしてください") + + if not isinstance(long_break_minutes, int) or long_break_minutes <= 0: + errors.append("長い休憩時間は正の整数である必要があります") + elif long_break_minutes > 60: + errors.append("長い休憩時間は60分以下にしてください") + + return len(errors) == 0, errors + + @staticmethod + def format_time(total_seconds: int) -> str: + """秒を MM:SS 形式に変換""" + if total_seconds < 0: + total_seconds = 0 + + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes:02d}:{seconds:02d}" + + @staticmethod + def calculate_break_duration(completed_pomodoros: int, short_break_time: int, long_break_time: int) -> int: + """完了したポモドーロ数に基づいて休憩時間を計算""" + return long_break_time if completed_pomodoros % 4 == 0 and completed_pomodoros > 0 else short_break_time + + @staticmethod + def get_session_type_display(session_type: str, completed_pomodoros: int) -> str: + """セッションタイプの表示名を取得""" + if session_type == 'work': + return '作業中' + else: + is_long_break = completed_pomodoros % 4 == 0 and completed_pomodoros > 0 + return '長い休憩中' if is_long_break else '休憩中' diff --git a/start_server.sh b/start_server.sh new file mode 100755 index 00000000..1fc6e7f4 --- /dev/null +++ b/start_server.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# ポモドーロタイマーサーバー起動スクリプト + +# 色の定義 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🍅 ポモドーロタイマーサーバー起動スクリプト${NC}" +echo "=================================" + +# 作業ディレクトリに移動 +cd /workspaces/2025-Github-Copilot-Workshop-Python + +# 既存のプロセスを確認・停止 +echo -e "${YELLOW}既存のサーバープロセスを確認中...${NC}" +if pgrep -f "python.*app.py" > /dev/null; then + echo -e "${YELLOW}既存のサーバーを停止中...${NC}" + pkill -f "python.*app.py" + sleep 2 +fi + +# 仮想環境の確認 +if [ ! -d ".venv" ]; then + echo -e "${RED}エラー: 仮想環境が見つかりません${NC}" + exit 1 +fi + +# サーバー起動 +echo -e "${GREEN}サーバーを起動中...${NC}" +echo "" +echo -e "${GREEN}アクセスURL:${NC}" +echo "- ローカル: http://127.0.0.1:8000" +echo "- ネットワーク: http://10.0.3.246:8000" +echo "" +echo -e "${YELLOW}サーバーを停止するには Ctrl+C を押してください${NC}" +echo "=================================" + +# サーバー起動 +.venv/bin/python app.py diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 00000000..1d8a789d --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,422 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + color: #333; +} + +.container { + background: rgba(255, 255, 255, 0.95); + border-radius: 25px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1); + padding: 40px; + text-align: center; + min-width: 400px; + max-width: 500px; + backdrop-filter: blur(10px); +} + +.timer-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; +} + +.header { + margin-bottom: 20px; +} + +.header h1 { + font-size: 2.2rem; + color: #667eea; + font-weight: 600; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); +} + +.timer-display { + margin: 20px 0; +} + +.time-circle { + width: 250px; + height: 250px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea, #764ba2); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3); + position: relative; + margin: 0 auto; +} + +.time-circle::before { + content: ''; + position: absolute; + top: 10px; + left: 10px; + right: 10px; + bottom: 10px; + background: rgba(255, 255, 255, 0.95); + border-radius: 50%; + z-index: 1; +} + +.time { + font-size: 3.5rem; + font-weight: bold; + color: #667eea; + z-index: 2; + position: relative; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); +} + +.status { + font-size: 1.2rem; + color: #666; + z-index: 2; + position: relative; + margin-top: 5px; + font-weight: 500; + padding: 5px 15px; + border-radius: 20px; + transition: all 0.3s ease; +} + +.status.work { + background: rgba(102, 126, 234, 0.1); + color: #667eea; + border: 2px solid rgba(102, 126, 234, 0.3); +} + +.status.break { + background: rgba(255, 193, 7, 0.1); + color: #ffc107; + border: 2px solid rgba(255, 193, 7, 0.3); +} + +.info-section { + background: rgba(102, 126, 234, 0.1); + padding: 20px; + border-radius: 20px; + border: 2px solid rgba(102, 126, 234, 0.2); + width: 100%; +} + +.pomodoro-counter { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +.counter-label { + font-size: 1.1rem; + color: #666; + font-weight: 500; +} + +.counter-value { + font-size: 2rem; + font-weight: bold; + color: #667eea; + background: white; + padding: 10px 20px; + border-radius: 15px; + border: 2px solid #667eea; + min-width: 60px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} + +.controls { + display: flex; + gap: 15px; + flex-wrap: wrap; + justify-content: center; + width: 100%; +} + +.btn { + padding: 15px 25px; + border: none; + border-radius: 15px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + min-width: 180px; + width: 180px; + flex-shrink: 0; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn span { + font-size: 1.2rem; +} + +.btn:hover { + transform: translateY(-3px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.btn:active { + transform: translateY(-1px); +} + +.btn-start { + background: linear-gradient(135deg, #28a745, #20c997); + color: white; +} + +.btn-start:hover { + background: linear-gradient(135deg, #218838, #17a2b8); +} + +.btn-reset { + background: linear-gradient(135deg, #6c757d, #495057); + color: white; +} + +.btn-reset:hover { + background: linear-gradient(135deg, #5a6268, #343a40); +} + +.action-buttons { + display: flex; + gap: 15px; + justify-content: center; + width: 100%; + margin-bottom: 20px; +} + +.settings-section { + width: 100%; + position: relative; +} + +.settings-panel { + background: rgba(255, 255, 255, 0.98); + border: 2px solid #667eea; + border-radius: 20px; + padding: 25px; + margin-top: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.settings-panel.hidden { + display: none; +} + +.settings-panel h3 { + color: #667eea; + margin-bottom: 20px; + text-align: center; + font-size: 1.3rem; +} + +.setting-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding: 10px; + background: rgba(102, 126, 234, 0.05); + border-radius: 10px; +} + +.setting-item label { + font-weight: 500; + color: #333; + flex: 1; +} + +.setting-item input { + width: 80px; + padding: 8px 12px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 1rem; + text-align: center; + transition: border-color 0.3s ease; +} + +.setting-item input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.setting-buttons { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: 2px solid #667eea; +} + +.btn-primary:hover { + background: linear-gradient(135deg, #5a6fd8, #6a4190); +} + +.btn-secondary { + background: linear-gradient(135deg, #6c757d, #495057); + color: white; + border: 2px solid #6c757d; +} + +.btn-secondary:hover { + background: linear-gradient(135deg, #5a6268, #343a40); +} + +.history-section { + width: 100%; + position: relative; +} + +.history-panel { + background: rgba(255, 255, 255, 0.98); + border: 2px solid #667eea; + border-radius: 20px; + padding: 25px; + margin-top: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + max-height: 400px; + overflow-y: auto; +} + +.history-panel.hidden { + display: none; +} + +.history-panel h3 { + color: #667eea; + margin-bottom: 15px; + text-align: center; + font-size: 1.2rem; +} + +.stats-container { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: rgba(102, 126, 234, 0.05); + border-radius: 10px; +} + +.stat-label { + font-weight: 500; + color: #333; +} + +.stat-value { + font-weight: bold; + color: #667eea; + background: white; + padding: 5px 12px; + border-radius: 8px; + border: 1px solid #667eea; +} + +.history-list { + max-height: 200px; + overflow-y: auto; + margin-bottom: 20px; +} + +.history-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 15px; + margin-bottom: 8px; + background: rgba(102, 126, 234, 0.05); + border-radius: 8px; + font-size: 0.9rem; +} + +.history-item.work { + border-left: 4px solid #667eea; +} + +.history-item.break { + border-left: 4px solid #ffc107; +} + +.history-buttons { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 15px; +} + +@media (max-width: 600px) { + .container { + margin: 20px; + padding: 30px 20px; + min-width: auto; + width: 90vw; + } + + .header h1 { + font-size: 1.8rem; + } + + .time-circle { + width: 200px; + height: 200px; + } + + .time { + font-size: 2.5rem; + } + + .controls { + flex-direction: column; + gap: 12px; + } + + .btn { + min-width: 100%; + width: 100%; + } + + .action-buttons { + flex-direction: column; + gap: 12px; + } + + .pomodoro-counter { + flex-direction: column; + gap: 10px; + } +} diff --git a/pomodoro.png b/static/img/pomodoro.png similarity index 100% rename from pomodoro.png rename to static/img/pomodoro.png diff --git a/static/js/timer.js b/static/js/timer.js new file mode 100644 index 00000000..b6c16a25 --- /dev/null +++ b/static/js/timer.js @@ -0,0 +1,404 @@ +// ポモドーロタイマーアプリケーション + +class PomodoroTimer { + constructor() { + this.timeDisplay = document.querySelector('.time'); + this.statusDisplay = document.querySelector('.status'); + this.counterDisplay = document.querySelector('.counter-value'); + + this.startBtn = document.getElementById('start-btn'); + this.resetBtn = document.getElementById('reset-btn'); + + // 設定関連の要素 + this.settingsBtn = document.getElementById('settings-btn'); + this.settingsPanel = document.getElementById('settings-panel'); + this.workTimeInput = document.getElementById('work-time'); + this.shortBreakTimeInput = document.getElementById('short-break-time'); + this.longBreakTimeInput = document.getElementById('long-break-time'); + this.saveSettingsBtn = document.getElementById('save-settings'); + this.cancelSettingsBtn = document.getElementById('cancel-settings'); + + // 履歴関連の要素 + this.historyBtn = document.getElementById('history-btn'); + this.historyPanel = document.getElementById('history-panel'); + this.refreshHistoryBtn = document.getElementById('refresh-history'); + this.closeHistoryBtn = document.getElementById('close-history'); + + // タイマー設定(秒) + this.workTime = 25 * 60; // 25分 + this.shortBreakTime = 5 * 60; // 5分 + this.longBreakTime = 15 * 60; // 15分 + + // 現在の状態 + this.currentTime = this.workTime; + this.isRunning = false; + this.isWorkSession = true; + this.completedPomodoros = 0; + this.intervalId = null; + + // 初期化 + this.init(); + } + + init() { + // イベントリスナーを設定 + this.startBtn.addEventListener('click', () => this.start()); + this.resetBtn.addEventListener('click', () => this.reset()); + + // 設定関連のイベントリスナー + this.settingsBtn.addEventListener('click', () => this.toggleSettings()); + this.saveSettingsBtn.addEventListener('click', () => this.saveSettings()); + this.cancelSettingsBtn.addEventListener('click', () => this.cancelSettings()); + + // 履歴関連のイベントリスナー + this.historyBtn.addEventListener('click', () => this.toggleHistory()); + this.refreshHistoryBtn.addEventListener('click', () => this.loadHistory()); + this.closeHistoryBtn.addEventListener('click', () => this.closeHistory()); + + // 設定を読み込み + this.loadSettings(); + + // 初期表示を更新 + this.updateDisplay(); + + console.log('ポモドーロタイマーが初期化されました'); + } + + start() { + if (!this.isRunning) { + this.isRunning = true; + this.intervalId = setInterval(() => { + this.tick(); + }, 1000); + + // ボタンの状態を更新 + this.startBtn.textContent = '⏸ 一時停止'; + + console.log('タイマー開始'); + } else { + // 一時停止 + this.pause(); + } + } + + pause() { + this.isRunning = false; + clearInterval(this.intervalId); + this.intervalId = null; + + // ボタンの状態を更新 + this.startBtn.textContent = '▶ 再開'; + + console.log('タイマー一時停止'); + } + + reset() { + this.pause(); + + // 現在のセッションタイプに応じて時間をリセット + if (this.isWorkSession) { + this.currentTime = this.workTime; + } else { + // 休憩時間の決定(4セット目後は長い休憩) + this.currentTime = (this.completedPomodoros % 4 === 0 && this.completedPomodoros > 0) + ? this.longBreakTime + : this.shortBreakTime; + } + + // ボタンの状態をリセット + this.startBtn.textContent = '▶ スタート'; + + this.updateDisplay(); + console.log('タイマーリセット'); + } + + tick() { + if (this.currentTime > 0) { + this.currentTime--; + this.updateDisplay(); + } else { + // タイマー終了 + this.onTimerComplete(); + } + } + + onTimerComplete() { + this.pause(); + + if (this.isWorkSession) { + // 作業セッション完了 - 履歴に保存 + this.saveToHistory('work', this.workTime); + + this.completedPomodoros++; + this.isWorkSession = false; + + // 次は休憩セッション + this.currentTime = (this.completedPomodoros % 4 === 0) + ? this.longBreakTime + : this.shortBreakTime; + + this.showNotification('作業時間終了!', '休憩時間を開始しましょう。'); + } else { + // 休憩セッション完了 - 履歴に保存 + const breakDuration = (this.completedPomodoros % 4 === 0) + ? this.longBreakTime + : this.shortBreakTime; + this.saveToHistory('break', breakDuration); + + this.isWorkSession = true; + this.currentTime = this.workTime; + + this.showNotification('休憩時間終了!', '次のポモドーロを開始しましょう。'); + } + + // ボタンの状態をリセット + this.startBtn.textContent = '▶ スタート'; + + this.updateDisplay(); + console.log('タイマー完了 - セッション切り替え'); + } + + updateDisplay() { + // 時間表示を更新 + this.timeDisplay.textContent = this.formatTime(this.currentTime); + + // 状態表示を更新 + if (this.isWorkSession) { + this.statusDisplay.textContent = '作業中'; + this.statusDisplay.className = 'status work'; + } else { + const isLongBreak = (this.completedPomodoros % 4 === 0 && this.completedPomodoros > 0); + this.statusDisplay.textContent = isLongBreak ? '長い休憩中' : '休憩中'; + this.statusDisplay.className = 'status break'; + } + + // カウンター表示を更新 + this.counterDisplay.textContent = this.completedPomodoros; + + // ページタイトルも更新 + document.title = `${this.formatTime(this.currentTime)} - ポモドーロタイマー`; + } + + formatTime(seconds) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; + } + + showNotification(title, message) { + // ブラウザ通知(基本的なアラート) + if (Notification.permission === 'granted') { + new Notification(title, { body: message }); + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then(permission => { + if (permission === 'granted') { + new Notification(title, { body: message }); + } + }); + } else { + // フォールバック: アラート + alert(`${title}\n${message}`); + } + + // 音の再生(可能な場合) + this.playNotificationSound(); + } + + playNotificationSound() { + try { + // Web Audio APIを使用した簡単なビープ音 + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.value = 800; // 800Hz + gainNode.gain.value = 0.1; // 音量 + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.2); // 0.2秒間 + } catch (error) { + console.log('音声再生に失敗しました:', error); + } + } + + // 設定関連のメソッド + toggleSettings() { + this.settingsPanel.classList.toggle('hidden'); + if (!this.settingsPanel.classList.contains('hidden')) { + // 設定パネルを開く時は現在の値を表示 + this.workTimeInput.value = this.workTime / 60; + this.shortBreakTimeInput.value = this.shortBreakTime / 60; + this.longBreakTimeInput.value = this.longBreakTime / 60; + } + } + + saveSettings() { + const workTime = parseInt(this.workTimeInput.value) * 60; + const shortBreakTime = parseInt(this.shortBreakTimeInput.value) * 60; + const longBreakTime = parseInt(this.longBreakTimeInput.value) * 60; + + // バリデーション + if (workTime < 60 || workTime > 3600 || + shortBreakTime < 60 || shortBreakTime > 1800 || + longBreakTime < 60 || longBreakTime > 3600) { + alert('設定値が範囲外です。適切な値を入力してください。'); + return; + } + + // 設定を更新 + this.workTime = workTime; + this.shortBreakTime = shortBreakTime; + this.longBreakTime = longBreakTime; + + // ローカルストレージに保存 + const settings = { + workTime: this.workTime, + shortBreakTime: this.shortBreakTime, + longBreakTime: this.longBreakTime + }; + localStorage.setItem('pomodoroSettings', JSON.stringify(settings)); + + // 現在のタイマーをリセット + this.reset(); + + // 設定パネルを閉じる + this.settingsPanel.classList.add('hidden'); + + console.log('設定が保存されました'); + alert('設定が保存されました!'); + } + + cancelSettings() { + this.settingsPanel.classList.add('hidden'); + } + + loadSettings() { + try { + const savedSettings = localStorage.getItem('pomodoroSettings'); + if (savedSettings) { + const settings = JSON.parse(savedSettings); + this.workTime = settings.workTime || this.workTime; + this.shortBreakTime = settings.shortBreakTime || this.shortBreakTime; + this.longBreakTime = settings.longBreakTime || this.longBreakTime; + + // 現在の時間も更新 + if (this.isWorkSession) { + this.currentTime = this.workTime; + } + + console.log('設定が読み込まれました'); + } + } catch (error) { + console.log('設定の読み込みに失敗しました:', error); + } + } + + // 履歴関連のメソッド + toggleHistory() { + this.historyPanel.classList.toggle('hidden'); + if (!this.historyPanel.classList.contains('hidden')) { + this.loadHistory(); + } + } + + closeHistory() { + this.historyPanel.classList.add('hidden'); + } + + async loadHistory() { + try { + // 統計情報を取得 + const statsResponse = await fetch('/api/stats'); + const stats = await statsResponse.json(); + + // 統計情報を表示 + document.getElementById('today-pomodoros').textContent = stats.today_pomodoros || 0; + document.getElementById('total-pomodoros').textContent = stats.total_pomodoros || 0; + + const totalHours = Math.floor((stats.total_work_time || 0) / 3600); + const totalMinutes = Math.floor(((stats.total_work_time || 0) % 3600) / 60); + document.getElementById('total-work-time').textContent = `${totalHours}時間${totalMinutes}分`; + + // 履歴データを取得 + const historyResponse = await fetch('/api/history'); + const history = await historyResponse.json(); + + // 履歴リストを表示 + this.displayHistory(history); + + } catch (error) { + console.error('履歴の読み込みに失敗しました:', error); + alert('履歴の読み込みに失敗しました'); + } + } + + displayHistory(history) { + const historyList = document.getElementById('history-list'); + historyList.innerHTML = ''; + + if (history.length === 0) { + historyList.innerHTML = '
履歴がありません
'; + return; + } + + // 最新10件のみ表示 + const recentHistory = history.slice(-10).reverse(); + + recentHistory.forEach(item => { + const historyItem = document.createElement('div'); + historyItem.className = `history-item ${item.session_type}`; + + const sessionType = item.session_type === 'work' ? '作業' : '休憩'; + const duration = this.formatTime(item.duration); + const date = new Date(item.completed_at); + const timeStr = date.toLocaleString('ja-JP', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + historyItem.innerHTML = ` + ${sessionType} (${duration}) + ${timeStr} + `; + + historyList.appendChild(historyItem); + }); + } + + async saveToHistory(sessionType, duration) { + try { + const historyData = { + session_type: sessionType, + duration: duration, + pomodoro_count: this.completedPomodoros + }; + + const response = await fetch('/api/history', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(historyData) + }); + + if (response.ok) { + console.log('履歴が保存されました'); + } else { + console.error('履歴の保存に失敗しました'); + } + + } catch (error) { + console.error('履歴の保存エラー:', error); + } + } +} + +// ページ読み込み完了後にタイマーを初期化 +document.addEventListener('DOMContentLoaded', () => { + new PomodoroTimer(); +}); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..c19c3e1e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,98 @@ + + + + + +