From e9dfcdbca1d2f7f8ca61ab096cb6260f4aa5ae27 Mon Sep 17 00:00:00 2001 From: Feiko Wielsma Date: Wed, 25 Mar 2026 12:50:55 +0100 Subject: [PATCH] Add Docker configuration and ignore files --- .dockerignore | 7 ++ .gitignore | 4 + Dockerfile | 12 +++ app.py | 255 ++++++++++++++++++++++++++++++++++++++++----- database.py | 54 ++++++++-- docker-compose.yml | 16 +++ 6 files changed, 318 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6428b06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.venv +__pycache__ +work_log.db +.dockerignore +Dockerfile +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f95cc32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +work_log.db +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27f8a81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8501 + +CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] diff --git a/app.py b/app.py index bc99adb..baa9c6b 100644 --- a/app.py +++ b/app.py @@ -17,9 +17,28 @@ st.title("📅 Work Hours Tracker") col1, col2 = st.columns([2, 1]) +# --- Sidebar Controls --- +with st.sidebar: + st.header("Settings") + + # Global Config for Stats + DAILY_HOURS = st.slider("Work Hours per Day (for Stats)", min_value=6.0, max_value=8.0, value=8.0, step=0.5) + st.divider() + + mode = st.radio("Mode", ["Mark Work Day", "Mark Holiday", "Remove Day"]) + + course_name = "" + if mode == "Mark Work Day": + course_name = st.text_input("Course Name", placeholder="e.g. Python 101") + + st.info(f"Current Mode: **{mode}**") + if mode == "Mark Work Day": + if course_name: + st.caption(f"Course: **{course_name}**") + # --- Calendar Section --- with col1: - st.subheader("Select Work Days") + st.subheader("Select Days") # Fetch existing data to populate calendar df = database.get_all_days() @@ -27,12 +46,36 @@ with col1: # Format events for streamlit-calendar events = [] if not df.empty: - for date_str in df['date']: + for _, row in df.iterrows(): + date_str = row['date'] + day_type = row.get('type', 'work') + c_name = row.get('course_name', '') + # Get hours for tooltip/display if needed, though simple title is usually best for calendar + + title = "Worked" + color = "#4CAF50" # Green + + if day_type == 'holiday': + title = "Holiday" + color = "#FF9800" # Orange + elif c_name: + title = c_name + + if row.get('travel_costs'): + title = f"🚗 {title}" + events.append({ - "title": "Worked", + "title": title, "start": date_str, "allDay": True, - "color": "#4CAF50" # Green for worked days + "color": color, + # Store extra props if needed + "extendedProps": { + "type": day_type, + "course": c_name, + "hours": row.get('hours', 8.0), + "travel_costs": bool(row.get('travel_costs')) + } }) # Calendar options @@ -44,41 +87,205 @@ with col1: }, "initialView": "dayGridMonth", "selectable": True, + "timeZone": "UTC", # Force UTC to prevent off-by-one errors from local timezone } # Render Calendar cal = calendar(events=events, options=calendar_options, key="calendar") # Handle Click Events + + # 1. Date Click (Empty cell) if cal.get("callback") == "dateClick": - clicked_date = cal["dateClick"]["dateStr"] - database.toggle_day(clicked_date) - st.rerun() # Refresh to update calendar and stats + # Check if we already processed this specific event to avoid infinite loops/double toggles + # The component might return the same event object on rerun until interaction changes + current_event_str = str(cal) + if st.session_state.get("last_processed_event") != current_event_str: + st.session_state["last_processed_event"] = current_event_str + + # Parse date from ISO string (e.g. "2023-01-01T00:00:00.000Z") + if "date" in cal["dateClick"]: + clicked_date = cal["dateClick"]["date"] + if "T" in clicked_date: + clicked_date = clicked_date.split("T")[0] + + # Logic based on Mode + if mode == "Remove Day": + database.remove_day(clicked_date) + elif mode == "Mark Holiday": + # Holidays are standard 8h for now, or could use the slider too if user wanted "half holiday" + database.update_day(clicked_date, day_type="holiday", course_name="Holiday", hours=DAILY_HOURS) + else: # Mark Work Day + database.update_day(clicked_date, day_type="work", course_name=course_name, hours=DAILY_HOURS, travel_costs=True) + + st.rerun() # Refresh to update calendar and stats + + # 2. Event Click (Existing item) + elif cal.get("callback") == "eventClick": + current_event_str = str(cal) + if st.session_state.get("last_processed_event") != current_event_str: + st.session_state["last_processed_event"] = current_event_str + + event_data = cal["eventClick"]["event"] + extended_props = event_data.get("extendedProps", {}) + + # Only toggle travel costs for "work" days + if extended_props.get("type") == "work": + clicked_date = event_data["start"] + if "T" in clicked_date: + clicked_date = clicked_date.split("T")[0] + + # Invert current travel cost status + current_travel_costs = extended_props.get("travel_costs", False) + new_travel_costs = not current_travel_costs + + # Update database + database.update_day( + clicked_date, + day_type="work", + course_name=extended_props.get("course"), + hours=extended_props.get("hours", DAILY_HOURS), + travel_costs=new_travel_costs + ) + + st.rerun() # --- Statistics Section --- with col2: st.subheader("Statistics") - if not df.empty: - # Convert to datetime - df['date'] = pd.to_datetime(df['date']) - df = df.sort_values('date') + + # Configuration: Start Date + START_DATE = pd.Timestamp("2025-09-15") + + # Convert to datetime BEFORE filtering + df['date'] = pd.to_datetime(df['date']) + + # Filter for data >= Start Date + df = df[df['date'] >= START_DATE] + df = df.sort_values('date') + + # Determine "effective" days and hours + # User requested Holidays count as full days for simplicity + def get_effective_days(row): + return 1.0 - # Calculate weekly stats - # Resample to weekly frequency (W-MON) - weekly_counts = df.resample('W-MON', on='date').size() - - if not weekly_counts.empty: - avg_days_per_week = weekly_counts.mean() - st.metric("Avg Days / Week (Total)", f"{avg_days_per_week:.2f}") + def get_effective_hours(row): + # Use the global setting, ignoring DB value if necessary + # (Or could prioritize DB if users want override? User asked to recalculate based on entered days) + # "I don't want it to change the actual hours worked. I just want to... recalculate the averages" + # This implies ignoring stored hour values and reapplying the global constant. + return DAILY_HOURS - # Plotting - st.line_chart(weekly_counts, use_container_width=True) + # Ensure 'type' exists + if 'type' not in df.columns: + df['type'] = 'work' + + df['effective_days'] = df.apply(get_effective_days, axis=1) + df['effective_hours'] = df.apply(get_effective_hours, axis=1) + + # --- Calculate Weekly Stats --- + # Resample to weekly frequency (W-MON) + weekly_data = df.resample('W-MON', on='date')[['effective_days', 'effective_hours']].sum() + + # Reindex to ensure we cover from START_DATE to Today (or Max Date) + # This ensures weeks with 0 work are counted in the average + max_date = max(df['date'].max(), pd.Timestamp.now()) + full_weeks = pd.date_range(start=START_DATE, end=max_date, freq='W-MON') + weekly_data = weekly_data.reindex(full_weeks, fill_value=0) + + weekly_data.columns = ['Days', 'Hours'] + + if not weekly_data.empty: + avg_days = weekly_data['Days'].mean() + avg_hours = weekly_data['Hours'].mean() + + m1, m2 = st.columns(2) + m1.metric("Avg Days / Week", f"{avg_days:.2f}") + m2.metric("Avg Hours / Week", f"{avg_hours:.1f}") + + # Plotting + st.caption("Days per Week (Effective)") + # Calculate running average (expanding mean) to show trend over time + weekly_data['Running Average'] = weekly_data['Days'].expanding().mean() + st.line_chart(weekly_data[['Days', 'Running Average']], use_container_width=True) + + # --- Monthly Breakdown --- + monthly_data = df.resample('M', on='date')[['effective_days', 'effective_hours']].sum() + + # Reindex for full months too + # (Use 'MS' for Month Start to align better, or 'M' for End) + # But reindexing with 'M' is tricky if today is mid-month. + # Let's stick to simple reindexing of what we have + fillna(0) for displayed months + # Actually, standard resample handles missing months WITHIN the range. + # We just need to make sure the range starts at START_DATE. + full_months = pd.date_range(start=START_DATE, end=max_date, freq='M') + monthly_data = monthly_data.reindex(full_months, fill_value=0) + + monthly_data.columns = ['Days Worked', 'Hours Worked'] + + # Calculate Avg Hours/Week for the month + # (Total Hours / (Days in Month / 7)) + monthly_data['Avg Hours/Week'] = monthly_data['Hours Worked'] / (monthly_data.index.days_in_month / 7) + + # Format index to be more readable (e.g. "January 2023") + monthly_data.index = monthly_data.index.strftime('%B %Y') + + st.write("### Monthly Breakdown") + st.dataframe(monthly_data.style.format({ + "Avg Hours/Week": "{:.1f}", + "Hours Worked": "{:.1f}", + "Days Worked": "{:.1f}" + })) + + st.divider() + st.write("### 🚗 Export Travel Costs") + + # Select month for export + available_months = df.resample('M', on='date').size().index.strftime('%B %Y').tolist() + if available_months: + selected_export_month = st.selectbox("Select Month to Export", reversed(available_months)) - # Monthly breakdown - monthly_counts = df.resample('M', on='date').size() - st.write("### Monthly Breakdown") - st.dataframe(monthly_counts.rename("Days Worked")) + if st.button("Generate Export Text"): + # Filter data for selected month + month_dt = pd.to_datetime(selected_export_month, format='%B %Y') + month_df = df[(df['date'].dt.month == month_dt.month) & (df['date'].dt.year == month_dt.year)] + + if not month_df.empty: + export_lines = ["Hierbij ook nog even het lijstje aan werkdagen met reiskosten:"] + + # Group by course + courses = month_df['course_name'].unique() + total_travel_days = 0 + + for course in courses: + if not course or course == 'Holiday': + continue + + course_df = month_df[month_df['course_name'] == course].sort_values('date') + travel_days = course_df[course_df['travel_costs'] == 1] + no_travel_days = course_df[course_df['travel_costs'] == 0] + + if not travel_days.empty: + dates_str = ", ".join(travel_days['date'].dt.strftime('%d %b')) + line = f"{course}: {dates_str}" + + # Add "skipped" days if they exist within the range + if not no_travel_days.empty: + skip_dates = ", ".join(no_travel_days['date'].dt.strftime('%d %b')) + line += f" (dus niet {skip_dates} want toen was het Online)" + + export_lines.append(line) + total_travel_days += len(travel_days) + + export_lines.append("") + export_lines.append(f"Totaal lijkt het dus {total_travel_days} dagen geweest te zijn. Zoals eerder bekeken leek het zo'n 30km per dag.") + + export_text = "\n".join(export_lines) + st.code(export_text, language="text") + st.success("You can copy the text above!") + else: + st.warning("No data found for this month.") else: st.info("No work days logged yet. Click dates on the calendar.") diff --git a/database.py b/database.py index 63fbcc4..47dd53d 100644 --- a/database.py +++ b/database.py @@ -1,8 +1,14 @@ import sqlite3 import datetime import pandas as pd +import os -DB_FILE = "work_log.db" +DB_FILE = os.environ.get("DB_FILE", "work_log.db") + +# Ensure the directory for the database file exists +db_dir = os.path.dirname(DB_FILE) +if db_dir: + os.makedirs(db_dir, exist_ok=True) def get_connection(): conn = sqlite3.connect(DB_FILE) @@ -11,33 +17,69 @@ def get_connection(): def init_db(): conn = get_connection() c = conn.cursor() + + # Create table with new schema c.execute(''' CREATE TABLE IF NOT EXISTS work_days ( date TEXT PRIMARY KEY, hours REAL DEFAULT 8.0, - note TEXT + note TEXT, + type TEXT DEFAULT 'work', + course_name TEXT, + travel_costs INTEGER DEFAULT 0 ) ''') + + # Migration: Check if columns exist, if not add them + c.execute("PRAGMA table_info(work_days)") + columns = [info[1] for info in c.fetchall()] + + if 'type' not in columns: + c.execute("ALTER TABLE work_days ADD COLUMN type TEXT DEFAULT 'work'") + if 'course_name' not in columns: + c.execute("ALTER TABLE work_days ADD COLUMN course_name TEXT") + if 'travel_costs' not in columns: + c.execute("ALTER TABLE work_days ADD COLUMN travel_costs INTEGER DEFAULT 0") + conn.commit() conn.close() -def toggle_day(date_str): - """Toggles a work day: adds if not exists, removes if exists.""" +def update_day(date_str, day_type="work", course_name=None, hours=8.0, travel_costs=False): + """Updates or inserts a day. If course_name is None, it won't overwrite existing unless it's a new entry.""" conn = get_connection() c = conn.cursor() + travel_val = 1 if travel_costs else 0 + # Check if exists c.execute("SELECT date FROM work_days WHERE date = ?", (date_str,)) data = c.fetchone() if data: - c.execute("DELETE FROM work_days WHERE date = ?", (date_str,)) + # Update existing + c.execute(""" + UPDATE work_days + SET type = ?, course_name = ?, hours = ?, travel_costs = ? + WHERE date = ? + """, (day_type, course_name, hours, travel_val, date_str)) else: - c.execute("INSERT INTO work_days (date, hours) VALUES (?, ?)", (date_str, 8.0)) + # Insert new + c.execute(""" + INSERT INTO work_days (date, hours, type, course_name, travel_costs) + VALUES (?, ?, ?, ?, ?) + """, (date_str, hours, day_type, course_name, travel_val)) conn.commit() conn.close() +def remove_day(date_str): + """Removes a day from the database.""" + conn = get_connection() + c = conn.cursor() + c.execute("DELETE FROM work_days WHERE date = ?", (date_str,)) + conn.commit() + conn.close() + def get_all_days(): """Returns a list of all work days.""" conn = get_connection() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..743542b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + tracker: + build: . + container_name: work_tracker + ports: + - "8501:8501" + environment: + - DB_FILE=/data/work_log.db + volumes: + - tracker_data:/data + restart: unless-stopped + +volumes: + tracker_data: