eh meh working prototype i guess
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
209
app.py
Normal file
209
app.py
Normal file
@@ -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/<activity_id>', 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)
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask
|
||||
pymongo
|
||||
python-dotenv
|
||||
flask-bcrypt
|
||||
bson
|
||||
124
templates/dashboard.html
Normal file
124
templates/dashboard.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<!-- Active Timer Section -->
|
||||
{% if current_entry %}
|
||||
<div class="card active-section">
|
||||
<h3 style="text-align: center; color: {{ current_entry.activity_color }}">
|
||||
Currently Tracking: {{ current_entry.activity_name }}
|
||||
</h3>
|
||||
|
||||
{% if current_entry.note %}
|
||||
<p style="text-align: center; color: #666; font-style: italic;">"{{ current_entry.note }}"</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="timer-display" id="timer">00:00:00</div>
|
||||
|
||||
<!-- Tasks for current activity -->
|
||||
{% if tasks %}
|
||||
<div style="margin: 1rem 0; padding: 1rem; background: rgba(255,255,255,0.7); border-radius: 5px;">
|
||||
<h4>Tasks related to {{ current_entry.activity_name }}:</h4>
|
||||
{% for task in tasks %}
|
||||
<div style="margin-bottom: 5px;">
|
||||
<input type="checkbox" id="task_{{ task._id }}"
|
||||
onchange="completeTask('{{ task.name }}', '{{ current_entry._id }}', this)">
|
||||
<label for="task_{{ task._id }}">{{ task.name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('stop_timer') }}" method="POST" style="text-align: center;">
|
||||
<button type="submit" class="btn btn-danger" style="font-size: 1.2rem; width: 100%;">Stop Activity</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Timer Logic
|
||||
const startTime = new Date("{{ current_entry.start_time }}").getTime();
|
||||
|
||||
function updateTimer() {
|
||||
const now = new Date().getTime();
|
||||
const diff = now - startTime;
|
||||
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
document.getElementById("timer").innerHTML =
|
||||
(hours < 10 ? "0" + hours : hours) + ":" +
|
||||
(minutes < 10 ? "0" + minutes : minutes) + ":" +
|
||||
(seconds < 10 ? "0" + seconds : seconds);
|
||||
}
|
||||
setInterval(updateTimer, 1000);
|
||||
updateTimer(); // run immediately
|
||||
|
||||
// AJAX for Tasks
|
||||
function completeTask(name, entryId, checkbox) {
|
||||
if(!checkbox.checked) return; // Only track completion for now
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('task_name', name);
|
||||
formData.append('entry_id', entryId);
|
||||
|
||||
fetch('/complete_task', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkbox.disabled = true; // Prevent double logging
|
||||
checkbox.parentElement.style.textDecoration = "line-through";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Start Activity Section -->
|
||||
<div class="activity-grid">
|
||||
{% for act in activities %}
|
||||
<!-- Clicking opens a small modal or submits form directly. Let's do a form with a prompt for note -->
|
||||
<div class="activity-card"
|
||||
style="background-color: {{ act.color }}"
|
||||
onclick="startActivity('{{ act._id }}', '{{ act.name }}')">
|
||||
<span>{{ act.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Add New Button -->
|
||||
<div class="activity-card" style="background-color: #95a5a6; color: white;" onclick="document.getElementById('newActivityForm').style.display='block'">
|
||||
<span>+ New Activity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Form for Starting Activity with Note -->
|
||||
<form id="startForm" method="POST" style="display:none;">
|
||||
<input type="hidden" name="note" id="startNote">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function startActivity(id, name) {
|
||||
let note = prompt("Start " + name + "?\nAdd a note (optional):");
|
||||
if (note === null) return; // Cancelled
|
||||
|
||||
let form = document.getElementById('startForm');
|
||||
form.action = "/toggle_timer/" + id;
|
||||
document.getElementById('startNote').value = note;
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Create Activity Form -->
|
||||
<div id="newActivityForm" class="card" style="display:none; margin-top: 2rem;">
|
||||
<h3>Create New Activity Category</h3>
|
||||
<form action="{{ url_for('add_activity') }}" method="POST">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. Household">
|
||||
|
||||
<label>Color</label>
|
||||
<input type="color" name="color" value="#3498db" style="width:100%; height:40px; border:none;">
|
||||
|
||||
<label>Default Tasks (comma separated)</label>
|
||||
<input type="text" name="tasks" placeholder="e.g. Laundry, Dishes, Trash">
|
||||
|
||||
<button type="submit" class="btn">Create</button>
|
||||
<button type="button" class="btn" style="background: #7f8c8d" onclick="this.parentElement.parentElement.style.display='none'">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
53
templates/layout.html
Normal file
53
templates/layout.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenTimeTracker</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, system-ui, sans-serif; background: #f4f4f9; color: #333; margin: 0; padding: 0; }
|
||||
nav { background: #2c3e50; padding: 1rem; color: white; display: flex; justify-content: space-between; }
|
||||
nav a { color: white; text-decoration: none; margin-left: 15px; font-weight: bold; }
|
||||
.container { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.card { background: white; padding: 1.5rem; border-radius: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin-bottom: 1.5rem; }
|
||||
.btn { padding: 0.5rem 1rem; border: none; border-radius: 5px; cursor: pointer; color: white; background: #3498db; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
input[type="text"], input[type="password"] { width: 100%; padding: 0.5rem; margin-bottom: 1rem; box-sizing: border-box; }
|
||||
/* Grid for Activities */
|
||||
.activity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; }
|
||||
.activity-card {
|
||||
height: 100px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 10px; color: white; font-weight: bold; cursor: pointer; text-align: center;
|
||||
flex-direction: column; transition: transform 0.1s;
|
||||
}
|
||||
.activity-card:active { transform: scale(0.98); }
|
||||
.timer-display { font-size: 3rem; font-weight: bold; text-align: center; margin: 1rem 0; }
|
||||
.active-section { border: 2px solid #27ae60; background: #ecfdf5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="brand">OpenTimeTracker</div>
|
||||
<div>
|
||||
{% if session.user_id %}
|
||||
<a href="{{ url_for('index') }}">Tracker</a>
|
||||
<a href="{{ url_for('logbook') }}">Logbook</a>
|
||||
<a href="{{ url_for('logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}">Login</a>
|
||||
<a href="{{ url_for('register') }}">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div style="background: #fff3cd; padding: 10px; border-radius: 5px; margin-bottom: 15px;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
37
templates/logbook.html
Normal file
37
templates/logbook.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<h2>Logbook</h2>
|
||||
{% if not log %}
|
||||
<p>No activities recorded yet.</p>
|
||||
{% else %}
|
||||
{% for entry in log %}
|
||||
<div class="card" style="border-left: 5px solid {{ entry.activity.color }};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h3 style="margin: 0;">{{ entry.activity.name }}</h3>
|
||||
<small style="color: #666;">
|
||||
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} - {{ entry.end_time.strftime('%H:%M') }}
|
||||
</small>
|
||||
{% if entry.note %}
|
||||
<p style="margin: 5px 0; font-style: italic;">"{{ entry.note }}"</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-weight: bold; font-size: 1.2rem;">
|
||||
{{ entry.duration_str }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if entry.tasks %}
|
||||
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee;">
|
||||
<strong>Tasks Completed:</strong>
|
||||
<ul style="margin: 5px 0; padding-left: 20px;">
|
||||
{% for task in entry.tasks %}
|
||||
<li>{{ task.task_name }} <small>({{ task.completed_at.strftime('%H:%M') }})</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
13
templates/login.html
Normal file
13
templates/login.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 400px; margin: auto;">
|
||||
<h2>Login</h2>
|
||||
<form method="POST">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required>
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit" class="btn">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
templates/register.html
Normal file
13
templates/register.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 400px; margin: auto;">
|
||||
<h2>Register</h2>
|
||||
<form method="POST">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required>
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user