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:
246
app.py
246
app.py
@@ -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
|
||||||
@@ -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
99
migrate_db.sh
Normal 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."
|
||||||
@@ -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,20 +28,57 @@
|
|||||||
{% 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
|
||||||
|
value="{{ user.id }}"
|
||||||
|
{% if selected_user_id == user.id %}
|
||||||
|
selected
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
{{ user.name }}
|
{{ 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
77
test.sh
Normal 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 "======================================="
|
||||||
Reference in New Issue
Block a user