""" 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/', 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/', 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/', 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/', 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)