Add Docker configuration and ignore files
This commit is contained in:
parent
d976d12267
commit
e9dfcdbca1
6 changed files with 318 additions and 30 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
work_log.db
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
work_log.db
|
||||||
|
.env
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
253
app.py
253
app.py
|
|
@ -17,9 +17,28 @@ st.title("📅 Work Hours Tracker")
|
||||||
|
|
||||||
col1, col2 = st.columns([2, 1])
|
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 ---
|
# --- Calendar Section ---
|
||||||
with col1:
|
with col1:
|
||||||
st.subheader("Select Work Days")
|
st.subheader("Select Days")
|
||||||
|
|
||||||
# Fetch existing data to populate calendar
|
# Fetch existing data to populate calendar
|
||||||
df = database.get_all_days()
|
df = database.get_all_days()
|
||||||
|
|
@ -27,12 +46,36 @@ with col1:
|
||||||
# Format events for streamlit-calendar
|
# Format events for streamlit-calendar
|
||||||
events = []
|
events = []
|
||||||
if not df.empty:
|
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({
|
events.append({
|
||||||
"title": "Worked",
|
"title": title,
|
||||||
"start": date_str,
|
"start": date_str,
|
||||||
"allDay": True,
|
"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
|
# Calendar options
|
||||||
|
|
@ -44,41 +87,205 @@ with col1:
|
||||||
},
|
},
|
||||||
"initialView": "dayGridMonth",
|
"initialView": "dayGridMonth",
|
||||||
"selectable": True,
|
"selectable": True,
|
||||||
|
"timeZone": "UTC", # Force UTC to prevent off-by-one errors from local timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render Calendar
|
# Render Calendar
|
||||||
cal = calendar(events=events, options=calendar_options, key="calendar")
|
cal = calendar(events=events, options=calendar_options, key="calendar")
|
||||||
|
|
||||||
# Handle Click Events
|
# Handle Click Events
|
||||||
|
|
||||||
|
# 1. Date Click (Empty cell)
|
||||||
if cal.get("callback") == "dateClick":
|
if cal.get("callback") == "dateClick":
|
||||||
clicked_date = cal["dateClick"]["dateStr"]
|
# Check if we already processed this specific event to avoid infinite loops/double toggles
|
||||||
database.toggle_day(clicked_date)
|
# The component might return the same event object on rerun until interaction changes
|
||||||
st.rerun() # Refresh to update calendar and stats
|
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 ---
|
# --- Statistics Section ---
|
||||||
with col2:
|
with col2:
|
||||||
st.subheader("Statistics")
|
st.subheader("Statistics")
|
||||||
|
|
||||||
if not df.empty:
|
|
||||||
# Convert to datetime
|
|
||||||
df['date'] = pd.to_datetime(df['date'])
|
|
||||||
df = df.sort_values('date')
|
|
||||||
|
|
||||||
# Calculate weekly stats
|
# Configuration: Start Date
|
||||||
# Resample to weekly frequency (W-MON)
|
START_DATE = pd.Timestamp("2025-09-15")
|
||||||
weekly_counts = df.resample('W-MON', on='date').size()
|
|
||||||
|
|
||||||
if not weekly_counts.empty:
|
# Convert to datetime BEFORE filtering
|
||||||
avg_days_per_week = weekly_counts.mean()
|
df['date'] = pd.to_datetime(df['date'])
|
||||||
st.metric("Avg Days / Week (Total)", f"{avg_days_per_week:.2f}")
|
|
||||||
|
|
||||||
# Plotting
|
# Filter for data >= Start Date
|
||||||
st.line_chart(weekly_counts, use_container_width=True)
|
df = df[df['date'] >= START_DATE]
|
||||||
|
df = df.sort_values('date')
|
||||||
|
|
||||||
# Monthly breakdown
|
# Determine "effective" days and hours
|
||||||
monthly_counts = df.resample('M', on='date').size()
|
# User requested Holidays count as full days for simplicity
|
||||||
st.write("### Monthly Breakdown")
|
def get_effective_days(row):
|
||||||
st.dataframe(monthly_counts.rename("Days Worked"))
|
return 1.0
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
st.info("No work days logged yet. Click dates on the calendar.")
|
st.info("No work days logged yet. Click dates on the calendar.")
|
||||||
|
|
|
||||||
54
database.py
54
database.py
|
|
@ -1,8 +1,14 @@
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import datetime
|
import datetime
|
||||||
import pandas as pd
|
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():
|
def get_connection():
|
||||||
conn = sqlite3.connect(DB_FILE)
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
|
@ -11,33 +17,69 @@ def get_connection():
|
||||||
def init_db():
|
def init_db():
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Create table with new schema
|
||||||
c.execute('''
|
c.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS work_days (
|
CREATE TABLE IF NOT EXISTS work_days (
|
||||||
date TEXT PRIMARY KEY,
|
date TEXT PRIMARY KEY,
|
||||||
hours REAL DEFAULT 8.0,
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def toggle_day(date_str):
|
def update_day(date_str, day_type="work", course_name=None, hours=8.0, travel_costs=False):
|
||||||
"""Toggles a work day: adds if not exists, removes if exists."""
|
"""Updates or inserts a day. If course_name is None, it won't overwrite existing unless it's a new entry."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
|
travel_val = 1 if travel_costs else 0
|
||||||
|
|
||||||
# Check if exists
|
# Check if exists
|
||||||
c.execute("SELECT date FROM work_days WHERE date = ?", (date_str,))
|
c.execute("SELECT date FROM work_days WHERE date = ?", (date_str,))
|
||||||
data = c.fetchone()
|
data = c.fetchone()
|
||||||
|
|
||||||
if data:
|
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:
|
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.commit()
|
||||||
conn.close()
|
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():
|
def get_all_days():
|
||||||
"""Returns a list of all work days."""
|
"""Returns a list of all work days."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
Loading…
Add table
Add a link
Reference in a new issue