Files
Raccoon-TimeKeeper/app.py
2025-12-10 10:04:09 +11:00

446 lines
14 KiB
Python

"""
Raccoon Timekeeper - Self-hosted Time Tracking Application
"""
import os
import re
import uuid
from datetime import datetime, timedelta
from functools import wraps
from flask import Flask, render_template, request, jsonify, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'raccoon-secret-key-change-me')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///timekeeper.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# =============================================================================
# DATABASE MODELS
# =============================================================================
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
name = db.Column(db.String(255), nullable=False)
active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
entries = db.relationship('TimeEntry', backref='task', lazy=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
class TimeEntry(db.Model):
__tablename__ = 'time_entries'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
task_id = db.Column(db.String(36), db.ForeignKey('tasks.id'), nullable=False)
task_name = db.Column(db.String(255), nullable=False)
date = db.Column(db.Date, nullable=False)
hours = db.Column(db.Integer, default=0)
minutes = db.Column(db.Integer, default=0)
total_minutes = db.Column(db.Integer, default=0)
notes = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'task_id': self.task_id,
'task_name': self.task_name,
'date': self.date.isoformat() if self.date else None,
'hours': self.hours,
'minutes': self.minutes,
'total_minutes': self.total_minutes,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# =============================================================================
# UTILITY FUNCTIONS
# =============================================================================
def parse_time_input(time_str):
"""
Parse flexible time input formats:
- "1:30" or "1:45" (hours:minutes)
- "1.5" or "1.75" (decimal hours)
- "90m" or "90min" (minutes)
- "1h" or "1hr" (hours)
- "1h 30m" or "1h30m" (combined)
"""
if not time_str:
return None
time_str = str(time_str).strip().lower()
# Format: "1:30" (hours:minutes)
match = re.match(r'^(\d+):(\d+)$', time_str)
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
return {
'hours': hours,
'minutes': minutes,
'total_minutes': hours * 60 + minutes
}
# Format: "1h 30m" or "1h30m" or "1hr 30min"
match = re.match(r'^(\d+)\s*h(?:r|our)?s?\s*(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
return {
'hours': hours,
'minutes': minutes,
'total_minutes': hours * 60 + minutes
}
# Format: "90m" or "90min" or "90 minutes"
match = re.match(r'^(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
if match:
total_minutes = int(match.group(1))
return {
'hours': total_minutes // 60,
'minutes': total_minutes % 60,
'total_minutes': total_minutes
}
# Format: "1h" or "1hr" or "1 hour"
match = re.match(r'^(\d+)\s*h(?:r|our)?s?$', time_str)
if match:
hours = int(match.group(1))
return {
'hours': hours,
'minutes': 0,
'total_minutes': hours * 60
}
# Format: "1.5" or "1.75" (decimal hours)
match = re.match(r'^(\d+(?:\.\d+)?)$', time_str)
if match:
decimal_hours = float(match.group(1))
total_minutes = int(round(decimal_hours * 60))
return {
'hours': total_minutes // 60,
'minutes': total_minutes % 60,
'total_minutes': total_minutes
}
return None
def get_monday(date):
"""Get the Monday of the week for a given date."""
days_since_monday = date.weekday()
return date - timedelta(days=days_since_monday)
def format_minutes(total_minutes):
"""Format minutes as H:MM string."""
hours = total_minutes // 60
minutes = total_minutes % 60
return f"{hours}:{minutes:02d}"
# =============================================================================
# ROUTES - PAGES
# =============================================================================
@app.route('/')
def index():
"""Main time logging page."""
return render_template('index.html')
@app.route('/settings')
def settings():
"""Settings and task management page."""
return render_template('settings.html')
# =============================================================================
# API ROUTES - TASKS
# =============================================================================
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
"""Get all tasks."""
include_inactive = request.args.get('include_inactive', 'false').lower() == 'true'
query = Task.query
if not include_inactive:
query = query.filter_by(active=True)
tasks = query.order_by(Task.name).all()
return jsonify([task.to_dict() for task in tasks])
@app.route('/api/tasks', methods=['POST'])
def add_task():
"""Add a new task."""
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({'success': False, 'message': 'Task name cannot be empty'}), 400
# Check for duplicate
existing = Task.query.filter(db.func.lower(Task.name) == name.lower()).first()
if existing:
return jsonify({'success': False, 'message': 'Task already exists'}), 400
task = Task(name=name)
db.session.add(task)
db.session.commit()
return jsonify({
'success': True,
'message': 'Task added successfully',
'task': task.to_dict()
})
@app.route('/api/tasks/<task_id>', methods=['PUT'])
def update_task(task_id):
"""Update a task."""
task = Task.query.get(task_id)
if not task:
return jsonify({'success': False, 'message': 'Task not found'}), 404
data = request.get_json()
if 'name' in data:
task.name = data['name'].strip()
if 'active' in data:
task.active = data['active']
db.session.commit()
return jsonify({
'success': True,
'message': 'Task updated successfully',
'task': task.to_dict()
})
@app.route('/api/tasks/<task_id>', methods=['DELETE'])
def delete_task(task_id):
"""Delete a task."""
task = Task.query.get(task_id)
if not task:
return jsonify({'success': False, 'message': 'Task not found'}), 404
db.session.delete(task)
db.session.commit()
return jsonify({'success': True, 'message': 'Task deleted successfully'})
# =============================================================================
# API ROUTES - TIME ENTRIES
# =============================================================================
@app.route('/api/entries', methods=['GET'])
def get_entries():
"""Get time entries with optional date filtering."""
entries = TimeEntry.query.order_by(TimeEntry.date.desc(), TimeEntry.created_at.desc()).all()
return jsonify([entry.to_dict() for entry in entries])
@app.route('/api/entries', methods=['POST'])
def add_entry():
"""Add a new time entry."""
data = request.get_json()
task_id = data.get('task_id')
task_name = data.get('task_name')
date_str = data.get('date')
time_str = data.get('time')
notes = data.get('notes', '')
if not task_id or not date_str or not time_str:
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
# Parse time
parsed_time = parse_time_input(time_str)
if not parsed_time:
return jsonify({
'success': False,
'message': 'Invalid time format. Use formats like 1:30, 1.5, 90m, or 1h 30m'
}), 400
# Parse date
try:
entry_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid date format'}), 400
entry = TimeEntry(
task_id=task_id,
task_name=task_name,
date=entry_date,
hours=parsed_time['hours'],
minutes=parsed_time['minutes'],
total_minutes=parsed_time['total_minutes'],
notes=notes
)
db.session.add(entry)
db.session.commit()
return jsonify({
'success': True,
'message': 'Time entry added successfully',
'entry': entry.to_dict()
})
@app.route('/api/entries/<entry_id>', methods=['PUT'])
def update_entry(entry_id):
"""Update a time entry."""
entry = TimeEntry.query.get(entry_id)
if not entry:
return jsonify({'success': False, 'message': 'Entry not found'}), 404
data = request.get_json()
if 'task_id' in data:
entry.task_id = data['task_id']
if 'task_name' in data:
entry.task_name = data['task_name']
if 'date' in data:
entry.date = datetime.strptime(data['date'], '%Y-%m-%d').date()
if 'time' in data:
parsed_time = parse_time_input(data['time'])
if parsed_time:
entry.hours = parsed_time['hours']
entry.minutes = parsed_time['minutes']
entry.total_minutes = parsed_time['total_minutes']
if 'notes' in data:
entry.notes = data['notes']
db.session.commit()
return jsonify({
'success': True,
'message': 'Entry updated successfully',
'entry': entry.to_dict()
})
@app.route('/api/entries/<entry_id>', methods=['DELETE'])
def delete_entry(entry_id):
"""Delete a time entry."""
entry = TimeEntry.query.get(entry_id)
if not entry:
return jsonify({'success': False, 'message': 'Entry not found'}), 404
db.session.delete(entry)
db.session.commit()
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
# =============================================================================
# API ROUTES - WEEKLY SUMMARY
# =============================================================================
@app.route('/api/weekly-summary')
def get_weekly_summary():
"""Get weekly summary for a given week."""
week_start_str = request.args.get('week_start')
if week_start_str:
try:
week_start = datetime.strptime(week_start_str, '%Y-%m-%d').date()
except ValueError:
week_start = get_monday(datetime.now().date())
else:
week_start = get_monday(datetime.now().date())
# Ensure we start on Monday
week_start = get_monday(week_start)
week_end = week_start + timedelta(days=6)
# Get entries for this week
entries = TimeEntry.query.filter(
TimeEntry.date >= week_start,
TimeEntry.date <= week_end
).all()
# Group by task
task_summary = {}
daily_totals = {i: 0 for i in range(7)} # Mon=0, Sun=6
for entry in entries:
task_name = entry.task_name
day_index = entry.date.weekday() # Mon=0, Sun=6
if task_name not in task_summary:
task_summary[task_name] = {
'task_id': entry.task_id,
'task_name': task_name,
'days': {i: 0 for i in range(7)},
'total_minutes': 0
}
task_summary[task_name]['days'][day_index] += entry.total_minutes
task_summary[task_name]['total_minutes'] += entry.total_minutes
daily_totals[day_index] += entry.total_minutes
tasks = list(task_summary.values())
grand_total = sum(daily_totals.values())
return jsonify({
'week_start': week_start.isoformat(),
'week_end': week_end.isoformat(),
'tasks': tasks,
'daily_totals': daily_totals,
'grand_total': grand_total,
'entries': [e.to_dict() for e in entries]
})
# =============================================================================
# DATABASE INITIALIZATION
# =============================================================================
def init_db():
"""Initialize the database."""
with app.app_context():
db.create_all()
# Add some default tasks if none exist
if Task.query.count() == 0:
default_tasks = ['General Work', 'Meetings', 'Admin', 'Training']
for task_name in default_tasks:
task = Task(name=task_name)
db.session.add(task)
db.session.commit()
print("Default tasks created.")
# =============================================================================
# MAIN
# =============================================================================
if __name__ == '__main__':
init_db()
app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true', host='0.0.0.0', port=5000)