Compare commits

..

6 Commits

3 changed files with 50 additions and 227 deletions

227
app.py
View File

@@ -1,15 +1,5 @@
#!/usr/bin/env python3
"""
Minimal Time Clock Application
"""
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
LEGACY_TIMEZONE = timezone.utc
from flask import ( from flask import (
Flask, Flask,
request, request,
@@ -17,209 +7,107 @@ from flask import (
jsonify jsonify
) )
# ----------------------------------------------------------------------------- LEGACY_TIMEZONE = timezone.utc
# Flask Configuration
# -----------------------------------------------------------------------------
app = Flask(__name__) app = Flask(__name__)
DATABASE = "database/timeclock.db" DATABASE = "database/timeclock.db"
# -----------------------------------------------------------------------------
# Database Helpers
# -----------------------------------------------------------------------------
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,
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: if dt.tzinfo is None:
dt = dt.replace(tzinfo=LEGACY_TIMEZONE) dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
return dt return dt
def utc_now(): def utc_now():
"""
Return current UTC timestamp.
"""
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# -----------------------------------------------------------------------------
# Utility Functions
# -----------------------------------------------------------------------------
def create_entry(user_id, entry_type, client_timestamp=None): def create_entry(user_id, entry_type, client_timestamp=None):
"""
Create a clock in/out entry for the specified user.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
if client_timestamp: if client_timestamp:
timestamp = parse_iso_datetime(client_timestamp) timestamp = parse_iso_datetime(client_timestamp)
else: else:
timestamp = utc_now() timestamp = utc_now()
cursor.execute(""" cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts) INSERT INTO entries (user_id, entrytype, ts, paid)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
""", ( """, (user_id, entry_type, timestamp.isoformat(), False))
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.
"""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() 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 SELECT entrytype, ts, paid
FROM entries FROM entries
WHERE user_id = ? WHERE user_id = ?
AND ts BETWEEN ? AND ?
ORDER BY ts ASC ORDER BY ts ASC
""", (user_id,)) """, (user_id, begin_dt, end_dt))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
total_seconds = 0 total_seconds = 0
paid_seconds = 0
clock_in_time = None
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
delta = end_dt - timestamp
if row["entrytype"] == "in": if row["paid"]:
clock_in_time = timestamp paid_seconds += delta.total_seconds()
else:
elif row["entrytype"] == "out" and clock_in_time is not None:
delta = timestamp - clock_in_time
total_seconds += delta.total_seconds() total_seconds += delta.total_seconds()
clock_in_time = None
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)
# -----------------------------------------------------------------------------
# Web UI Routes
# -----------------------------------------------------------------------------
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
"""
Main application page.
"""
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()
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,94 +126,66 @@ 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 report_hours = None
all_user_reports = [] all_user_reports = []
selected_user_id = None selected_user_id = None
now = utc_now() now = utc_now()
default_begin = now - timedelta(days=7) default_begin = now - timedelta(days=7)
default_end = now default_end = now
if request.method == "POST": if request.method == "POST":
selected_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") client_timestamp = request.form.get("client_timestamp")
if action == "clock_in": if action == "clock_in":
create_entry( create_entry(
selected_user_id, selected_user_id,
"in", "in",
client_timestamp client_timestamp
) )
elif action == "clock_out": elif action == "clock_out":
create_entry( create_entry(
selected_user_id, selected_user_id,
"out", "out",
client_timestamp client_timestamp
) )
elif action == "pay":
# Update the paid column for all entries for the selected user to True
create_entry(
selected_user_id,
"pay"
)
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(
selected_user_id, selected_user_id,
begin_date, begin_date,
end_date end_date
) )
default_begin = parse_iso_datetime(begin_date) default_begin = parse_iso_datetime(begin_date)
default_end = parse_iso_datetime(end_date) default_end = parse_iso_datetime(end_date)
else: else:
#
# Automatically generate last 7 day report
# for ALL users
#
for user in users: for user in users:
total_hours, paid_hours = generate_report(
hours = generate_report(
user["id"], user["id"],
default_begin.isoformat(), default_begin.isoformat(),
default_end.isoformat() default_end.isoformat()
) )
all_user_reports.append({ all_user_reports.append({
"id": user["id"], "id": user["id"],
"name": user["name"], "name": user["name"],
"hours": hours "total_hours": total_hours,
"paid_hours": paid_hours
}) })
return render_template( return render_template(
"index.html", "index.html",
users=users, users=users,
@@ -337,78 +197,53 @@ def index():
selected_user_id=selected_user_id selected_user_id=selected_user_id
) )
# -----------------------------------------------------------------------------
# CRUD API
# -----------------------------------------------------------------------------
@app.route("/api/users", methods=["GET"]) @app.route("/api/users", methods=["GET"])
def api_get_users(): def api_get_users():
conn = get_db_connection() conn = get_db_connection()
users = conn.execute(""" users = conn.execute("""
SELECT id, name SELECT id, name
FROM users FROM users
""").fetchall() """).fetchall()
conn.close() conn.close()
return jsonify([dict(user) for user in users]) return jsonify([dict(user) for user in users])
@app.route("/api/entries", methods=["GET"]) @app.route("/api/entries", methods=["GET"])
def api_get_entries(): def api_get_entries():
conn = get_db_connection() conn = get_db_connection()
entries = conn.execute(""" entries = conn.execute("""
SELECT id, user_id, entrytype, ts SELECT id, user_id, entrytype, ts, paid
FROM entries FROM entries
ORDER BY ts DESC ORDER BY ts DESC
""").fetchall() """).fetchall()
conn.close() conn.close()
return jsonify([dict(entry) for entry in entries]) return jsonify([dict(entry) for entry in entries])
@app.route("/api/entries", methods=["POST"]) @app.route("/api/entries", methods=["POST"])
def api_create_entry(): def api_create_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") client_timestamp = data.get("timestamp")
paid = data.get("paid", False)
if entry_type not in ("in", "out"): if entry_type not in ("in", "out", "pay"):
return jsonify({ return jsonify({
"error": "Invalid entrytype" "error": "Invalid entrytype"
}), 400 }), 400
create_entry( create_entry(
user_id, user_id,
entry_type, entry_type,
client_timestamp client_timestamp,
paid
) )
return jsonify({ return jsonify({
"status": "success" "status": "success"
}), 201 }), 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,
debug=False debug=False
) )

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,14 @@
<tr> <tr>
<th>User</th> <th>User</th>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</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>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -82,7 +84,6 @@
{{ user.name }} {{ user.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
</tr> </tr>
@@ -110,6 +111,15 @@
Clock Out Clock Out
</button> </button>
<button
type="submit"
name="action"
value="pay"
onclick="setCurrentTimestamp()"
>
Pay
</button>
</td> </td>
</tr> </tr>
@@ -167,10 +177,12 @@
<tr> <tr>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th>
</tr> </tr>
<tr> <tr>
<td>{{ report_hours }}</td> <td>{{ report_hours.total_hours }}</td>
<td>{{ report_hours.paid_hours }}</td>
</tr> </tr>
</table> </table>
@@ -180,9 +192,7 @@
<script> <script>
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 +203,21 @@ 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("begin_date").value = localDatetimeValue(weekAgo);
document.getElementById("begin_date").value = document.getElementById("end_date").value = localDatetimeValue(now);
localDatetimeValue(weekAgo);
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>