Compare commits

..

10 Commits

3 changed files with 215 additions and 321 deletions

433
app.py
View File

@@ -1,225 +1,231 @@
#!/usr/bin/env python3
"""
Minimal Time Clock Application
"""
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
LEGACY_TIMEZONE = timezone.utc
from flask import ( from flask import (
Flask, Flask,
request, request,
render_template, render_template,
jsonify jsonify
) )
import logging
# ----------------------------------------------------------------------------- LEGACY_TIMEZONE = ZoneInfo("America/New_York")
# Flask Configuration
# -----------------------------------------------------------------------------
app = Flask(__name__) app = Flask(__name__)
DATABASE = "database/timeclock.db" DATABASE = "database/timeclock.db"
# ----------------------------------------------------------------------------- def is_timezone_aware(dt):
# Database Helpers return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
# -----------------------------------------------------------------------------
def make_timezone_aware(dt, tzstr):
if tzstr:
tzval = datetime.strptime(tzstr.replace(":", ""), "%z").tzinfo
else:
tzval = LEGACY_TIMEZONE
if not is_timezone_aware(dt):
return dt.replace(tzinfo=tzval)
else:
return dt
def get_db_connection(): def get_db_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
def initialize_database(): def initialize_database():
"""
Create database tables if they do not already exist.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
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,
name TEXT NOT NULL name TEXT NOT NULL
) )
""") """)
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 TEXT NOT NULL, ts TEXT NOT NULL,
paid BOOLEAN NOT NULL DEFAULT FALSE,
comments TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) )
""") """)
conn.commit() conn.commit()
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): 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") normalized = value.replace(" ", "T")
dt = datetime.fromisoformat(normalized) dt = datetime.fromisoformat(normalized)
#
# If timestamp is naive, assume legacy timezone.
#
if dt.tzinfo is None:
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
return dt return dt
def utc_now(): def est_now():
""" return datetime.now(ZoneInfo("America/New_York"))
Return current UTC timestamp.
"""
return datetime.now(timezone.utc)
# -----------------------------------------------------------------------------
# Utility Functions
# -----------------------------------------------------------------------------
def create_entry(user_id, entry_type, client_timestamp=None):
"""
Create a clock in/out entry for the specified user.
"""
def pay_hours(user_id, begin_date, end_date):
conn = get_db_connection() conn = get_db_connection()
conn.set_trace_callback(logging.error)
cursor = conn.cursor() cursor = conn.cursor()
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.isoformat()
))
conn.commit()
conn.close()
def generate_report(user_id, begin_date, end_date):
"""
Generate total worked hours for a user between two dates.
"""
conn = get_db_connection()
cursor = conn.cursor()
begin_dt = parse_iso_datetime(begin_date) begin_dt = parse_iso_datetime(begin_date)
end_dt = parse_iso_datetime(end_date) end_dt = parse_iso_datetime(end_date)
cursor.execute(""" cursor.execute("""
SELECT entrytype, ts UPDATE entries
FROM entries SET paid = TRUE
WHERE user_id = ? WHERE user_id = ?
ORDER BY ts ASC AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
""", (user_id,)) """, (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.commit()
conn.close() conn.close()
def create_entry(user_id, entry_type, client_timestamp=None, comments=None, client_timezone=None):
conn = get_db_connection()
cursor = conn.cursor()
if client_timestamp:
timestamp = make_timezone_aware(parse_iso_datetime(client_timestamp), client_timezone)
else:
timestamp = est_now()
if comments:
cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid, comments)
VALUES (?, ?, ?, ?, ?)
""", (user_id, entry_type, timestamp.isoformat(), False, comments))
else:
cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid)
VALUES (?, ?, ?, ?)
""", (user_id, entry_type, timestamp.isoformat(), False))
conn.commit()
conn.close()
def generate_report(user_id, begin_date, end_date, client_timezone):
logging.error(f"user_id={user_id}, begin_date={begin_date}, end_date={end_date}")
conn = get_db_connection()
cursor = conn.cursor()
begin_dt = make_timezone_aware(parse_iso_datetime(begin_date), client_timezone)
end_dt = make_timezone_aware(parse_iso_datetime(end_date), client_timezone)
logging.error(f"begin_dt={begin_dt}, end_dt={end_dt}")
cursor.execute("""
SELECT entrytype, ts, paid, comments
FROM entries
WHERE user_id = ?
AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
ORDER BY ts ASC
""", (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall()
conn.close()
total_seconds = 0 total_seconds = 0
clock_in_time = None clock_in_time = None
paid_seconds = 0
actions = []
for row in rows: for row in rows:
timestamp = parse_iso_datetime(row["ts"]) timestamp = parse_iso_datetime(row["ts"])
if timestamp < begin_dt or timestamp > end_dt: if timestamp < begin_dt or timestamp > end_dt:
continue continue
if row["entrytype"] == "in": if row["entrytype"] == "in":
clock_in_time = timestamp clock_in_time = timestamp
elif row["entrytype"] == "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
if row["paid"]:
paid_seconds += delta.total_seconds()
total_seconds += delta.total_seconds() total_seconds += delta.total_seconds()
if row["comments"]:
clock_in_time = None if row["paid"]:
actions.append("(PAID) {}".format(row["comments"]))
else:
actions.append(row["comments"])
total_hours = total_seconds / 3600.0 total_hours = total_seconds / 3600.0
paid_hours = paid_seconds / 3600.0
return round(total_hours, 2) return round(total_hours, 2), round(paid_hours, 2), actions
# -----------------------------------------------------------------------------
# Web UI Routes
# -----------------------------------------------------------------------------
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
""" report_hours = None
Main application page. user_report = {}
""" all_user_reports = []
selected_user_id = None
now = est_now()
default_begin = now - timedelta(days=7)
default_end = now
conn = get_db_connection() conn = get_db_connection()
users = conn.execute(""" users = conn.execute("""
SELECT id, name SELECT id, name
FROM users FROM users
ORDER BY name ORDER BY name
""").fetchall() """).fetchall()
conn.close()
if request.method == "POST":
selected_user_id = request.form.get("user_id")
action = request.form.get("action")
client_timestamp = request.form.get("client_timestamp")
client_timezone = request.form.get("client_timezone")
comments = request.form.get("comments")
if action == "clock_in":
create_entry(
selected_user_id,
"in",
client_timestamp,
comments,
client_timezone
)
elif action == "clock_out":
create_entry(
selected_user_id,
"out",
client_timestamp,
comments,
client_timezone
)
elif action == "pay":
begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date")
pay_hours(
selected_user_id,
begin_date,
end_date,
client_timezone
)
elif action == "report":
begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date")
client_timezone = request.form.get("client_timezone")
report_hours, paid_hours, actions = generate_report(
selected_user_id,
begin_date,
end_date,
client_timezone
)
user_report = {
"total_hours": report_hours,
"paid_hours": paid_hours,
"actions": actions
}
default_begin = parse_iso_datetime(begin_date)
default_end = parse_iso_datetime(end_date)
for user in users:
total_hours, paid_hours, actions = generate_report(
user["id"],
default_begin.isoformat(),
default_end.isoformat(),
request.form.get("client_timezone")
)
all_user_reports.append({
"id": user["id"],
"name": user["name"],
"total_hours": total_hours,
"paid_hours": paid_hours,
"actions": actions
})
conn = get_db_connection()
clocked_in_users = conn.execute(""" clocked_in_users = conn.execute("""
SELECT u.id, u.name, last_entry.ts SELECT u.id, u.name, last_entry.ts
FROM users u FROM users u
@@ -238,98 +244,19 @@ def index():
WHERE last_entry.entrytype = 'in' WHERE last_entry.entrytype = 'in'
ORDER BY u.name ORDER BY u.name
""").fetchall() """).fetchall()
conn.close() conn.close()
clocked_in_list = [] clocked_in_list = []
for user in clocked_in_users: for user in clocked_in_users:
ts = parse_iso_datetime(user["ts"]) ts = parse_iso_datetime(user["ts"])
clocked_in_list.append({ clocked_in_list.append({
"id": user["id"], "id": user["id"],
"name": user["name"], "name": user["name"],
"since": ts.isoformat() "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":
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(
selected_user_id,
"in",
client_timestamp
)
elif action == "clock_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(
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( return render_template(
"index.html", "index.html",
users=users, users=users,
report_hours=report_hours, user_report=user_report,
all_user_reports=all_user_reports, all_user_reports=all_user_reports,
clocked_in_users=clocked_in_list, clocked_in_users=clocked_in_list,
default_begin=default_begin.isoformat(timespec="minutes"), default_begin=default_begin.isoformat(timespec="minutes"),
@@ -337,76 +264,8 @@ def index():
selected_user_id=selected_user_id selected_user_id=selected_user_id
) )
# -----------------------------------------------------------------------------
# CRUD API
# -----------------------------------------------------------------------------
@app.route("/api/users", methods=["GET"])
def api_get_users():
conn = get_db_connection()
users = conn.execute("""
SELECT id, name
FROM users
""").fetchall()
conn.close()
return jsonify([dict(user) for user in users])
@app.route("/api/entries", methods=["GET"])
def api_get_entries():
conn = get_db_connection()
entries = conn.execute("""
SELECT id, user_id, entrytype, ts
FROM entries
ORDER BY ts DESC
""").fetchall()
conn.close()
return jsonify([dict(entry) for entry in entries])
@app.route("/api/entries", methods=["POST"])
def api_create_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,
client_timestamp
)
return jsonify({
"status": "success"
}), 201
# -----------------------------------------------------------------------------
# Application Entry Point
# -----------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
initialize_database() initialize_database()
app.run( app.run(
host="0.0.0.0", host="0.0.0.0",
port=5000, port=5000,

View File

@@ -4,7 +4,7 @@ replicaCount: 1
image: image:
repository: registry.home.aklabs.net/timeclock repository: registry.home.aklabs.net/timeclock
tag: 2026052901 tag: 2026061501
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:

View File

@@ -38,12 +38,22 @@
<tr> <tr>
<th>User</th> <th>User</th>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th>
<th>Actions</th>
</tr> </tr>
{% for report in all_user_reports %} {% for report in all_user_reports %}
<tr> <tr>
<td>{{ report.name }}</td> <td>{{ report.name }}</td>
<td>{{ report.hours }}</td> <td>{{ report.total_hours }}</td>
<td>{{ report.paid_hours }}</td>
<td>
<ul>
{% for action in report.actions %}
<li>{{ action }}</li>
{% endfor %}
</ul>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -58,6 +68,12 @@
<form method="POST" id="timeclock-form"> <form method="POST" id="timeclock-form">
<input
type="hidden"
id="client_timezone"
name="client_timezone"
value=""/>
<input <input
type="hidden" type="hidden"
name="client_timestamp" name="client_timestamp"
@@ -82,7 +98,6 @@
{{ user.name }} {{ user.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
</tr> </tr>
@@ -110,6 +125,15 @@
Clock Out Clock Out
</button> </button>
<button
type="submit"
name="action"
value="pay"
onclick="setCurrentTimestamp()"
>
Pay
</button>
</td> </td>
</tr> </tr>
@@ -121,8 +145,7 @@
type="datetime-local" type="datetime-local"
name="begin_date" name="begin_date"
id="begin_date" id="begin_date"
value="" value="">
>
</td> </td>
</tr> </tr>
@@ -139,6 +162,18 @@
</td> </td>
</tr> </tr>
<tr>
<td>Comments</td>
<td>
<textarea
name="comments"
id="comments"
rows="5" cols="72"
></textarea>
</td>
</tr>
<tr> <tr>
<td>Report</td> <td>Report</td>
@@ -159,7 +194,7 @@
<br> <br>
{% if report_hours is not none %} {% if user_report is not none %}
<h2>Custom Report</h2> <h2>Custom Report</h2>
@@ -167,10 +202,20 @@
<tr> <tr>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th>
<th>Actions</th>
</tr> </tr>
<tr> <tr>
<td>{{ report_hours }}</td> <td>{{ user_report.total_hours }}</td>
<td>{{ user_report.paid_hours }}</td>
<td>
<ul>
{% for action in user_report.actions %}
<li>{{ action }}</li>
{% endfor %}
</ul>
</td>
</tr> </tr>
</table> </table>
@@ -179,10 +224,21 @@
<script> <script>
function getFormattedOffset() {
const offset = new Date().getTimezoneOffset();
const absMinutes = Math.abs(offset);
// Invert the sign to match standard UTC notation
const sign = offset <= 0 ? "+" : "-";
const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0");
const minutes = String(absMinutes % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
}
function localDatetimeValue(date) { function localDatetimeValue(date) {
const pad = (v) => String(v).padStart(2, "0"); const pad = (v) => String(v).padStart(2, "0");
return ( return (
date.getFullYear() + "-" + date.getFullYear() + "-" +
pad(date.getMonth() + 1) + "-" + pad(date.getMonth() + 1) + "-" +
@@ -193,43 +249,22 @@ function localDatetimeValue(date) {
} }
function setCurrentTimestamp() { function setCurrentTimestamp() {
document.getElementById("client_timestamp").value = new Date().toISOString();
document.getElementById("client_timestamp").value =
new Date().toISOString();
} }
window.addEventListener("load", () => { window.addEventListener("load", () => {
//
// Populate default report range
//
const now = new Date(); const now = new Date();
const weekAgo = new Date(); const weekAgo = new Date();
weekAgo.setDate(now.getDate() - 7); weekAgo.setDate(now.getDate() - 7);
document.getElementById("client_timezone").value = getFormattedOffset();
document.getElementById("begin_date").value = document.getElementById("begin_date").value = localDatetimeValue(weekAgo);
localDatetimeValue(weekAgo); document.getElementById("end_date").value = localDatetimeValue(now);
document.getElementById("end_date").value =
localDatetimeValue(now);
//
// Convert displayed timestamps to local timezone
//
document.querySelectorAll(".tztime").forEach((el) => { document.querySelectorAll(".tztime").forEach((el) => {
const ts = el.dataset.ts; const ts = el.dataset.ts;
const d = new Date(ts); const d = new Date(ts);
el.textContent = d.toLocaleString(); el.textContent = d.toLocaleString();
}); });
}); });
</script> </script>
</body> </body>