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
This commit is contained in:
2026-05-29 10:55:56 -04:00
parent f175321283
commit 4ee4ced6eb
4 changed files with 473 additions and 84 deletions

248
app.py
View File

@@ -2,27 +2,20 @@
""" """
Minimal Time Clock Application 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 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 # Flask Configuration
@@ -40,8 +33,10 @@ def get_db_connection():
""" """
Create and return a sqlite3 database connection. Create and return a sqlite3 database connection.
""" """
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@@ -51,9 +46,9 @@ def initialize_database():
""" """
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Users table
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS entries ( CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')), entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')),
ts DATETIME NOT NULL, ts TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) )
""") """)
conn.commit() conn.commit()
# Add sample users if table is empty
cursor.execute("SELECT COUNT(*) AS count FROM users") cursor.execute("SELECT COUNT(*) AS count FROM users")
result = cursor.fetchone() result = cursor.fetchone()
if result["count"] == 0: if result["count"] == 0:
cursor.execute("INSERT INTO users (name) VALUES ('Alice')") cursor.execute("INSERT INTO users (name) VALUES ('Alice')")
cursor.execute("INSERT INTO users (name) VALUES ('Bob')") cursor.execute("INSERT INTO users (name) VALUES ('Bob')")
cursor.execute("INSERT INTO users (name) VALUES ('Charlie')") cursor.execute("INSERT INTO users (name) VALUES ('Charlie')")
conn.commit() conn.commit()
conn.close() 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 # 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. Create a clock in/out entry for the specified user.
""" """
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() 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(""" cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts) INSERT INTO entries (user_id, entrytype, ts)
VALUES (?, ?, ?) VALUES (?, ?, ?)
""", (user_id, entry_type, timestamp)) """, (
user_id,
entry_type,
timestamp.isoformat()
))
conn.commit() conn.commit()
conn.close() conn.close()
def generate_report(user_id, begin_date, end_date): def generate_report(user_id, begin_date, end_date):
""" """
Generate total worked hours for a user between two dates. 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() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Inclusive date range begin_dt = parse_iso_datetime(begin_date)
begin_dt = datetime.strptime(begin_date, "%Y-%m-%dT%H:%M") end_dt = parse_iso_datetime(end_date)
end_dt = datetime.strptime(end_date, "%Y-%m-%dT%H:%M")
# Include entire ending day
end_dt = end_dt + timedelta(days=1)
cursor.execute(""" cursor.execute("""
SELECT entrytype, ts SELECT entrytype, ts
FROM entries FROM entries
WHERE user_id = ? WHERE user_id = ?
AND ts >= ?
AND ts < ?
ORDER BY ts ASC ORDER BY ts ASC
""", ( """, (user_id,))
user_id,
begin_dt.strftime("%Y-%m-%d %H:%M"),
end_dt.strftime("%Y-%m-%d %H:%M")
))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
total_seconds = 0 total_seconds = 0
clock_in_time = None clock_in_time = None
for row in rows: 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 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 delta = timestamp - clock_in_time
total_seconds += delta.total_seconds() total_seconds += delta.total_seconds()
clock_in_time = None clock_in_time = None
total_hours = total_seconds / 3600.0 total_hours = total_seconds / 3600.0
@@ -192,10 +220,8 @@ def index():
ORDER BY name ORDER BY name
""").fetchall() """).fetchall()
# NEW: users currently clocked in
clocked_in_users = conn.execute(""" clocked_in_users = conn.execute("""
SELECT u.id, u.name SELECT u.id, u.name, last_entry.ts
FROM users u FROM users u
JOIN ( JOIN (
SELECT e.user_id, e.entrytype, e.ts SELECT e.user_id, e.entrytype, e.ts
@@ -206,7 +232,7 @@ def index():
GROUP BY user_id GROUP BY user_id
) latest ) latest
ON e.user_id = latest.user_id ON e.user_id = latest.user_id
AND e.ts = latest.max_ts AND e.ts = latest.max_ts
) last_entry ) last_entry
ON u.id = last_entry.user_id ON u.id = last_entry.user_id
WHERE last_entry.entrytype = 'in' WHERE last_entry.entrytype = 'in'
@@ -215,35 +241,100 @@ def index():
conn.close() 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 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": if request.method == "POST":
user_id = request.form.get("user_id") selected_user_id = request.form.get("user_id")
action = request.form.get("action") action = request.form.get("action")
client_timestamp = request.form.get("client_timestamp")
if action == "clock_in": if action == "clock_in":
create_entry(user_id, "in")
create_entry(
selected_user_id,
"in",
client_timestamp
)
elif action == "clock_out": elif action == "clock_out":
create_entry(user_id, "out")
create_entry(
selected_user_id,
"out",
client_timestamp
)
elif action == "report": elif action == "report":
begin_date = request.form.get("begin_date") begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date") end_date = request.form.get("end_date")
report_hours = generate_report( report_hours = generate_report(
user_id, selected_user_id,
begin_date, begin_date,
end_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( return render_template(
"index.html", "index.html",
users=users, users=users,
report_hours=report_hours, 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"]) @app.route("/api/users", methods=["GET"])
def api_get_users(): def api_get_users():
"""
Return all users.
"""
conn = get_db_connection() conn = get_db_connection()
@@ -270,9 +358,6 @@ def api_get_users():
@app.route("/api/entries", methods=["GET"]) @app.route("/api/entries", methods=["GET"])
def api_get_entries(): def api_get_entries():
"""
Return all entries.
"""
conn = get_db_connection() conn = get_db_connection()
@@ -289,21 +374,30 @@ def api_get_entries():
@app.route("/api/entries", methods=["POST"]) @app.route("/api/entries", methods=["POST"])
def api_create_entry(): def api_create_entry():
"""
Create a clock entry.
"""
data = request.get_json() data = request.get_json()
user_id = data.get("user_id") user_id = data.get("user_id")
entry_type = data.get("entrytype") entry_type = data.get("entrytype")
client_timestamp = data.get("timestamp")
if entry_type not in ("in", "out"): 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 # Application Entry Point

99
migrate_db.sh Normal file
View File

@@ -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}" <<SQL
BEGIN TRANSACTION;
--
-- Convert naive timestamps to ISO8601 with timezone
--
-- Only modify timestamps that:
-- 1. Do NOT already contain timezone offsets
-- 2. Do NOT already contain a trailing Z
--
UPDATE entries
SET ts =
REPLACE(ts, ' ', 'T') || '${DEFAULT_OFFSET}'
WHERE
ts NOT LIKE '%+__:__'
AND ts NOT LIKE '%-__:__'
AND ts NOT LIKE '%Z';
COMMIT;
SQL
echo
echo "Migration complete."
echo
echo "Verifying results..."
echo
sqlite3 "${DATABASE}" <<SQL
.headers on
.mode column
SELECT
id,
user_id,
entrytype,
ts
FROM entries
ORDER BY id DESC
LIMIT 10;
SQL
echo
echo "Done."

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Time Clock</title> <title>Time Clock</title>
</head> </head>
@@ -13,7 +14,13 @@
{% if clocked_in_users %} {% if clocked_in_users %}
<ul> <ul>
{% for user in clocked_in_users %} {% for user in clocked_in_users %}
<li>{{ user.name }}</li> <li>
{{ user.name }} —
since
<span class="tztime" data-ts="{{ user.since }}">
{{ user.since }}
</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
@@ -21,21 +28,58 @@
{% endif %} {% endif %}
<br/> <br/>
{% if all_user_reports %}
<h2>Last 7 Days Report</h2>
<table border="1" cellpadding="5">
<tr>
<th>User</th>
<th>Total Hours Worked</th>
</tr>
{% for report in all_user_reports %}
<tr>
<td>{{ report.name }}</td>
<td>{{ report.hours }}</td>
</tr>
{% endfor %}
</table>
<br>
{% endif %}
<hr/> <hr/>
<br/> <br/>
<form method="POST"> <form method="POST" id="timeclock-form">
<input
type="hidden"
name="client_timestamp"
id="client_timestamp"
>
<table border="1" cellpadding="5"> <table border="1" cellpadding="5">
<tr> <tr>
<td>User</td> <td>User</td>
<td> <td>
<select name="user_id"> <select name="user_id">
{% for user in users %} {% for user in users %}
<option value="{{ user.id }}"> <option
{{ user.name }} value="{{ user.id }}"
{% if selected_user_id == user.id %}
selected
{% endif %}
>
{{ user.name }}
</option> </option>
{% endfor %} {% endfor %}
@@ -47,13 +91,25 @@
<td>Clock Actions</td> <td>Clock Actions</td>
<td> <td>
<button type="submit" name="action" value="clock_in">
<button
type="submit"
name="action"
value="clock_in"
onclick="setCurrentTimestamp()"
>
Clock In Clock In
</button> </button>
<button type="submit" name="action" value="clock_out"> <button
type="submit"
name="action"
value="clock_out"
onclick="setCurrentTimestamp()"
>
Clock Out Clock Out
</button> </button>
</td> </td>
</tr> </tr>
@@ -64,6 +120,7 @@
<input <input
type="datetime-local" type="datetime-local"
name="begin_date" name="begin_date"
id="begin_date"
value="" value=""
> >
</td> </td>
@@ -76,6 +133,7 @@
<input <input
type="datetime-local" type="datetime-local"
name="end_date" name="end_date"
id="end_date"
value="" value=""
> >
</td> </td>
@@ -85,7 +143,11 @@
<td>Report</td> <td>Report</td>
<td> <td>
<button type="submit" name="action" value="report"> <button
type="submit"
name="action"
value="report"
>
Generate Report Generate Report
</button> </button>
</td> </td>
@@ -99,6 +161,8 @@
{% if report_hours is not none %} {% if report_hours is not none %}
<h2>Custom Report</h2>
<table border="1" cellpadding="5"> <table border="1" cellpadding="5">
<tr> <tr>
@@ -113,5 +177,60 @@
{% endif %} {% endif %}
<script>
function localDatetimeValue(date) {
const pad = (v) => String(v).padStart(2, "0");
return (
date.getFullYear() + "-" +
pad(date.getMonth() + 1) + "-" +
pad(date.getDate()) + "T" +
pad(date.getHours()) + ":" +
pad(date.getMinutes())
);
}
function setCurrentTimestamp() {
document.getElementById("client_timestamp").value =
new Date().toISOString();
}
window.addEventListener("load", () => {
//
// Populate default report range
//
const now = new Date();
const weekAgo = new Date();
weekAgo.setDate(now.getDate() - 7);
document.getElementById("begin_date").value =
localDatetimeValue(weekAgo);
document.getElementById("end_date").value =
localDatetimeValue(now);
//
// Convert displayed timestamps to local timezone
//
document.querySelectorAll(".tztime").forEach((el) => {
const ts = el.dataset.ts;
const d = new Date(ts);
el.textContent = d.toLocaleString();
});
});
</script>
</body> </body>
</html> </html>

77
test.sh Normal file
View File

@@ -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 "======================================="