From 8601579532fb1a76bb3b97610fabc2729c32bb6e Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 10 Dec 2025 10:04:09 +1100 Subject: [PATCH] Add main Flask application --- app.py | 445 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..007fa38 --- /dev/null +++ b/app.py @@ -0,0 +1,445 @@ +""" +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)