From 4ee4ced6eb1b109bd8d04f2e6010d7bea2b0e44b Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Fri, 29 May 2026 10:55:56 -0400 Subject: [PATCH] GPT : Timezone and reporting improvements Add timezone-aware timestamp support and reporting improvements Store timestamps in ISO-8601 format with timezone offsets Accept client-local timestamps from browser and API Normalize legacy naive timestamps during parsing Add migration script for converting old SQLite timestamps Fix mixed naive/aware datetime comparison errors Render timestamps in browser-local timezone Auto-generate default 7-day reports on page load Expand default dashboard report to include all users Preserve manual single-user report generation Add curl-based validation/test script for API and report flows --- app.py | 248 +++++++++++++++++++++++++++++-------------- migrate_db.sh | 99 +++++++++++++++++ templates/index.html | 133 +++++++++++++++++++++-- test.sh | 77 ++++++++++++++ 4 files changed, 473 insertions(+), 84 deletions(-) create mode 100644 migrate_db.sh create mode 100644 test.sh diff --git a/app.py b/app.py index b6f261a..be43bfa 100644 --- a/app.py +++ b/app.py @@ -2,27 +2,20 @@ """ Minimal Time Clock Application - -Requirements: -- Python 3 -- Flask -- sqlite3 -- Linux compatible -- No JavaScript -- Simple HTML UI - -Features: -- User selection -- Clock in -- Clock out -- Report generation -- CRUD API -- Automatic database initialization """ import sqlite3 -from datetime import datetime, timedelta -from flask import Flask, request, redirect, url_for, render_template, jsonify + +from datetime import datetime, timedelta, timezone + +LEGACY_TIMEZONE = timezone.utc + +from flask import ( + Flask, + request, + render_template, + jsonify +) # ----------------------------------------------------------------------------- # Flask Configuration @@ -40,8 +33,10 @@ def get_db_connection(): """ Create and return a sqlite3 database connection. """ + conn = sqlite3.connect(DATABASE) conn.row_factory = sqlite3.Row + return conn @@ -51,9 +46,9 @@ def initialize_database(): """ conn = get_db_connection() + cursor = conn.cursor() - # Users table cursor.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -61,113 +56,146 @@ def initialize_database(): ) """) - # Entries table - # - # user_id: - # References users.id - # - # entrytype: - # Must be either 'in' or 'out' - # - # ts: - # Timestamp stored as ISO datetime string - # cursor.execute(""" CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')), - ts DATETIME NOT NULL, + ts TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ) """) conn.commit() - # Add sample users if table is empty cursor.execute("SELECT COUNT(*) AS count FROM users") + result = cursor.fetchone() if result["count"] == 0: cursor.execute("INSERT INTO users (name) VALUES ('Alice')") cursor.execute("INSERT INTO users (name) VALUES ('Bob')") cursor.execute("INSERT INTO users (name) VALUES ('Charlie')") + conn.commit() conn.close() +# ----------------------------------------------------------------------------- +# Time Helpers +# ----------------------------------------------------------------------------- + +def parse_iso_datetime(value): + """ + Parse timestamps into timezone-aware datetime objects. + + Handles both: + 2026-05-29 08:00:00 + and: + 2026-05-29T08:00:00-04:00 + """ + + # + # Support legacy sqlite format: + # 2026-05-29 08:00:00 + # + + normalized = value.replace(" ", "T") + + dt = datetime.fromisoformat(normalized) + + # + # If timestamp is naive, assume legacy timezone. + # + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=LEGACY_TIMEZONE) + + return dt + +def utc_now(): + """ + Return current UTC timestamp. + """ + + return datetime.now(timezone.utc) + + # ----------------------------------------------------------------------------- # Utility Functions # ----------------------------------------------------------------------------- -def create_entry(user_id, entry_type): +def create_entry(user_id, entry_type, client_timestamp=None): """ Create a clock in/out entry for the specified user. """ conn = get_db_connection() + cursor = conn.cursor() - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if client_timestamp: + timestamp = parse_iso_datetime(client_timestamp) + else: + timestamp = utc_now() cursor.execute(""" INSERT INTO entries (user_id, entrytype, ts) VALUES (?, ?, ?) - """, (user_id, entry_type, timestamp)) + """, ( + user_id, + entry_type, + timestamp.isoformat() + )) conn.commit() + conn.close() def generate_report(user_id, begin_date, end_date): """ Generate total worked hours for a user between two dates. - - Assumptions: - - Entries alternate correctly between 'in' and 'out' - - Missing pairs are ignored """ conn = get_db_connection() + cursor = conn.cursor() - # Inclusive date range - begin_dt = datetime.strptime(begin_date, "%Y-%m-%dT%H:%M") - end_dt = datetime.strptime(end_date, "%Y-%m-%dT%H:%M") - - # Include entire ending day - end_dt = end_dt + timedelta(days=1) + begin_dt = parse_iso_datetime(begin_date) + end_dt = parse_iso_datetime(end_date) cursor.execute(""" SELECT entrytype, ts FROM entries WHERE user_id = ? - AND ts >= ? - AND ts < ? ORDER BY ts ASC - """, ( - user_id, - begin_dt.strftime("%Y-%m-%d %H:%M"), - end_dt.strftime("%Y-%m-%d %H:%M") - )) + """, (user_id,)) rows = cursor.fetchall() conn.close() total_seconds = 0 + clock_in_time = None for row in rows: - entry_type = row["entrytype"] - timestamp = datetime.strptime(row["ts"], "%Y-%m-%d %H:%M:%S") - if entry_type == "in": + timestamp = parse_iso_datetime(row["ts"]) + + if timestamp < begin_dt or timestamp > end_dt: + continue + + if row["entrytype"] == "in": clock_in_time = timestamp - elif entry_type == "out" and clock_in_time is not None: + elif row["entrytype"] == "out" and clock_in_time is not None: + delta = timestamp - clock_in_time + total_seconds += delta.total_seconds() + clock_in_time = None total_hours = total_seconds / 3600.0 @@ -192,10 +220,8 @@ def index(): ORDER BY name """).fetchall() - - # NEW: users currently clocked in clocked_in_users = conn.execute(""" - SELECT u.id, u.name + SELECT u.id, u.name, last_entry.ts FROM users u JOIN ( SELECT e.user_id, e.entrytype, e.ts @@ -206,7 +232,7 @@ def index(): GROUP BY user_id ) latest ON e.user_id = latest.user_id - AND e.ts = latest.max_ts + AND e.ts = latest.max_ts ) last_entry ON u.id = last_entry.user_id WHERE last_entry.entrytype = 'in' @@ -215,35 +241,100 @@ def index(): conn.close() + clocked_in_list = [] + + for user in clocked_in_users: + + ts = parse_iso_datetime(user["ts"]) + + clocked_in_list.append({ + "id": user["id"], + "name": user["name"], + "since": ts.isoformat() + }) + report_hours = None + all_user_reports = [] + + selected_user_id = None + + now = utc_now() + + default_begin = now - timedelta(days=7) + + default_end = now + if request.method == "POST": - user_id = request.form.get("user_id") + selected_user_id = request.form.get("user_id") + action = request.form.get("action") + client_timestamp = request.form.get("client_timestamp") + if action == "clock_in": - create_entry(user_id, "in") + + create_entry( + selected_user_id, + "in", + client_timestamp + ) elif action == "clock_out": - create_entry(user_id, "out") + + create_entry( + selected_user_id, + "out", + client_timestamp + ) elif action == "report": begin_date = request.form.get("begin_date") + end_date = request.form.get("end_date") report_hours = generate_report( - user_id, + selected_user_id, begin_date, end_date ) + default_begin = parse_iso_datetime(begin_date) + + default_end = parse_iso_datetime(end_date) + + else: + + # + # Automatically generate last 7 day report + # for ALL users + # + + for user in users: + + hours = generate_report( + user["id"], + default_begin.isoformat(), + default_end.isoformat() + ) + + all_user_reports.append({ + "id": user["id"], + "name": user["name"], + "hours": hours + }) + return render_template( "index.html", users=users, report_hours=report_hours, - clocked_in_users=clocked_in_users + all_user_reports=all_user_reports, + clocked_in_users=clocked_in_list, + default_begin=default_begin.isoformat(timespec="minutes"), + default_end=default_end.isoformat(timespec="minutes"), + selected_user_id=selected_user_id ) # ----------------------------------------------------------------------------- @@ -252,9 +343,6 @@ def index(): @app.route("/api/users", methods=["GET"]) def api_get_users(): - """ - Return all users. - """ conn = get_db_connection() @@ -270,9 +358,6 @@ def api_get_users(): @app.route("/api/entries", methods=["GET"]) def api_get_entries(): - """ - Return all entries. - """ conn = get_db_connection() @@ -289,21 +374,30 @@ def api_get_entries(): @app.route("/api/entries", methods=["POST"]) def api_create_entry(): - """ - Create a clock entry. - """ data = request.get_json() user_id = data.get("user_id") + entry_type = data.get("entrytype") + client_timestamp = data.get("timestamp") + if entry_type not in ("in", "out"): - return jsonify({"error": "Invalid entrytype"}), 400 - create_entry(user_id, entry_type) + return jsonify({ + "error": "Invalid entrytype" + }), 400 - return jsonify({"status": "success"}), 201 + create_entry( + user_id, + entry_type, + client_timestamp + ) + + return jsonify({ + "status": "success" + }), 201 # ----------------------------------------------------------------------------- # Application Entry Point diff --git a/migrate_db.sh b/migrate_db.sh new file mode 100644 index 0000000..b8b6678 --- /dev/null +++ b/migrate_db.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# +# SQLite Timestamp Migration +# +# Converts legacy naive timestamps: +# +# 2026-05-29 08:15:00 +# +# into timezone-aware ISO-8601 timestamps: +# +# 2026-05-29T08:15:00-04:00 +# +# Existing timezone-aware timestamps are preserved. +# + +set -euo pipefail + +DATABASE="${1:-database/timeclock.db}" + +# +# Timezone offset to apply to old naive timestamps. +# +# Examples: +# -04:00 Eastern Daylight Time +# -05:00 Eastern Standard Time +# +00:00 UTC +# +DEFAULT_OFFSET="-04:00" + +echo "=========================================" +echo "Timeclock Timestamp Migration" +echo "=========================================" +echo +echo "Database: ${DATABASE}" +echo "Assumed legacy offset: ${DEFAULT_OFFSET}" +echo + +if [ ! -f "${DATABASE}" ]; then + echo "ERROR: Database not found" + exit 1 +fi + +BACKUP="${DATABASE}.backup.$(date +%Y%m%d%H%M%S)" + +echo "Creating backup:" +echo " ${BACKUP}" + +cp "${DATABASE}" "${BACKUP}" + +echo +echo "Beginning migration..." +echo + +sqlite3 "${DATABASE}" < + Time Clock @@ -13,7 +14,13 @@ {% if clocked_in_users %} {% else %} @@ -21,21 +28,58 @@ {% endif %}
+ +{% if all_user_reports %} + +

Last 7 Days Report

+ + + + + + + + +{% for report in all_user_reports %} + + + + +{% endfor %} + +
UserTotal Hours Worked
{{ report.name }}{{ report.hours }}
+ +
+ +{% endif %} +

-
+ + + + @@ -64,6 +120,7 @@ @@ -76,6 +133,7 @@ @@ -85,7 +143,11 @@ @@ -99,6 +161,8 @@ {% if report_hours is not none %} +

Custom Report

+
User Clock Actions - - +
Report -
@@ -113,5 +177,60 @@ {% endif %} + + diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..72dd591 --- /dev/null +++ b/test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -e + +BASE_URL="http://127.0.0.1:5000" + +echo "=======================================" +echo "Fetching users" +echo "=======================================" + +curl -s \ + "${BASE_URL}/api/users" \ + | jq . + +echo +echo "=======================================" +echo "Clocking IN user 1" +echo "=======================================" + +curl -s \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": 1, + "entrytype": "in", + "timestamp": "2026-05-29T08:00:00-04:00" + }' \ + "${BASE_URL}/api/entries" \ + | jq . + +sleep 1 + +echo +echo "=======================================" +echo "Clocking OUT user 1" +echo "=======================================" + +curl -s \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": 1, + "entrytype": "out", + "timestamp": "2026-05-29T17:00:00-04:00" + }' \ + "${BASE_URL}/api/entries" \ + | jq . + +echo +echo "=======================================" +echo "Fetching entries" +echo "=======================================" + +curl -s \ + "${BASE_URL}/api/entries" \ + | jq . + +echo +echo "=======================================" +echo "Generating report through web form" +echo "=======================================" + +curl -s \ + -X POST \ + -d "user_id=1" \ + -d "action=report" \ + -d "begin_date=2026-05-22T00:00:00-04:00" \ + -d "end_date=2026-05-29T23:59:59-04:00" \ + "${BASE_URL}/" \ + > report.html + +echo "Report saved to report.html" + +echo +echo "=======================================" +echo "Done" +echo "======================================="