Add clock in/clock out workflow for time tracking

Replace the single-modal time entry flow with a toggle-based clock in/out
system. Tapping the FAB on the Time tab now opens a simplified Clock In
modal (project + note); tapping again while clocked in fills in the time
out. A green banner shows elapsed time when clocked in, and the full
manual entry modal remains accessible via a link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 20:50:13 -06:00
parent f7e704943e
commit 8442846feb
2 changed files with 888 additions and 17 deletions

189
server.py
View File

@@ -31,6 +31,7 @@ NC_DAV_ROOT = "" # set after login, e.g. /remote.php/dav/files/<user>/business-
NC_AUTH = ()
RECEIPTS_FILE = "receipts.json"
TIMESHEET_FILE = "timesheet.json"
SETTINGS_FILE = "settings.json"
AUTH_FILE = "auth.json"
PHOTOS_DIR = "photos"
@@ -204,6 +205,105 @@ def build_excel(receipts):
return buf.getvalue()
# --- Helpers for timesheet.json -----------------------------------------------
def load_timesheet():
"""Read timesheet.json from Nextcloud. Returns a list."""
r = nc_get(TIMESHEET_FILE)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json()
def save_timesheet(entries):
"""Write timesheet.json to Nextcloud."""
data = json.dumps(entries, indent=2).encode()
r = nc_put(TIMESHEET_FILE, data, "application/json")
r.raise_for_status()
def build_timesheet_excel(entries):
"""Build an .xlsx file from time entries. Returns bytes."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Timesheet"
settings = load_settings()
project_map = {p["id"]: p for p in settings.get("projects", [])}
customer_map = {c["id"]: c for c in settings.get("customers", [])}
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill("solid", fgColor="1A1A2E")
header_align = Alignment(horizontal="center")
thin_border = Border(bottom=Side(style="thin", color="DDDDDD"))
hours_fmt = '0.00'
headers = ["Date", "Time In", "Time Out", "Hours", "Customer", "Project", "Note"]
col_widths = [14, 12, 12, 10, 20, 20, 40]
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
ws.column_dimensions[cell.column_letter].width = width
for row_idx, entry in enumerate(entries, 2):
ws.cell(row=row_idx, column=1, value=entry.get("date", "")).border = thin_border
ws.cell(row=row_idx, column=2, value=entry.get("timeIn", "")).border = thin_border
t_out = entry.get("timeOut", "")
ws.cell(row=row_idx, column=3, value=t_out if t_out else "In progress").border = thin_border
hours = 0
try:
t_in = entry.get("timeIn", "")
if t_in and t_out:
h1, m1 = map(int, t_in.split(":"))
h2, m2 = map(int, t_out.split(":"))
hours = (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
except (ValueError, AttributeError):
pass
hrs_cell = ws.cell(row=row_idx, column=4, value=hours)
hrs_cell.number_format = hours_fmt
hrs_cell.border = thin_border
project_name = ""
customer_name = ""
pid = entry.get("projectId", "")
if pid and pid in project_map:
proj = project_map[pid]
project_name = proj.get("name", "")
cid = proj.get("customerId", "")
if cid and cid in customer_map:
customer_name = customer_map[cid].get("name", "")
ws.cell(row=row_idx, column=5, value=customer_name).border = thin_border
ws.cell(row=row_idx, column=6, value=project_name).border = thin_border
ws.cell(row=row_idx, column=7, value=entry.get("note", "")).border = thin_border
if entries:
total_row = len(entries) + 2
total_label = ws.cell(row=total_row, column=1, value="TOTAL")
total_label.font = Font(bold=True)
total_hours = 0
for entry in entries:
try:
t_in = entry.get("timeIn", "")
t_out = entry.get("timeOut", "")
if t_in and t_out:
h1, m1 = map(int, t_in.split(":"))
h2, m2 = map(int, t_out.split(":"))
total_hours += (h2 * 60 + m2 - h1 * 60 - m1) / 60.0
except (ValueError, AttributeError):
pass
total_cell = ws.cell(row=total_row, column=4, value=total_hours)
total_cell.font = Font(bold=True)
total_cell.number_format = hours_fmt
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# --- Receipt extraction via Claude Haiku ---------------------------------------
def extract_receipt_info(jpeg_bytes):
@@ -439,6 +539,62 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# GET /api/timesheet
if self.path == "/api/timesheet":
if not self._check_session():
return
try:
entries = load_timesheet()
self._send_json(entries)
except Exception as e:
self._send_error(502, str(e))
return
# GET /api/timesheet/export — export timesheet as Excel
if self.path.startswith("/api/timesheet/export"):
if not self._check_session():
return
try:
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
project_filter = qs.get("project", [None])[0]
entries = load_timesheet()
if project_filter == "none":
entries = [e for e in entries if not e.get("projectId")]
elif project_filter:
entries = [e for e in entries if e.get("projectId") == project_filter]
settings_data = load_settings()
project_map = {p["id"]: p for p in settings_data.get("projects", [])}
customer_map = {c["id"]: c for c in settings_data.get("customers", [])}
def sanitize(s):
return re.sub(r'[^\w\s-]', '', s).strip().replace(" ", "_")
filename = "timesheet"
if project_filter and project_filter != "none":
proj = project_map.get(project_filter)
if proj:
cust = customer_map.get(proj.get("customerId", ""))
parts = []
if cust:
parts.append(sanitize(cust["name"]))
parts.append(sanitize(proj["name"]))
filename = "-".join(parts) + "-timesheet" if parts else "timesheet"
excel_bytes = build_timesheet_excel(entries)
self.send_response(200)
self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.send_header("Content-Disposition",
f'attachment; filename="{filename}.xlsx"')
self.send_header("Content-Length", str(len(excel_bytes)))
self.end_headers()
self.wfile.write(excel_bytes)
except Exception as e:
self._send_error(500, str(e))
return
# GET /api/settings
if self.path == "/api/settings":
if not self._check_session():
@@ -583,6 +739,24 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# POST /api/timesheet — upsert a time entry
if self.path == "/api/timesheet":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
entries = load_timesheet()
idx = next((i for i, e in enumerate(entries) if e["id"] == data["id"]), None)
if idx is not None:
entries[idx] = data
else:
entries.append(data)
save_timesheet(entries)
self._send_json(data, 200)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/extract-receipt — extract total + date from receipt image
if self.path == "/api/extract-receipt":
if not self._check_session():
@@ -686,6 +860,21 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# DELETE /api/timesheet/<id>
m = re.fullmatch(r"/api/timesheet/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
entry_id = m.group(1)
try:
entries = load_timesheet()
entries = [e for e in entries if e["id"] != entry_id]
save_timesheet(entries)
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
self._send_error(404, "Not found")
# Suppress default logging noise