Compare commits

..

6 Commits

3 changed files with 50 additions and 227 deletions

225
app.py
View File

@@ -1,15 +1,5 @@
#!/usr/bin/env python3
"""
Minimal Time Clock Application
"""
import sqlite3
from datetime import datetime, timedelta, timezone
LEGACY_TIMEZONE = timezone.utc
from flask import (
Flask,
request,
@@ -17,209 +7,107 @@ from flask import (
jsonify
)
# -----------------------------------------------------------------------------
# Flask Configuration
# -----------------------------------------------------------------------------
LEGACY_TIMEZONE = timezone.utc
app = Flask(__name__)
DATABASE = "database/timeclock.db"
# -----------------------------------------------------------------------------
# Database Helpers
# -----------------------------------------------------------------------------
def get_db_connection():
"""
Create and return a sqlite3 database connection.
"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def initialize_database():
"""
Create database tables if they do not already exist.
"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)
""")
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 TEXT NOT NULL,
paid BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY(user_id) REFERENCES users(id)
)
""")
conn.commit()
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, client_timestamp=None):
"""
Create a clock in/out entry for the specified user.
"""
conn = get_db_connection()
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()
))
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):
"""
Generate total worked hours for a user between two dates.
"""
conn = get_db_connection()
cursor = conn.cursor()
begin_dt = parse_iso_datetime(begin_date)
end_dt = parse_iso_datetime(end_date)
cursor.execute("""
SELECT entrytype, ts
SELECT entrytype, ts, paid
FROM entries
WHERE user_id = ?
AND ts BETWEEN ? AND ?
ORDER BY ts ASC
""", (user_id,))
""", (user_id, begin_dt, end_dt))
rows = cursor.fetchall()
conn.close()
total_seconds = 0
clock_in_time = None
paid_seconds = 0
for row in rows:
timestamp = parse_iso_datetime(row["ts"])
if timestamp < begin_dt or timestamp > end_dt:
continue
if row["entrytype"] == "in":
clock_in_time = timestamp
elif row["entrytype"] == "out" and clock_in_time is not None:
delta = timestamp - clock_in_time
delta = end_dt - timestamp
if row["paid"]:
paid_seconds += delta.total_seconds()
else:
total_seconds += delta.total_seconds()
clock_in_time = None
total_hours = total_seconds / 3600.0
return round(total_hours, 2)
# -----------------------------------------------------------------------------
# Web UI Routes
# -----------------------------------------------------------------------------
paid_hours = paid_seconds / 3600.0
return round(total_hours, 2), round(paid_hours, 2)
@app.route("/", methods=["GET", "POST"])
def index():
"""
Main application page.
"""
conn = get_db_connection()
users = conn.execute("""
SELECT id, name
FROM users
ORDER BY name
""").fetchall()
clocked_in_users = conn.execute("""
SELECT u.id, u.name, last_entry.ts
FROM users u
@@ -238,94 +126,66 @@ def index():
WHERE last_entry.entrytype = 'in'
ORDER BY u.name
""").fetchall()
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":
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 == "pay":
# Update the paid column for all entries for the selected user to True
create_entry(
selected_user_id,
"pay"
)
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(
total_hours, paid_hours = generate_report(
user["id"],
default_begin.isoformat(),
default_end.isoformat()
)
all_user_reports.append({
"id": user["id"],
"name": user["name"],
"hours": hours
"total_hours": total_hours,
"paid_hours": paid_hours
})
return render_template(
"index.html",
users=users,
@@ -337,78 +197,53 @@ def index():
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
SELECT id, user_id, entrytype, ts, paid
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"):
paid = data.get("paid", False)
if entry_type not in ("in", "out", "pay"):
return jsonify({
"error": "Invalid entrytype"
}), 400
create_entry(
user_id,
entry_type,
client_timestamp
client_timestamp,
paid
)
return jsonify({
"status": "success"
}), 201
# -----------------------------------------------------------------------------
# Application Entry Point
# -----------------------------------------------------------------------------
if __name__ == "__main__":
initialize_database()
app.run(
host="0.0.0.0",
port=5000,
debug=False
)

View File

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

View File

@@ -38,12 +38,14 @@
<tr>
<th>User</th>
<th>Total Hours Worked</th>
<th>Paid Hours</th>
</tr>
{% for report in all_user_reports %}
<tr>
<td>{{ report.name }}</td>
<td>{{ report.hours }}</td>
<td>{{ report.total_hours }}</td>
<td>{{ report.paid_hours }}</td>
</tr>
{% endfor %}
@@ -82,7 +84,6 @@
{{ user.name }}
</option>
{% endfor %}
</select>
</td>
</tr>
@@ -110,6 +111,15 @@
Clock Out
</button>
<button
type="submit"
name="action"
value="pay"
onclick="setCurrentTimestamp()"
>
Pay
</button>
</td>
</tr>
@@ -167,10 +177,12 @@
<tr>
<th>Total Hours Worked</th>
<th>Paid Hours</th>
</tr>
<tr>
<td>{{ report_hours }}</td>
<td>{{ report_hours.total_hours }}</td>
<td>{{ report_hours.paid_hours }}</td>
</tr>
</table>
@@ -180,9 +192,7 @@
<script>
function localDatetimeValue(date) {
const pad = (v) => String(v).padStart(2, "0");
return (
date.getFullYear() + "-" +
pad(date.getMonth() + 1) + "-" +
@@ -193,43 +203,21 @@ function localDatetimeValue(date) {
}
function setCurrentTimestamp() {
document.getElementById("client_timestamp").value =
new Date().toISOString();
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.getElementById("begin_date").value = localDatetimeValue(weekAgo);
document.getElementById("end_date").value = localDatetimeValue(now);
document.querySelectorAll(".tztime").forEach((el) => {
const ts = el.dataset.ts;
const d = new Date(ts);
el.textContent = d.toLocaleString();
});
});
</script>
</body>