630 lines
21 KiB
Python
630 lines
21 KiB
Python
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, timedelta
|
|
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 = []
|
|
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')
|
|
|
|
# 1. Tasks generated specifically for this session (time_entry_id linked)
|
|
# 2. Open tasks linked to this activity type (contextual todos)
|
|
active_tasks = list(db.tasks.find({
|
|
'user_id': user_id,
|
|
'status': 'open',
|
|
'$or': [
|
|
{'time_entry_id': current_entry['_id']},
|
|
{'activity_id': current_entry['activity_id'], 'is_template': False, 'time_entry_id': None}
|
|
]
|
|
}))
|
|
|
|
return render_template('dashboard.html',
|
|
activities=activities,
|
|
current_entry=current_entry,
|
|
tasks=active_tasks)
|
|
|
|
@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')
|
|
user_id = db.users.insert_one({'username': username, 'password': hashed_password}).inserted_id
|
|
|
|
# Auto login
|
|
session['user_id'] = str(user_id)
|
|
flash('Account created! You are now logged in.')
|
|
return redirect(url_for('index'))
|
|
|
|
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,
|
|
'subcategories': []
|
|
}).inserted_id
|
|
|
|
# Add optional default tasks (Revised from list input)
|
|
tasks_str = request.form.get('tasks_list_data', '')
|
|
if tasks_str:
|
|
tasks = [t.strip() for t in tasks_str.split(',') if t.strip()]
|
|
for t in tasks:
|
|
db.tasks.insert_one({
|
|
'user_id': get_user_id(),
|
|
'name': t,
|
|
'activity_id': ObjectId(activity_id),
|
|
'is_template': True,
|
|
'status': 'open',
|
|
'time_entry_id': None,
|
|
'created_at': datetime.now(),
|
|
'comments': []
|
|
})
|
|
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/edit_activity/<activity_id>', methods=['GET', 'POST'])
|
|
def edit_activity(activity_id):
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
user_id = get_user_id()
|
|
|
|
if request.method == 'POST':
|
|
name = request.form['name']
|
|
color = request.form.get('color', '#3498db')
|
|
subcats_str = request.form.get('subcategories', '')
|
|
|
|
# Parse comma-separated string into list
|
|
subcategories = [s.strip() for s in subcats_str.split(',') if s.strip()]
|
|
|
|
db.activities.update_one(
|
|
{'_id': ObjectId(activity_id), 'user_id': user_id},
|
|
{'$set': {'name': name, 'color': color, 'subcategories': subcategories}}
|
|
)
|
|
return redirect(url_for('index'))
|
|
|
|
activity = db.activities.find_one({'_id': ObjectId(activity_id), 'user_id': user_id})
|
|
if not activity: return "Activity not found", 404
|
|
|
|
return render_template('edit_activity.html', activity=activity)
|
|
|
|
@app.route('/start_timer_bg/<activity_id>', methods=['POST'])
|
|
def start_timer_bg(activity_id):
|
|
if not is_logged_in(): return jsonify({'error': 'auth'}), 401
|
|
user_id = get_user_id()
|
|
|
|
# Stop ANY running timer
|
|
db.time_entries.update_many(
|
|
{'user_id': user_id, 'end_time': None},
|
|
{'$set': {'end_time': datetime.now()}}
|
|
)
|
|
|
|
# Start new
|
|
start_time = datetime.now()
|
|
new_entry_id = db.time_entries.insert_one({
|
|
'user_id': user_id,
|
|
'activity_id': ObjectId(activity_id),
|
|
'start_time': start_time,
|
|
'end_time': None,
|
|
'note': '',
|
|
'subcategory': ''
|
|
}).inserted_id
|
|
|
|
# Auto-add templates
|
|
templates = db.tasks.find({
|
|
'user_id': user_id,
|
|
'activity_id': ObjectId(activity_id),
|
|
'is_template': True
|
|
})
|
|
|
|
for t in templates:
|
|
db.tasks.insert_one({
|
|
'user_id': user_id,
|
|
'name': t['name'],
|
|
'activity_id': ObjectId(activity_id),
|
|
'time_entry_id': new_entry_id,
|
|
'status': 'open',
|
|
'is_template': False,
|
|
'source': 'auto', # Mark as auto-generated
|
|
'created_at': datetime.now(),
|
|
'comments': []
|
|
})
|
|
|
|
activity = db.activities.find_one({'_id': ObjectId(activity_id)})
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'start_time': start_time.isoformat(),
|
|
'activity_name': activity['name'],
|
|
'activity_color': activity.get('color', '#3498db')
|
|
})
|
|
|
|
@app.route('/update_active_entry', methods=['POST'])
|
|
def update_active_entry():
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
user_id = get_user_id()
|
|
|
|
note = request.form.get('note', '')
|
|
subcategory = request.form.get('subcategory', '')
|
|
|
|
db.time_entries.update_one(
|
|
{'user_id': user_id, 'end_time': None},
|
|
{'$set': {'note': note, 'subcategory': subcategory}}
|
|
)
|
|
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', '')
|
|
subcategory = request.form.get('subcategory', '') # Get subcategory
|
|
|
|
# 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
|
|
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,
|
|
'subcategory': subcategory # Store it
|
|
})
|
|
|
|
# Check for Task Templates and auto-add them
|
|
templates = db.tasks.find({
|
|
'user_id': user_id,
|
|
'activity_id': ObjectId(activity_id),
|
|
'is_template': True
|
|
})
|
|
|
|
for t in templates:
|
|
db.tasks.insert_one({
|
|
'user_id': user_id,
|
|
'name': t['name'],
|
|
'activity_id': ObjectId(activity_id),
|
|
'time_entry_id': new_entry_id,
|
|
'status': 'open',
|
|
'is_template': False,
|
|
'source': 'auto', # Mark as auto-generated
|
|
'created_at': datetime.now(),
|
|
'comments': []
|
|
})
|
|
|
|
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
|
|
|
|
user_id = get_user_id()
|
|
task_id = request.form['task_id']
|
|
is_checked = request.form['is_checked'] == 'true'
|
|
|
|
status = 'completed' if is_checked else 'open'
|
|
completed_at = datetime.now() if is_checked else None
|
|
|
|
update_doc = {
|
|
'status': status,
|
|
'completed_at': completed_at
|
|
}
|
|
|
|
# If the task is being completed, try to link it to the currently active timer
|
|
if is_checked:
|
|
current_entry = db.time_entries.find_one({
|
|
'user_id': user_id,
|
|
'end_time': None
|
|
})
|
|
if current_entry:
|
|
update_doc['time_entry_id'] = current_entry['_id']
|
|
|
|
db.tasks.update_one(
|
|
{'_id': ObjectId(task_id), 'user_id': user_id},
|
|
{'$set': update_doc}
|
|
)
|
|
|
|
return jsonify({'status': 'success'})
|
|
|
|
# --- New Task Management Routes ---
|
|
|
|
@app.route('/tasks')
|
|
def tasks():
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
user_id = get_user_id()
|
|
|
|
# Fetch all activities for the dropdown
|
|
activities = list(db.activities.find({'user_id': user_id}))
|
|
|
|
# Categorize tasks
|
|
# Shows Manual Tasks (Open OR Completed). Hides "source: auto" (Session checklists)
|
|
tasks_list = list(db.tasks.find({
|
|
'user_id': user_id,
|
|
'is_template': False,
|
|
'source': {'$ne': 'auto'} # Show everything except explicitly auto-generated tasks
|
|
}).sort('due_date', 1))
|
|
|
|
# 2. Templates
|
|
templates = list(db.tasks.find({'user_id': user_id, 'is_template': True}))
|
|
|
|
# Enhance tasks with activity names
|
|
act_map = {str(a['_id']): a for a in activities}
|
|
for t in tasks_list + templates:
|
|
if t.get('activity_id'):
|
|
aid = str(t['activity_id'])
|
|
if aid in act_map:
|
|
t['activity_name'] = act_map[aid]['name']
|
|
t['activity_color'] = act_map[aid].get('color', '#ccc')
|
|
|
|
return render_template('tasks.html', tasks=tasks_list, templates=templates, activities=activities)
|
|
|
|
@app.route('/create_task', methods=['POST'])
|
|
def create_task():
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
|
|
name = request.form['name']
|
|
activity_id = request.form.get('activity_id')
|
|
due_date_str = request.form.get('due_date')
|
|
is_template = 'is_template' in request.form
|
|
|
|
task_doc = {
|
|
'user_id': get_user_id(),
|
|
'name': name,
|
|
'status': 'open',
|
|
'is_template': is_template,
|
|
'source': 'manual', # Mark as manually created
|
|
'created_at': datetime.now(),
|
|
'comments': []
|
|
}
|
|
|
|
if activity_id:
|
|
task_doc['activity_id'] = ObjectId(activity_id)
|
|
|
|
if due_date_str:
|
|
task_doc['due_date'] = datetime.strptime(due_date_str, '%Y-%m-%dT%H:%M')
|
|
|
|
# Standard task or template doesn't belong to a specific time entry yet
|
|
task_doc['time_entry_id'] = None
|
|
|
|
db.tasks.insert_one(task_doc)
|
|
return redirect(url_for('tasks'))
|
|
|
|
@app.route('/task/<task_id>', methods=['GET', 'POST'])
|
|
def task_detail(task_id):
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
|
|
task = db.tasks.find_one({'_id': ObjectId(task_id), 'user_id': get_user_id()})
|
|
if not task: return "Task not found", 404
|
|
|
|
if request.method == 'POST':
|
|
comment = request.form['comment']
|
|
if comment:
|
|
db.tasks.update_one(
|
|
{'_id': ObjectId(task_id)},
|
|
{'$push': {'comments': {
|
|
'text': comment,
|
|
'created_at': datetime.now(),
|
|
'user_id': get_user_id() # In case we add teams later
|
|
}}}
|
|
)
|
|
return redirect(url_for('task_detail', task_id=task_id))
|
|
|
|
return render_template('task_detail.html', task=task)
|
|
|
|
@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'},
|
|
# Join with tasks collection instead of completed_tasks
|
|
{'$lookup': {
|
|
'from': 'tasks',
|
|
'localField': '_id',
|
|
'foreignField': 'time_entry_id',
|
|
'as': 'tasks'
|
|
}},
|
|
]
|
|
|
|
log = list(db.time_entries.aggregate(pipeline))
|
|
|
|
# Basic formatting
|
|
for entry in log:
|
|
# Filter tasks to only completed ones for display cleanly in list view
|
|
entry['tasks'] = [t for t in entry['tasks'] if t['status'] == 'completed']
|
|
duration = entry['end_time'] - entry['start_time']
|
|
entry['duration_str'] = str(duration).split('.')[0] # HH:MM:SS
|
|
|
|
return render_template('logbook.html', log=log)
|
|
|
|
@app.route('/logbook/<entry_id>', methods=['GET', 'POST'])
|
|
def log_entry_detail(entry_id):
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
|
|
# Handle Updates and Deletion
|
|
if request.method == 'POST':
|
|
action = request.form.get('action')
|
|
|
|
if action == 'delete':
|
|
db.time_entries.delete_one({'_id': ObjectId(entry_id), 'user_id': get_user_id()})
|
|
flash('Entry deleted')
|
|
return redirect(url_for('logbook'))
|
|
|
|
elif action == 'update':
|
|
new_note = request.form.get('note')
|
|
start_str = request.form.get('start_time')
|
|
end_str = request.form.get('end_time')
|
|
subcategory = request.form.get('subcategory', '')
|
|
|
|
update_fields = {
|
|
'note': new_note,
|
|
'subcategory': subcategory
|
|
}
|
|
|
|
try:
|
|
if start_str:
|
|
update_fields['start_time'] = datetime.strptime(start_str, '%Y-%m-%dT%H:%M')
|
|
if end_str:
|
|
update_fields['end_time'] = datetime.strptime(end_str, '%Y-%m-%dT%H:%M')
|
|
except ValueError:
|
|
flash('Invalid date format')
|
|
return redirect(url_for('log_entry_detail', entry_id=entry_id))
|
|
|
|
db.time_entries.update_one(
|
|
{'_id': ObjectId(entry_id), 'user_id': get_user_id()},
|
|
{'$set': update_fields}
|
|
)
|
|
flash('Entry updated')
|
|
return redirect(url_for('log_entry_detail', entry_id=entry_id))
|
|
|
|
# Fetch Entry with Activity info
|
|
pipeline = [
|
|
{'$match': {'_id': ObjectId(entry_id), 'user_id': get_user_id()}},
|
|
{'$lookup': {
|
|
'from': 'activities',
|
|
'localField': 'activity_id',
|
|
'foreignField': '_id',
|
|
'as': 'activity'
|
|
}},
|
|
{'$unwind': '$activity'}
|
|
]
|
|
|
|
results = list(db.time_entries.aggregate(pipeline))
|
|
if not results:
|
|
return "Entry not found", 404
|
|
|
|
entry = results[0]
|
|
|
|
# Calculate duration
|
|
if entry.get('end_time') and entry.get('start_time'):
|
|
duration = entry['end_time'] - entry['start_time']
|
|
entry['duration_str'] = str(duration).split('.')[0]
|
|
else:
|
|
entry['duration_str'] = "Running..."
|
|
|
|
# Fetch ALL Tasks linked to this entry (completed or not)
|
|
tasks = list(db.tasks.find({'time_entry_id': ObjectId(entry_id)}))
|
|
|
|
return render_template('logbook_detail.html', entry=entry, tasks=tasks)
|
|
|
|
@app.route('/goals', methods=['GET', 'POST'])
|
|
def goals():
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
user_id = get_user_id()
|
|
|
|
# Handle creating new goal
|
|
if request.method == 'POST':
|
|
name = request.form['name']
|
|
target_hours = float(request.form['target_hours'])
|
|
frequency = request.form['frequency'] # daily, weekly, monthly, yearly
|
|
activity_id = request.form.get('activity_id') # Required now
|
|
subcategory = request.form.get('subcategory', '')
|
|
|
|
if not activity_id:
|
|
flash("You must select an activity for this goal.")
|
|
return redirect(url_for('goals'))
|
|
|
|
db.goals.insert_one({
|
|
'user_id': user_id,
|
|
'name': name,
|
|
'target_hours': target_hours,
|
|
'frequency': frequency,
|
|
'activity_id': ObjectId(activity_id),
|
|
'subcategory': subcategory,
|
|
'created_at': datetime.now()
|
|
})
|
|
return redirect(url_for('goals'))
|
|
|
|
# Fetch goals and calculate progress
|
|
goals_list = list(db.goals.find({'user_id': user_id}))
|
|
activities = list(db.activities.find({'user_id': user_id}))
|
|
|
|
today = datetime.now()
|
|
start_of_today = today.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
# Determine date ranges
|
|
ranges = {
|
|
'daily': start_of_today,
|
|
'weekly': start_of_today - timedelta(days=today.weekday()), # Monday
|
|
'monthly': start_of_today.replace(day=1),
|
|
'yearly': start_of_today.replace(month=1, day=1)
|
|
}
|
|
|
|
for goal in goals_list:
|
|
start_date = ranges.get(goal['frequency'], start_of_today)
|
|
|
|
query = {
|
|
'user_id': user_id,
|
|
'start_time': {'$gte': start_date},
|
|
'end_time': {'$ne': None}
|
|
}
|
|
|
|
if goal.get('activity_id'):
|
|
query['activity_id'] = goal['activity_id']
|
|
|
|
if goal.get('subcategory'):
|
|
query['subcategory'] = goal['subcategory']
|
|
|
|
entries = list(db.time_entries.find(query))
|
|
|
|
total_seconds = sum([(e['end_time'] - e['start_time']).total_seconds() for e in entries])
|
|
current_hours = total_seconds / 3600
|
|
|
|
# Display formatting: Show minutes if < 1 hour, else show hours
|
|
if current_hours > 0 and current_hours < 1:
|
|
minutes = int(total_seconds / 60)
|
|
goal['display_progress'] = f"{minutes}m"
|
|
else:
|
|
goal['display_progress'] = f"{round(current_hours, 1)}h"
|
|
|
|
goal['current_hours'] = round(current_hours, 1) # Keep purely numeric for math/sorting if needed
|
|
goal['percent'] = min(100, int((current_hours / goal['target_hours']) * 100))
|
|
|
|
# Link activity name for display
|
|
if goal.get('activity_id'):
|
|
act = db.activities.find_one({'_id': goal['activity_id']})
|
|
goal['activity_name'] = act['name'] if act else 'Unknown'
|
|
goal['activity_color'] = act['color'] if act else '#ccc'
|
|
|
|
return render_template('goals.html', goals=goals_list, activities=activities)
|
|
|
|
@app.route('/edit_goal/<goal_id>', methods=['GET', 'POST'])
|
|
def edit_goal(goal_id):
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
user_id = get_user_id()
|
|
|
|
if request.method == 'POST':
|
|
name = request.form['name']
|
|
target_hours = float(request.form['target_hours'])
|
|
frequency = request.form['frequency']
|
|
activity_id = request.form.get('activity_id')
|
|
subcategory = request.form.get('subcategory', '')
|
|
|
|
db.goals.update_one(
|
|
{'_id': ObjectId(goal_id), 'user_id': user_id},
|
|
{'$set': {
|
|
'name': name,
|
|
'target_hours': target_hours,
|
|
'frequency': frequency,
|
|
'activity_id': ObjectId(activity_id),
|
|
'subcategory': subcategory
|
|
}}
|
|
)
|
|
return redirect(url_for('goals'))
|
|
|
|
goal = db.goals.find_one({'_id': ObjectId(goal_id), 'user_id': user_id})
|
|
if not goal: return "Goal not found", 404
|
|
|
|
activities = list(db.activities.find({'user_id': user_id}))
|
|
return render_template('edit_goal.html', goal=goal, activities=activities)
|
|
|
|
@app.route('/delete_goal/<goal_id>')
|
|
def delete_goal(goal_id):
|
|
if not is_logged_in(): return redirect(url_for('login'))
|
|
db.goals.delete_one({'_id': ObjectId(goal_id), 'user_id': get_user_id()})
|
|
return redirect(url_for('goals'))
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=True)
|