Qwen broke several things with the last features. Fix those. Handle user timezones properly. Use a textarea for the comments instead of a text input.

This commit is contained in:
2026-06-16 12:16:41 -04:00
parent 1a85ca6830
commit dbd8b6e6ac
2 changed files with 163 additions and 128 deletions

245
app.py
View File

@@ -1,18 +1,33 @@
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from flask import ( from flask import (
Flask, Flask,
request, request,
render_template, render_template,
jsonify jsonify
) )
import logging
LEGACY_TIMEZONE = timezone.utc LEGACY_TIMEZONE = ZoneInfo("America/New_York")
app = Flask(__name__) app = Flask(__name__)
DATABASE = "database/timeclock.db" DATABASE = "database/timeclock.db"
def is_timezone_aware(dt):
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():
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -51,20 +66,34 @@ def initialize_database():
def parse_iso_datetime(value): def parse_iso_datetime(value):
normalized = value.replace(" ", "T") normalized = value.replace(" ", "T")
dt = datetime.fromisoformat(normalized) dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
return dt return dt
def utc_now(): def est_now():
return datetime.now(timezone.utc) return datetime.now(ZoneInfo("America/New_York"))
def create_entry(user_id, entry_type, client_timestamp=None, comments=None): def pay_hours(user_id, begin_date, end_date):
conn = get_db_connection()
conn.set_trace_callback(logging.error)
cursor = conn.cursor()
begin_dt = parse_iso_datetime(begin_date)
end_dt = parse_iso_datetime(end_date)
cursor.execute("""
UPDATE entries
SET paid = TRUE
WHERE user_id = ?
AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
""", (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall()
conn.commit()
conn.close()
def create_entry(user_id, entry_type, client_timestamp=None, comments=None, client_timezone=None):
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 = make_timezone_aware(parse_iso_datetime(client_timestamp), client_timezone)
else: else:
timestamp = utc_now() timestamp = est_now()
if comments: if comments:
cursor.execute(""" cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid, comments) INSERT INTO entries (user_id, entrytype, ts, paid, comments)
@@ -78,46 +107,125 @@ def create_entry(user_id, entry_type, client_timestamp=None, comments=None):
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, client_timezone):
logging.error(f"user_id={user_id}, begin_date={begin_date}, end_date={end_date}")
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
begin_dt = parse_iso_datetime(begin_date) begin_dt = make_timezone_aware(parse_iso_datetime(begin_date), client_timezone)
end_dt = parse_iso_datetime(end_date) 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(""" cursor.execute("""
SELECT entrytype, ts, paid, comments SELECT entrytype, ts, paid, comments
FROM entries FROM entries
WHERE user_id = ? WHERE user_id = ?
AND ts BETWEEN ? AND ? AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
ORDER BY ts ASC ORDER BY ts ASC
""", (user_id, begin_dt, end_dt)) """, (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
total_seconds = 0 total_seconds = 0
clock_in_time = None
paid_seconds = 0 paid_seconds = 0
actions = [] 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
delta = end_dt - timestamp if row["entrytype"] == "in":
if row["paid"]: clock_in_time = timestamp
paid_seconds += delta.total_seconds() elif row["entrytype"] == "out" and clock_in_time is not None:
else: 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"]: if row["comments"]:
actions.append(row["comments"]) 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 paid_hours = paid_seconds / 3600.0
return round(total_hours, 2), round(paid_hours, 2), actions return round(total_hours, 2), round(paid_hours, 2), actions
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
report_hours = None
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
@@ -145,65 +253,10 @@ def index():
"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")
comments = request.form.get("comments")
if action == "clock_in":
create_entry(
selected_user_id,
"in",
client_timestamp,
comments
)
elif action == "clock_out":
create_entry(
selected_user_id,
"out",
client_timestamp,
comments
)
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, paid_hours, actions = generate_report(
selected_user_id,
begin_date,
end_date
)
default_begin = parse_iso_datetime(begin_date)
default_end = parse_iso_datetime(end_date)
else:
for user in users:
total_hours, paid_hours, actions = generate_report(
user["id"],
default_begin.isoformat(),
default_end.isoformat()
)
all_user_reports.append({
"id": user["id"],
"name": user["name"],
"total_hours": total_hours,
"paid_hours": paid_hours,
"actions": actions
})
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"),
@@ -211,50 +264,6 @@ def index():
selected_user_id=selected_user_id selected_user_id=selected_user_id
) )
@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, paid, comments
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")
paid = data.get("paid", False)
comments = data.get("comments", "")
if entry_type not in ("in", "out", "pay"):
return jsonify({
"error": "Invalid entrytype"
}), 400
create_entry(
user_id,
entry_type,
client_timestamp,
paid,
comments
)
return jsonify({
"status": "success"
}), 201
if __name__ == "__main__": if __name__ == "__main__":
initialize_database() initialize_database()
app.run( app.run(

View File

@@ -68,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"
@@ -139,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>
@@ -161,12 +166,11 @@
<td>Comments</td> <td>Comments</td>
<td> <td>
<input <textarea
type="textarea" name="comments"
name="comments"
id="comments" id="comments"
value="" rows="5" cols="72"
> ></textarea>
</td> </td>
</tr> </tr>
@@ -190,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>
@@ -199,11 +203,19 @@
<tr> <tr>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th> <th>Paid Hours</th>
<th>Actions</th>
</tr> </tr>
<tr> <tr>
<td>{{ report_hours.total_hours }}</td> <td>{{ user_report.total_hours }}</td>
<td>{{ report_hours.paid_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>
@@ -212,6 +224,19 @@
<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 (
@@ -231,6 +256,7 @@ window.addEventListener("load", () => {
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 = localDatetimeValue(weekAgo); document.getElementById("begin_date").value = localDatetimeValue(weekAgo);
document.getElementById("end_date").value = localDatetimeValue(now); document.getElementById("end_date").value = localDatetimeValue(now);
document.querySelectorAll(".tztime").forEach((el) => { document.querySelectorAll(".tztime").forEach((el) => {