initial commit

This commit is contained in:
Bartal Læarsson
2026-06-18 21:41:47 +01:00
commit 8e60f0472b
29 changed files with 2872 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{{define "about_page"}}
<h2>About this POC</h2>
<section class="about-card">
<p><strong>zeitkort</strong> is a consultant time-tracking web application bundled with a PDF invoice creator.</p>
<p>At <strong>FLÓ</strong>, I use this for tracking all my consultancy work, generating and sending invoices.</p>
<p>So the hierarchy is like this: at the top level I create companies I do work for.</p>
<p>For example, add new company, and add the company info. The company info will automatically be used for generating the invoice</p>
<p>One company can have multiple assignments. That could be 'Create backend for webservice A' or 'Build ETL process for billing pipeline'. Like a large chunk of work for a company, a logical border.</p>
<p>One assignment can ahve many tasks. For example 'Set up HTTP server', 'Administrative work', or 'CI/CD Pipeline' under the assignment 'Create backend for web service A'.</p>
<p>From here on you can either start a timer for a certain task, or add tasks manually. Everything can be edited or deleted, but not cascading deletes upwards in the hierarchy.</p>
<p>When you are done with an assignment for the company, create the invoice. When pressing the button, you can change the basic info about the company, and you can add lines that are not time, but quantity or other measures. For example, 2 pcs. routers at 200 kr a piece. Or whatever you know what I mean. Or like perhaps it is not an hourly rate, but just a one time price for the whole assignment.</p>
<br>
<p>This dummy version runs in-memory and is reset every 10 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
<p>It is served via Caddy (I love Caddy) to my FLÓ subdomain.</p>
<p>It is one static Go binary, it uses the golang built-in server-rendered HTML templating language, htmx for page updates (Javascript for the dummy timer), embedded static files.</p>
<p>I mean, come on, this things ships <strong>as a single binary</strong>, like all of this right? And when I use the API, it is all still a single binary. Could not be easier (I love Go).</p>
<p class="poc-warning"><strong>POC reset:</strong> all in-memory data is wiped every {{.ResetIntervalMinutes}} minutes and whenever the process restarts.</p>
<p><button hx-get="/main" hx-target="#main">Back to timer</button></p>
</section>
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "assignment_form"}}
<h2>{{if .Assignment}}Edit assignment{{else}}New assignment{{end}}</h2>
<form hx-post="{{if .Assignment}}/assignments/{{.AssignID}}{{else}}/assignments{{end}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<p><label>Name<br><input name="name" value="{{with .Assignment}}{{.Name}}{{end}}" required></label></p>
<p><label>Description<br><textarea name="description" rows="3">{{with .Assignment}}{{.Description}}{{end}}</textarea></label></p>
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Assignment}}{{money .HourlyRate}}{{end}}"></label>
<br><small class="muted">Leave 0 to use the company's default rate.</small></p>
{{if .Assignment}}
<p><label>Status<br>
<select name="status">
<option value="active" {{if eq .Assignment.Status "active"}}selected{{end}}>active</option>
<option value="archived" {{if eq .Assignment.Status "archived"}}selected{{end}}>archived</option>
</select></label></p>
{{end}}
<p>
<button type="submit">Save</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}{{if .Assignment}}&assignment_id={{.AssignID}}{{end}}"
hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "company_form"}}
<h2>{{if .Company}}Edit company{{else}}New company{{end}}</h2>
<form hx-post="{{if .Company}}/companies/{{.CompanyID}}{{else}}/companies{{end}}" hx-target="#main">
<p><label>Name<br><input name="name" value="{{with .Company}}{{.Name}}{{end}}" required></label></p>
<p><label>Address<br><input name="address" value="{{with .Company}}{{.Address}}{{end}}"></label></p>
<p><label>Email<br><input name="contact_email" value="{{with .Company}}{{.ContactEmail}}{{end}}"></label></p>
<p><label>Phone<br><input name="contact_phone" value="{{with .Company}}{{.ContactPhone}}{{end}}"></label></p>
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Company}}{{money .HourlyRate}}{{end}}"></label></p>
<p>
<button type="submit">Save</button>
<button type="button" hx-get="/main?company_id={{.CompanyID}}" hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{define "invoice_form"}}
<h2>Invoice — {{.Assignment.Name}}</h2>
<p class="muted">{{.Company.Name}} · {{money .TotalHours}} hrs logged = {{money .TotalAmount}} DKK before MVG</p>
<form method="post" action="/invoice">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<fieldset>
<legend>Invoice</legend>
<p><label>Invoice number<br><input name="invoice_number" value="{{.InvoiceNumber}}" required></label></p>
<p><label>Date<br><input value="{{.Today}}" disabled></label> <span class="muted">(today)</span></p>
<p><label>Payment terms (days)<br><input name="due_days" type="number" value="{{.DueDays}}"></label></p>
<p><label>MVG %<br><input name="mvg" type="number" step="0.01" value="{{money .MVGPercent}}"></label></p>
<p><label>Comment (optional)<br><textarea name="comment" rows="2"></textarea></label></p>
</fieldset>
<fieldset>
<legend>Sender</legend>
<p><label>Name<br><input name="from_name" value="{{.Cfg.CompanyName}}"></label></p>
<p><label>Address<br><input name="from_address" value="{{.Cfg.CompanyAddress}}"></label></p>
<p><label>Phone<br><input name="from_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
<p><label>Email<br><input name="from_email" value="{{.Cfg.CompanyEmail}}"></label></p>
<p><label>Web<br><input name="from_web" value="{{.Cfg.CompanyWeb}}"></label></p>
<p><label>V-tal<br><input name="from_vat" value="{{.Cfg.TaxID}}"></label></p>
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
</fieldset>
<fieldset>
<legend>Extra lines (optional)</legend>
<div id="extra-lines"></div>
<button type="button" hx-get="/invoice/line" hx-target="#extra-lines" hx-swap="beforeend">Add line</button>
</fieldset>
<p>
<button type="submit">Generate PDF</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
hx-target="#main">Back</button>
</p>
</form>
{{end}}
{{define "invoice_line"}}
<div class="line">
<input name="extra_desc" placeholder="description">
<input name="extra_qty" type="number" step="0.01" placeholder="qty">
<select name="extra_unit"><option value="stk">stk</option><option value="tímar">tímar</option></select>
<input name="extra_price" type="number" step="0.01" placeholder="unit price">
<button type="button" hx-get="/void" hx-target="closest .line" hx-swap="outerHTML">×</button>
</div>
{{end}}
+178
View File
@@ -0,0 +1,178 @@
{{define "main"}}
{{if .AppError}}<p class="error">{{.AppError}}</p>{{end}}
{{if .Notice}}<p class="notice">{{.Notice}}</p>{{end}}
{{if .Running}}
<div class="running-bar">
<span class="clock" id="clock" data-start="{{rfc3339 .Running.StartTime}}">00:00:00</span>
<span class="rb-task">Working on <strong>{{.Running.TaskDescription}}</strong></span>
<button class="go stop" hx-post="/sessions/{{.Running.ID}}/stop"
hx-vals='{"company_id":"{{.CompanyID}}","assignment_id":"{{.AssignmentID}}"}'
hx-target="#main">■ Stop</button>
</div>
{{end}}
<fieldset>
<legend>Company</legend>
<select name="company_id" hx-get="/main" hx-target="#main">
<option value="0">— select company —</option>
{{range .Companies}}
<option value="{{.ID}}" {{if eq .ID $.CompanyID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button hx-get="/companies/new" hx-target="#main">New</button>
{{if .CompanyID}}
<button hx-get="/companies/{{.CompanyID}}/edit" hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this company? You must remove its assignments first. This cannot be undone.
<button hx-post="/companies/{{.CompanyID}}/delete" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</fieldset>
{{if .CompanyID}}
<fieldset>
<legend>Assignment</legend>
<select name="assignment_id" hx-get="/main" hx-target="#main"
hx-vals='{"company_id":"{{.CompanyID}}"}'>
<option value="0">— select assignment —</option>
{{range .Assignments}}
<option value="{{.ID}}" {{if eq .ID $.AssignmentID}}selected{{end}}>{{.Name}} ({{.Status}})</option>
{{end}}
</select>
<button hx-get="/assignments/new?company_id={{.CompanyID}}" hx-target="#main">New</button>
{{if .AssignmentID}}
<button hx-get="/assignments/{{.AssignmentID}}/edit?company_id={{.CompanyID}}" hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this assignment? You must remove its tasks first. This cannot be undone.
<button hx-post="/assignments/{{.AssignmentID}}/delete?company_id={{.CompanyID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</fieldset>
{{end}}
{{if .Assignment}}
<div class="columns">
<div class="col">
<fieldset>
<legend>Tasks</legend>
{{range .Tasks}}
{{if eq .ID $.EditingTaskID}}
<div class="task">
<form class="inline" hx-post="/tasks/{{.ID}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{$.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{$.AssignmentID}}">
<span class="muted">{{.Number}} -</span>
<input type="text" name="name" value="{{.Name}}">
<button type="submit">Save</button>
<button type="button" class="cancel"
hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}"
hx-target="#main">Cancel</button>
</form>
</div>
{{else}}
<div class="task">
{{if $.Running}}
{{if eq .ID $.Running.TaskID}}
<span class="go-badge">● running</span>
{{else}}
<button class="go" disabled title="Stop the running timer first">▶ Start</button>
{{end}}
{{else}}
<button class="go" hx-post="/sessions/start"
hx-vals='{"company_id":"{{$.CompanyID}}","assignment_id":"{{$.AssignmentID}}","task_id":"{{.ID}}"}'
hx-target="#main">▶ Start</button>
{{end}}
<span class="pick">{{.Number}} - {{.Name}}
<span class="muted">({{hours .TotalMinutes}} hrs)</span>
</span>
<button hx-get="/tasks/{{.ID}}/edit?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}"
hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this task? You must remove its work sessions first. This cannot be undone.
<button hx-post="/tasks/{{.ID}}/delete?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
</div>
{{end}}
{{else}}
<p class="muted">No tasks yet.</p>
{{end}}
{{if .AddingTask}}
<form class="inline" hx-post="/tasks" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<input type="text" name="name" placeholder="new task name">
<button type="submit">Add</button>
<button type="button" class="cancel"
hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Cancel</button>
</form>
{{else}}
<button class="add-toggle"
hx-get="/tasks/new?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">+ New task</button>
{{end}}
</fieldset>
</div>
<div class="col">
<fieldset>
<legend>Recent sessions</legend>
<p>Total: <strong>{{money .TotalHours}} hrs</strong> = <strong>{{money .TotalAmount}} DKK</strong></p>
<div class="table-wrap">
<table>
<thead><tr><th>Date</th><th>Time</th><th>Hrs</th><th>Task</th><th></th></tr></thead>
<tbody>
{{range .Sessions}}
<tr {{if not .EndTime}}class="active"{{end}}>
<td>{{datef .StartTime}}</td>
<td>{{hm .StartTime}}{{timef .EndTime}}</td>
<td>{{if .EndTime}}{{hours .DurationMinutes}}{{else}}—{{end}}</td>
<td>{{.TaskDescription}}</td>
<td class="row-actions">
{{if .EndTime}}
<button hx-get="/sessions/{{.ID}}/edit?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}"
hx-target="#main">Edit</button>
<details class="confirm">
<summary>Del</summary>
<div class="confirm-pop">
Delete this work session? This cannot be undone.
<button hx-post="/sessions/{{.ID}}/delete?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No sessions yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
<p>
<button hx-get="/sessions/new?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Add manual entry</button>
<button hx-get="/invoice?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Generate invoice (PDF)</button>
</p>
</fieldset>
</div>
</div>
{{end}}
{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "page"}}<!doctype html>
<html lang="fo">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zeitkort</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header>
<span class="brand">zeitkort</span>
<nav>
<a href="/">Timer</a>
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">Settings</a>
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">About</a>
</nav>
</header>
<div class="reset-banner" role="status" aria-live="polite">
<span class="reset-label">POC reset in</span>
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{rfc3339 .ResetAt}}">--:--</span>
<span class="reset-note">All in-memory data clears every {{.ResetIntervalMinutes}} minutes.</span>
</div>
<main id="main">
{{template "main" .}}
</main>
<footer>zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes</footer>
<script src="/static/htmx.min.js"></script>
<script src="/static/timer.js"></script>
</body>
</html>{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "session_form"}}
<h2>{{if .Session}}Edit session{{else}}Add manual session{{end}}</h2>
<form hx-post="{{if .Session}}/sessions/{{.SessionID}}{{else}}/sessions/manual{{end}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<p><label>Task<br>
<select name="task_id">
{{range .Tasks}}
<option value="{{.ID}}" {{if and $.Session (eq .ID $.Session.TaskID)}}selected{{end}}>{{.Number}} - {{.Name}}</option>
{{end}}
<option value="new">+ new task…</option>
</select></label></p>
<p><label>New task name <span class="muted">(only if “+ new task” selected)</span><br>
<input name="new_task_name"></label></p>
<p><label>Date (DD-MM-YYYY)<br>
<input name="date" value="{{if .Session}}{{dateInput .Session.StartTime}}{{else}}{{today}}{{end}}" required></label></p>
<p><label>Start (HH:MM)<br>
<input name="start" value="{{if .Session}}{{hm .Session.StartTime}}{{else}}09:00{{end}}" required></label></p>
<p><label>End (HH:MM)<br>
<input name="end" value="{{if .Session}}{{hmp .Session.EndTime}}{{else}}17:00{{end}}" required></label></p>
<p>
<button type="submit">Save</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "settings_page"}}
<h2>Settings</h2>
{{if .Saved}}<p class="notice">Saved.</p>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form hx-post="/settings" hx-target="#main">
<fieldset>
<legend>Sender / company details (used on invoices)</legend>
<p><label>Company name<br><input name="company_name" value="{{.Cfg.CompanyName}}"></label></p>
<p><label>Address<br><input name="company_address" value="{{.Cfg.CompanyAddress}}"></label></p>
<p><label>Phone<br><input name="company_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
<p><label>Email<br><input name="company_email" value="{{.Cfg.CompanyEmail}}"></label></p>
<p><label>Web<br><input name="company_web" value="{{.Cfg.CompanyWeb}}"></label></p>
<p><label>V-tal (tax ID)<br><input name="tax_id" value="{{.Cfg.TaxID}}"></label></p>
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
</fieldset>
<fieldset>
<legend>Invoice defaults</legend>
<p><label>Payment terms (days)<br><input name="payment_terms_days" type="number" value="{{.Cfg.PaymentTermsDays}}"></label></p>
<p><label>MVG %<br><input name="mvg_percent" type="number" step="0.01" value="{{money .Cfg.MVGPercent}}"></label></p>
</fieldset>
<p>
<button type="submit">Save</button>
<button type="button" hx-get="/main" hx-target="#main">Back</button>
</p>
</form>
{{end}}