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
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

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>
<html>
<head>
<title>Time Clock</title>
</head>
@@ -13,7 +14,13 @@
{% if clocked_in_users %}
<ul>
{% 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 %}
</ul>
{% else %}
@@ -21,21 +28,58 @@
{% endif %}
<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/>
<br/>
<form method="POST">
<form method="POST" id="timeclock-form">
<input
type="hidden"
name="client_timestamp"
id="client_timestamp"
>
<table border="1" cellpadding="5">
<tr>
<td>User</td>
<td>
<select name="user_id">
{% for user in users %}
<option value="{{ user.id }}">
{{ user.name }}
<option
value="{{ user.id }}"
{% if selected_user_id == user.id %}
selected
{% endif %}
>
{{ user.name }}
</option>
{% endfor %}
@@ -47,13 +91,25 @@
<td>Clock Actions</td>
<td>
<button type="submit" name="action" value="clock_in">
<button
type="submit"
name="action"
value="clock_in"
onclick="setCurrentTimestamp()"
>
Clock In
</button>
<button type="submit" name="action" value="clock_out">
<button
type="submit"
name="action"
value="clock_out"
onclick="setCurrentTimestamp()"
>
Clock Out
</button>
</td>
</tr>
@@ -64,6 +120,7 @@
<input
type="datetime-local"
name="begin_date"
id="begin_date"
value=""
>
</td>
@@ -76,6 +133,7 @@
<input
type="datetime-local"
name="end_date"
id="end_date"
value=""
>
</td>
@@ -85,7 +143,11 @@
<td>Report</td>
<td>
<button type="submit" name="action" value="report">
<button
type="submit"
name="action"
value="report"
>
Generate Report
</button>
</td>
@@ -99,6 +161,8 @@
{% if report_hours is not none %}
<h2>Custom Report</h2>
<table border="1" cellpadding="5">
<tr>
@@ -113,5 +177,60 @@
{% 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>
</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 "======================================="