diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/app.py b/app.py new file mode 100644 index 0000000..619b544 --- /dev/null +++ b/app.py @@ -0,0 +1,209 @@ +from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify +from flask_bcrypt import Bcrypt +from pymongo import MongoClient +from bson.objectid import ObjectId +from datetime import datetime +import os +from dotenv import load_dotenv + +load_dotenv() + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') + +client = MongoClient(os.getenv('MONGO_URI')) +db = client.get_default_database() +bcrypt = Bcrypt(app) + +# --- Helpers --- +def get_user_id(): + return session.get('user_id') + +def is_logged_in(): + return 'user_id' in session + +# --- Routes --- + +@app.route('/') +def index(): + if not is_logged_in(): + return redirect(url_for('login')) + + user_id = get_user_id() + + # Get all activities + activities = list(db.activities.find({'user_id': user_id})) + + # Check for active time entry + current_entry = db.time_entries.find_one({ + 'user_id': user_id, + 'end_time': None + }) + + active_tasks_template = [] + if current_entry: + # Get tasks associated with the active activity type + active_activity = db.activities.find_one({'_id': current_entry['activity_id']}) + if active_activity: + current_entry['activity_name'] = active_activity['name'] + current_entry['activity_color'] = active_activity.get('color', '#3498db') + # Find template tasks for this activity + active_tasks_template = list(db.task_templates.find({'activity_id': active_activity['_id']})) + + return render_template('dashboard.html', + activities=activities, + current_entry=current_entry, + tasks=active_tasks_template) + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + if db.users.find_one({'username': username}): + flash('Username already exists') + return redirect(url_for('register')) + + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + db.users.insert_one({'username': username, 'password': hashed_password}) + flash('Account created! Please login.') + return redirect(url_for('login')) + + return render_template('register.html') + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + user = db.users.find_one({'username': request.form['username']}) + if user and bcrypt.check_password_hash(user['password'], request.form['password']): + session['user_id'] = str(user['_id']) + return redirect(url_for('index')) + flash('Login Unsuccessful. Please check username and password') + return render_template('login.html') + +@app.route('/logout') +def logout(): + session.clear() + return redirect(url_for('login')) + +@app.route('/add_activity', methods=['POST']) +def add_activity(): + if not is_logged_in(): return redirect(url_for('login')) + name = request.form['name'] + color = request.form.get('color', '#3498db') + + activity_id = db.activities.insert_one({ + 'user_id': get_user_id(), + 'name': name, + 'color': color + }).inserted_id + + # Add optional tasks + tasks_text = request.form.get('tasks', '') + if tasks_text: + tasks = [t.strip() for t in tasks_text.split(',') if t.strip()] + for t in tasks: + db.task_templates.insert_one({ + 'activity_id': activity_id, + 'user_id': get_user_id(), + 'name': t + }) + + return redirect(url_for('index')) + +@app.route('/toggle_timer/', methods=['POST']) +def toggle_timer(activity_id): + if not is_logged_in(): return redirect(url_for('login')) + user_id = get_user_id() + note = request.form.get('note', '') + + # Check if this activity is currently running + current = db.time_entries.find_one({ + 'user_id': user_id, + 'activity_id': ObjectId(activity_id), + 'end_time': None + }) + + if current: + # Stop it + db.time_entries.update_one( + {'_id': current['_id']}, + {'$set': {'end_time': datetime.now()}} + ) + else: + # Stop ANY other running timer first (single tasking) + db.time_entries.update_many( + {'user_id': user_id, 'end_time': None}, + {'$set': {'end_time': datetime.now()}} + ) + # Start new + db.time_entries.insert_one({ + 'user_id': user_id, + 'activity_id': ObjectId(activity_id), + 'start_time': datetime.now(), + 'end_time': None, + 'note': note + }) + + return redirect(url_for('index')) + +@app.route('/stop_timer', methods=['POST']) +def stop_timer(): + if not is_logged_in(): return redirect(url_for('login')) + db.time_entries.update_many( + {'user_id': get_user_id(), 'end_time': None}, + {'$set': {'end_time': datetime.now()}} + ) + return redirect(url_for('index')) + +@app.route('/complete_task', methods=['POST']) +def complete_task(): + if not is_logged_in(): return jsonify({'error': 'auth'}), 401 + + # We log that a specific task template was completed during a specific time entry + task_name = request.form['task_name'] + entry_id = request.form['entry_id'] + + db.completed_tasks.insert_one({ + 'user_id': get_user_id(), + 'task_name': task_name, + 'time_entry_id': ObjectId(entry_id), + 'completed_at': datetime.now() + }) + return jsonify({'status': 'success'}) + +@app.route('/logbook') +def logbook(): + if not is_logged_in(): return redirect(url_for('login')) + + # Agrregation to join activities and tasks + pipeline = [ + {'$match': {'user_id': get_user_id(), 'end_time': {'$ne': None}}}, + {'$sort': {'start_time': -1}}, + {'$lookup': { + 'from': 'activities', + 'localField': 'activity_id', + 'foreignField': '_id', + 'as': 'activity' + }}, + {'$unwind': '$activity'}, + {'$lookup': { + 'from': 'completed_tasks', + 'localField': '_id', + 'foreignField': 'time_entry_id', + 'as': 'tasks' + }} + ] + + log = list(db.time_entries.aggregate(pipeline)) + + # Basic formatting + for entry in log: + duration = entry['end_time'] - entry['start_time'] + entry['duration_str'] = str(duration).split('.')[0] # HH:MM:SS + + return render_template('logbook.html', log=log) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5925b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask +pymongo +python-dotenv +flask-bcrypt +bson diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d8134f9 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,124 @@ +{% extends "layout.html" %} +{% block content %} + + {% if current_entry %} +
+

+ Currently Tracking: {{ current_entry.activity_name }} +

+ + {% if current_entry.note %} +

"{{ current_entry.note }}"

+ {% endif %} + +
00:00:00
+ + + {% if tasks %} +
+

Tasks related to {{ current_entry.activity_name }}:

+ {% for task in tasks %} +
+ + +
+ {% endfor %} +
+ {% endif %} + +
+ +
+ + +
+ {% endif %} + + +
+ {% for act in activities %} + +
+ {{ act.name }} +
+ {% endfor %} + + +
+ + New Activity +
+
+ + + + + + + + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..a4335c5 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,53 @@ + + + + + + OpenTimeTracker + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/logbook.html b/templates/logbook.html new file mode 100644 index 0000000..31d0a99 --- /dev/null +++ b/templates/logbook.html @@ -0,0 +1,37 @@ +{% extends "layout.html" %} +{% block content %} +

Logbook

+ {% if not log %} +

No activities recorded yet.

+ {% else %} + {% for entry in log %} +
+
+
+

{{ entry.activity.name }}

+ + {{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} - {{ entry.end_time.strftime('%H:%M') }} + + {% if entry.note %} +

"{{ entry.note }}"

+ {% endif %} +
+
+ {{ entry.duration_str }} +
+
+ + {% if entry.tasks %} +
+ Tasks Completed: +
    + {% for task in entry.tasks %} +
  • {{ task.task_name }} ({{ task.completed_at.strftime('%H:%M') }})
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2ed5822 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} +{% block content %} +
+

Login

+
+ + + + + +
+
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..2600323 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} +{% block content %} +
+

Register

+
+ + + + + +
+
+{% endblock %}