From 78eedbb2aba66695cacac8d832a44512379e4a22 Mon Sep 17 00:00:00 2001
From: Tom Smeding <tom@tomsmeding.com>
Date: Wed, 9 Mar 2022 00:08:00 +0100
Subject: Timetrack 2

---
 modules/timetrack2/timetrack.html   | 510 ++++++++++++++++++++++++++++++++++++
 modules/timetrack2/timetrack2.js    | 270 +++++++++++++++++++
 modules/timetrack2/unknownuser.html |  87 ++++++
 timetrack-migrate.js                |  52 ++++
 4 files changed, 919 insertions(+)
 create mode 100644 modules/timetrack2/timetrack.html
 create mode 100644 modules/timetrack2/timetrack2.js
 create mode 100644 modules/timetrack2/unknownuser.html
 create mode 100644 timetrack-migrate.js

diff --git a/modules/timetrack2/timetrack.html b/modules/timetrack2/timetrack.html
new file mode 100644
index 0000000..209458d
--- /dev/null
+++ b/modules/timetrack2/timetrack.html
@@ -0,0 +1,510 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack</title>
+<script>
+"use strict";
+
+var ROOT_ENDPOINT="/timetrack2";
+
+var lastlist=null;
+
+function fetch(method,url,data/*?*/,creds/*?*/,cb){
+	if(!creds){
+		cb=data;
+		data=undefined;
+		creds=undefined;
+	} else if(!cb){
+		cb=creds;
+		creds=undefined;
+	}
+	if(!cb)throw new Error("No callback passed to fetch");
+	var xhr=new XMLHttpRequest();
+	xhr.onreadystatechange=function(ev){
+		if(xhr.readyState<4)return;
+		cb(xhr.status,xhr.responseText);
+	};
+	if(creds){
+		xhr.open(method,url,true,creds[0],creds[1]);
+	} else {
+		xhr.open(method,url);
+	}
+	xhr.send(data);
+}
+
+function pad(s,n,c){
+	if(c==null)c=" ";
+	else c=c[0];
+	s=s+"";
+	while(s.length<n)s=c+s;
+	return s;
+}
+
+function toinputdate(date) {
+	return date.getFullYear() + "-" +
+			pad(date.getMonth()+1,2,"0") + "-" +
+			pad(date.getDate(),2,"0") + " " +
+			pad(date.getHours(),2,"0") + ":" +
+			pad(date.getMinutes(),2,"0") + ":" +
+			pad(date.getSeconds(),2,"0");
+}
+
+function daystart(date){
+	return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+}
+
+function weekstart(date){
+	var d=daystart(date);
+	var wkday=(d.getDay()+6)%7;
+	return new Date(d - wkday*24*3600*1000);
+}
+
+var monthnames=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
+var weekdays=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
+
+function formatdate(date) {
+	return weekdays[(date.getDay() + 6) % 7] + ", " + toinputdate(date);
+}
+
+function formatdaterange(d1, d2) {
+	var s1 = formatdate(d1);
+	if (daystart(d1).getTime() == daystart(d2).getTime()) {
+		return formatdate(d1) + " - " +
+			pad(d2.getHours(), 2, "0") + ":" +
+			pad(d2.getMinutes(), 2, "0") + ":" +
+			pad(d2.getSeconds(), 2, "0")
+	} else {
+		return formatdate(d1) + " - " + formatdate(d2);
+	}
+}
+
+function tablerowfor(ev){
+	var div=document.createElement("div");
+	div.classList.add("event");
+
+	var e,e2;
+
+	e=document.createElement("span");
+	e.classList.add("eventsheet");
+	e.appendChild(document.createTextNode(ev.sheet));
+	div.appendChild(e);
+
+	if (ev.text != "") {
+		e=document.createElement("span");
+		e.classList.add("eventtext");
+		e.appendChild(document.createTextNode("(" + ev.text + ")"));
+		div.appendChild(e);
+	}
+
+	var float=document.createElement("div");
+	float.classList.add("eventfloat");
+
+		e=document.createElement("span");
+		e.classList.add("eventdate");
+		e.appendChild(document.createTextNode(formatdaterange(ev.indate,ev.outdate)));
+		e.setAttribute("title",ev.indate.toString() + " - " + ev.outdate.toString());
+		float.appendChild(e);
+
+		e=document.createElement("div");
+		e.classList.add("eventbuttons");
+		e2=document.createElement("span");
+			e2.classList.add("eventdelete");
+			e2.appendChild(document.createTextNode("X"));
+			e2.addEventListener("click",function(){
+				if(!confirm("Really delete  event \""+ev.sheet+"\" (\""+ev.text+"\") at "+formatdate(ev.indate)+"?"))return;
+				fetch("DELETE",ROOT_ENDPOINT+"/event",ev.id,function(status,body){
+					if(status==200)getlist(false);
+					else alert("Delete failed: "+body);
+				});
+			});
+		e.appendChild(e2);
+		float.appendChild(e);
+
+	div.appendChild(float);
+
+	return div;
+}
+
+function refreshlist(list){
+	var listelem = document.getElementById("eventlist");
+	listelem.innerHTML = "";
+
+	if (list.length == 0) {
+		var div = document.createElement("div");
+		div.classList.add("noevents");
+		div.appendChild(document.createTextNode("No events yet"));
+		listelem.appendChild(div);
+		return;
+	}
+
+	var maxshown = 40;
+	if (list.length > maxshown) {
+		var div = document.createElement("div");
+		div.classList.add("moreevents");
+		div.appendChild(document.createTextNode("... more events ..."));
+		listelem.appendChild(div);
+	}
+	for (var i = Math.max(0, list.length - maxshown); i < list.length; i++) {
+		listelem.appendChild(tablerowfor(list[i]));
+	}
+}
+
+function refreshcalendar(list,npreweeks){
+	if (npreweeks == undefined) npreweeks = 2;
+
+	var dayTargetH = 38/5 - 1;
+
+	var weekTotals = new Map();  // only in weeks that have entries
+	var totalTime = 0;
+
+	function datekey(d) { return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); }
+	var hist = new Map();
+	for (var i = 0; i < list.length; i++) {
+		var key = datekey(list[i].indate);
+		var wkey = datekey(weekstart(list[i].indate));
+
+		var thistime = list[i].outdate - list[i].indate;
+		var yet = hist.has(key) ? hist.get(key) : 0;
+		hist.set(key, yet + thistime);
+
+		var weekyet = weekTotals.has(wkey) ? weekTotals.get(wkey) : 0;
+		weekTotals.set(wkey, weekyet + thistime);
+
+		totalTime += thistime;
+	}
+
+	var totalTargetH = 5 * weekTotals.size * dayTargetH;
+	var totalSurplus = totalTime - totalTargetH * 3600 * 1000;
+
+	var tb = document.getElementById("calendartb");
+	tb.innerHTML = "";
+
+	var tr = document.createElement("tr");
+	tr.appendChild(document.createElement("td"));
+	var td;
+	for (var i = 0; i < 7; i++) {
+		td = document.createElement("td");
+		td.appendChild(document.createTextNode(weekdays[i]));
+		tr.appendChild(td);
+	}
+	td = document.createElement("td");
+	var a = document.createElement("a");
+	a.href = "javascript:refreshcalendar(lastlist," + (npreweeks + 4) + ")";
+	a.appendChild(document.createTextNode("\u2191"));
+	td.appendChild(a);
+	tr.appendChild(td);
+	tb.appendChild(tr);
+
+	var now = new Date();
+	var today = daystart(now), thismonday = weekstart(now);
+	for (var wkoff = -npreweeks; wkoff <= 1; wkoff++) {
+		var monday = new Date(thismonday.getTime() + wkoff*7*24*3600*1000);
+
+		tr = document.createElement("tr");
+		td = document.createElement("td");
+		var label = monday.getDate().toString();
+		if (monday.getDate() <= 7 || wkoff == -npreweeks) {
+			label = monthnames[monday.getMonth()].slice(0, 3) + " " + label;
+		}
+		td.appendChild(document.createTextNode(label));
+		tr.appendChild(td);
+
+		for (var i = 0; i < 7; i++) {
+			var day = new Date(thismonday.getTime() + (wkoff*7+i)*24*3600*1000);
+			var tm = hist.get(datekey(day)) || 0;
+			var descr = Math.round(tm/1000/3600*10)/10 + "h";
+			var target = (day.getDay()+6)%7 < 5 ? 38/5 - 1 : 0;
+			var diff = tm - target*3600*1000;
+			var surplus = 1 - Math.exp(-(diff>0?diff:0)/(3*3600*1000));
+			var deficit = 1 - Math.exp(-(diff<0?-diff:0)/(3*3600*1000));
+			td = document.createElement("td");
+			td.appendChild(document.createTextNode(descr));
+			td.setAttribute("style",
+				"background-color:rgb(" +
+				(255 - 255*surplus) + "," +
+				(255 - 255*(deficit+surplus)) + "," +
+				(255 - 255*deficit) + ")"
+			);
+			if (day.getTime() == today.getTime()) td.classList.add("today");
+			tr.appendChild(td);
+		}
+
+		if (wkoff == -1) {
+			// "prev" refers to "all weeks before this week"
+			var thisWeekTotal = weekTotals.get(datekey(thismonday)) || 0;
+			var prevTotal = totalTime - thisWeekTotal;
+				var numPrevWeeks = weekTotals.size - (thisWeekTotal > 0);
+			var prevTarget = numPrevWeeks * 5 * dayTargetH;
+			var prevSurplus = prevTotal - prevTarget*3600*1000;
+			var descr = Math.round(prevSurplus/1000/3600*10)/10 + "h";
+
+			td = document.createElement("td");
+			var span = document.createElement("span");
+			span.setAttribute("style", "display:inline-block;width: max-content;");
+			span.appendChild(document.createTextNode("Account: " + descr));
+			td.appendChild(span);
+			tr.appendChild(td);
+		}
+
+		tb.appendChild(tr);
+	}
+}
+
+function handleReceivedList(list){
+	lastlist = list;
+	refreshlist(list);
+	refreshcalendar(list);
+}
+
+function updateCurrentBox(current) {
+	var sheet = current ? current.sheet : "";
+	var text = current ? current.text : "";
+	var indate = current ? toinputdate(current.indate) : "";
+
+	var e = document.getElementById("currentsheet");
+	e.innerHTML = "";
+	e.appendChild(document.createTextNode(sheet));
+	e = document.getElementById("currenttext");
+	e.innerHTML = "";
+	e.appendChild(document.createTextNode(text));
+	e = document.getElementById("currentindate");
+	e.innerHTML = "";
+	e.appendChild(document.createTextNode(indate));
+
+	var box = document.getElementById("currentbox");
+	var inputs = box.getElementsByTagName("input");
+	if (current == null) {
+		for (var i = 0; i < inputs.length; i++) inputs[i].setAttribute("disabled", "");
+		box.classList.add("disabled");
+		box.classList.remove("enabled");
+	} else {
+		for (var i = 0; i < inputs.length; i++) inputs[i].removeAttribute("disabled");
+		box.classList.add("enabled");
+		box.classList.remove("disabled");
+	}
+}
+
+function getlist(isinitial){
+	fetch("GET", ROOT_ENDPOINT + "/list", function(status, body) {
+		if (status != 200) {
+			alert("Error: "+body);
+			return;
+		}
+		var obj;
+		try {
+			obj = JSON.parse(body);
+		} catch(e){
+			alert("An error occurred!");
+			return;
+		}
+		var list = obj.list, current = obj.current;
+		for (var i = 0; i < list.length; i++) {
+			list[i].indate = new Date(list[i].indate);
+			list[i].outdate = new Date(list[i].outdate);
+		}
+		if (current != null) current.indate = new Date(current.indate);
+		handleReceivedList(list);
+		updateCurrentBox(current);
+		if (isinitial) document.getElementById("checkinbox").scrollIntoView();
+		dateToNow("checkindate");
+		dateToNow("checkoutdate");
+	});
+}
+
+function getsheets() {
+	fetch("GET",ROOT_ENDPOINT+"/sheets",function(status,body){
+		if(status!=200){
+			// alert("Error: "+body);
+			return;
+		}
+		var sheets;
+		try {
+			sheets=JSON.parse(body);
+		} catch(e){
+			// alert("An error occurred!");
+			return;
+		}
+
+		var el=document.getElementById("sheetselect");
+		el.innerHTML = "<option value=\"\" selected></option>";
+
+		for (var i = 0; i < sheets.length; i++) {
+			var opt = document.createElement("option");
+			opt.value = sheets[i];
+			opt.innerHTML = sheets[i];
+			el.appendChild(opt);
+		}
+	});
+}
+
+function doCheckin(atdate) {
+	if (atdate) atdate = new Date(document.getElementById("checkindate").value);
+	else atdate = new Date();
+	var sheet=document.getElementById("checkinsheet").value;
+	var text=document.getElementById("checkintext").value;
+	fetch("POST", ROOT_ENDPOINT + "/checkin", JSON.stringify({
+		sheet: sheet,
+		text: text,
+		date: atdate
+	}), function(status, body) {
+		if (status != 200) {
+			alert("Error performing check-in: " + body);
+			return;
+		}
+		getlist(false);
+	});
+}
+
+function doCheckout(atdate) {
+	if (atdate) atdate = new Date(document.getElementById("checkoutdate").value);
+	else atdate = new Date();
+	fetch("POST", ROOT_ENDPOINT + "/checkout", JSON.stringify({
+		date: atdate
+	}), function(status, body) {
+		if (status != 200) {
+			alert("Error performing check-out: " + body);
+			return;
+		}
+		getlist(false);
+	});
+}
+
+function setSheetFromSelect() {
+	document.getElementById("checkinsheet").value =
+		document.getElementById("sheetselect").value;
+}
+
+function logoutReload(){
+	fetch("GET", ROOT_ENDPOINT + "/authfail", undefined, ["x", "x"], function(status, body) {
+		location.href = location.href;
+	});
+}
+
+function dateToNow(textboxid) {
+	document.getElementById(textboxid).value = toinputdate(new Date());
+}
+
+window.addEventListener("load", function() {
+	getlist(true);
+	getsheets();
+	dateToNow("checkindate");
+	dateToNow("checkoutdate");
+});
+</script>
+<style>
+body{
+	font-family:Georgia,Times,serif;
+	font-size:14px;
+}
+.event{
+	border:1px #ddd solid;
+	border-bottom-width:0px;
+	padding:0px 9px;
+	background-color:#f8f8f8;
+	width:500px;
+}
+.event:last-child{
+	border-bottom-width:1px;
+}
+.eventsheet{
+	font-size:20px;
+	margin-left:10px;
+}
+.eventtext{
+	font-size:15px;
+	margin-left:10px;
+	width:150px;
+	display:inline-block;
+}
+.eventfloat{
+	float:right;
+	text-align:right;
+}
+.eventdate{
+	font-size:12px;
+	font-style:italic;
+	display:inline-block;
+	margin-top:6px;
+}
+.eventbuttons{
+	display:inline-block;
+	margin-left:60px;
+	font-size:10px;
+	vertical-align:middle;
+	width:10px;
+	text-align:center;
+}
+.eventdelete{
+	margin-bottom:5px;
+	font-size:10px;
+	font-family:sans-serif;
+	color:red;
+	cursor:pointer;
+}
+#currentbox.enabled{
+	background-color: #dfd;
+}
+#currentbox.disabled{
+	background-color: #eee;
+}
+#currentsheet{
+	font-weight: bold;
+}
+#checkinbox, #currentbox{
+	border:1px #ddd solid;
+	display:inline-block;
+	padding:5px;
+}
+#checkinbox > input[type="text"], #currentbox > input[type="text"] {
+	margin-bottom: 5px;
+}
+#logoutwrapper{
+	float:right;
+}
+#calendartb td{
+	width:35px;
+}
+#calendartb td:first-child{
+	text-align:right;
+	width:50px;
+}
+#calendartb td.today{
+	border:1px black solid;
+	font-weight:bold;
+}
+</style>
+</head>
+<body>
+<div id="logoutwrapper">
+	<input type="button" onclick="logoutReload();" value="Logout">
+</div>
+<h1>TimeTrack</h1>
+<div id="eventlist"></div>
+<br><br>
+<div id="currentbox">
+	Checked into: <span id="currentsheet"></span> (<span id="currenttext"></span>)<br>
+	Check-in at: <span id="currentindate"></span><br><br>
+
+	<input type="button" onclick="doCheckout(false)" value="Check out now"><br>
+	Or: <input type="datetime" id="checkoutdate" placeholder="YYYY-MM-DD HH:MM:SS" size="25">
+	<input type="button" onclick="dateToNow('checkoutdate')" value="now"><br>
+	<input type="button" onclick="doCheckout(true)" value="Check out at date">
+</div>
+<br><br>
+<div id="checkinbox">
+	Sheet: <input type="text" id="checkinsheet" placeholder="Sheet">
+	<select id="sheetselect" onchange="setSheetFromSelect()"></select> <br>
+	Text: <input type="text" id="checkintext" placeholder="Text"> (optional) <br>
+
+	<input type="button" onclick="doCheckin(false)" value="Check in now"><br>
+	Or: <input type="datetime" id="checkindate" placeholder="YYYY-MM-DD HH:MM:SS" size="25">
+	<input type="button" onclick="dateToNow('checkindate')" value="now"><br>
+	<input type="button" onclick="doCheckin(true)" value="Check in at date">
+</div>
+<br><br>
+<div id="calendar">
+	<table><tbody id="calendartb"></tbody></table>
+</div>
+</body>
+</html>
diff --git a/modules/timetrack2/timetrack2.js b/modules/timetrack2/timetrack2.js
new file mode 100644
index 0000000..cb22132
--- /dev/null
+++ b/modules/timetrack2/timetrack2.js
@@ -0,0 +1,270 @@
+"use strict";
+
+var cmn = require("../$common.js"),
+    persist = require("node-persist"),
+    crypto = require("crypto"),
+    basicAuth = require("basic-auth"),
+    fs = require("fs");
+
+var moddir = null;
+
+var ROOT_ENDPOINT = "/timetrack2";
+
+persist = persist.create({
+	dir: cmn.persistdir + "/timetrack2",
+	continuous: false,
+	interval: false
+});
+persist.initSync();
+
+//accounts: {
+//  "user": {
+//    "list": [{id: Int, sheet: String, text: String, indate: Date, outdate: Date}],
+//    "current": {sheet: String, text: String, indate: Date}/null,
+//    "pwhash": hash (String)
+//  }
+//}
+var nextid = persist.getItemSync("nextid");
+if (nextid == null) {
+	nextid = 1;
+	persist.setItemSync("nextid", nextid);
+}
+var accounts = persist.getItemSync("accounts");
+var naccounts = 0;
+(function() {
+	if (accounts == null) {
+		accounts = {};
+		persist.setItemSync("accounts", accounts);
+	} else {
+		for (var user in accounts) {
+			naccounts++;
+			for (var ev of accounts[user].list) {
+				ev.indate = new Date(ev.indate);
+				ev.outdate = new Date(ev.outdate);
+				if(nextid <= ev.id) nextid = ev.id+1;
+			}
+			if (accounts[user].current != null) {
+				accounts[user].current.indate = new Date(accounts[user].current.indate);
+			}
+		}
+		persist.setItemSync("nextid", nextid);
+	}
+})();
+
+
+function scryptHash(password, cb) {
+	crypto.randomBytes(16, function(err, salt){
+		if (err) {
+			cb(err, null);
+			return;
+		}
+		crypto.scrypt(password, salt, 32, function(err, key){
+			if (err) cb(err,null);
+			else cb(null, salt.toString("hex") + "$" + key.toString("hex"));
+		});
+	});
+}
+
+function scryptCompare(password, hash, cb){
+	hash = hash.split("$");
+	if (hash.length != 2) {
+		cb(new Error("Invalid hash in database"), null);
+		return;
+	}
+	var salt = Buffer.from(hash[0], "hex"), shash = hash[1];
+	crypto.scrypt(password, salt, 32, function(err, key){
+		if (err) cb(err, null);
+		else if(key.toString("hex") == shash) cb(null, true);
+		else cb(null, false);
+	});
+}
+
+
+function sendUnauth(res) {
+	res.set("WWW-Authenticate", "Basic realm=Authorization required");
+	return res.sendStatus(401);
+}
+
+function unknownUserHandler(req, res, next){
+	res.sendFile(moddir + "/unknownuser.html");
+}
+
+function authMiddleware(req, res, next){
+	var user = basicAuth(req);
+	req.authuser = null;
+	if (!user || !user.name) {
+		sendUnauth(res);
+		return;
+	}
+	req.authuser = user.name;
+	if (accounts[req.authuser]) {
+		scryptCompare(user.pass, accounts[req.authuser].pwhash, function(err, ok){
+			if (ok) next();
+			else sendUnauth(res);
+		});
+	} else {
+		unknownUserHandler(req, res, next);
+	}
+}
+
+function asciiValid(str) {
+	var i, c;
+	for(i = 0; i < str.length; i++) {
+		c = str.charCodeAt(i);
+		if (c < 32 || c >= 127) return false;
+	}
+	return true;
+}
+
+
+module.exports = function(app, io, _moddir){
+	moddir = _moddir;
+
+	//first the endpoints that need to bypass authMiddleware
+	app.get(ROOT_ENDPOINT + "/authfail", function(req, res){
+		sendUnauth(res);
+	});
+	app.post(ROOT_ENDPOINT + "/createuser", function(req, res){
+		var user = basicAuth(req);
+		if (!user || !user.name) {
+			res.status(400).send("No credentials sent");
+			return;
+		}
+		if (user.name.length < 3 || user.name.length > 32 || !asciiValid(user.name)) {
+			res.status(400).send("Invalid username");
+			return;
+		}
+		if (user.pass.length < 3 || user.pass.length > 32 || !asciiValid(user.pass)) {
+			res.status(400).send("Invalid password");
+			return;
+		}
+		if (accounts[user.name]) {
+			res.status(400).send("User already exists");
+			return;
+		}
+		if (naccounts >= 20) {
+			res.status(500).send("Too many accounts created, please contact Tom...");
+			return;
+		}
+		scryptHash(user.pass, function(err, hash){
+			if (!hash) {
+				res.status(500).send("Something went wrong...");
+				console.log(err);
+				return;
+			}
+			accounts[user.name] = {
+				list: [],
+				current: null,
+				pwhash: hash
+			};
+			naccounts++;
+			persist.setItemSync("accounts", accounts);
+			res.status(200).end();
+		});
+	});
+
+	app.all([ROOT_ENDPOINT, ROOT_ENDPOINT+"/*"], authMiddleware); //for all the other endpoints
+
+	app.get(ROOT_ENDPOINT, function(req, res){
+		res.sendFile(moddir + "/timetrack.html");
+	});
+	app.get(ROOT_ENDPOINT + "/list", function(req, res){
+		res.json({
+			list: accounts[req.authuser].list,
+			current: accounts[req.authuser].current,
+		});
+	});
+	app.get(ROOT_ENDPOINT + "/sheets", function(req, res){
+		var seen = new Map();
+		var list = [];
+		for (var i = 0; i < accounts[req.authuser].list.length; i++){
+			if (!seen.has(accounts[req.authuser].list[i].sheet)) {
+				seen.set(accounts[req.authuser].list[i].sheet, 0);
+				list.push(accounts[req.authuser].list[i].sheet);
+			}
+		}
+		res.json(list);
+	});
+	app.delete(ROOT_ENDPOINT + "/event", function(req, res){
+		var id = +req.body;
+		var i;
+		var fail = false;
+		if (id < 0 || ~~id != id || isNaN(id) || !(req.authuser in accounts)) {
+			fail = true;
+		} else {
+			var userevents = accounts[req.authuser].list;
+			for (i = 0; i < userevents.length; i++) if (userevents[i].id == id) break;
+			if (i == userevents.length) fail = true;
+			else {
+				userevents.splice(i, 1);
+				persist.setItemSync("accounts", accounts);
+			}
+		}
+		if (fail) res.status(404).send("Unknown id");
+		else res.status(200).end();
+	});
+	app.post(ROOT_ENDPOINT + "/checkin", function(req, res){
+		var obj;
+		try {
+			obj = JSON.parse(req.body);
+		} catch (e) {
+			res.status(400).send("Invalid request");
+			return;
+		}
+		var sheet = obj.sheet + "", text = obj.text + "", date = new Date(obj.date);
+		if (sheet.length == 0 || isNaN(date.getTime())) {
+			res.status(400).send("Invalid data");
+			return;
+		}
+		if (accounts[req.authuser].list.length >= 4000) {
+			res.status(400).send("Isn't 4000 events enough for you?");
+			return;
+		}
+		if (accounts[req.authuser].current != null) {
+			res.status(409).send("Already checked in");
+			return;
+		}
+		accounts[req.authuser].current = {
+			sheet: sheet,
+			text: text,
+			indate: date,
+		};
+		persist.setItemSync("accounts", accounts);
+		res.status(200).end();
+	});
+	app.post(ROOT_ENDPOINT + "/checkout", function(req, res){
+		var obj;
+		try {
+			obj = JSON.parse(req.body);
+		} catch (e) {
+			res.status(400).send("Invalid request");
+			return;
+		}
+		var date = new Date(obj.date);
+		if (isNaN(date.getTime())) {
+			res.status(400).send("Invalid data");
+			return;
+		}
+		if (accounts[req.authuser].current == null) {
+			res.status(409).send("Not checked in");
+			return;
+		}
+		if (accounts[req.authuser].list.length >= 4000) {
+			res.status(400).send("Isn't 4000 events enough for you?");
+			return;
+		}
+		var current = accounts[req.authuser].current;
+		accounts[req.authuser].list.push({
+			id: nextid++,
+			sheet: current.sheet,
+			text: current.text,
+			indate: current.indate,
+			outdate: date,
+		});
+		accounts[req.authuser].current = null;
+		accounts[req.authuser].list.sort(function(a, b) { return a.indate - b.indate; });
+		persist.setItemSync("accounts", accounts);
+		persist.setItemSync("nextid", nextid);
+		res.status(200).end();
+	});
+};
diff --git a/modules/timetrack2/unknownuser.html b/modules/timetrack2/unknownuser.html
new file mode 100644
index 0000000..e2142a1
--- /dev/null
+++ b/modules/timetrack2/unknownuser.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack: Unknown user</title>
+<script>
+"use strict";
+
+var ROOT_ENDPOINT="/timetrack2";
+
+function fetch(method,url,data/*?*/,creds/*?*/,cb){
+	if(!creds){
+		cb=data;
+		data=undefined;
+		creds=undefined;
+	} else if(!cb){
+		cb=creds;
+		creds=undefined;
+	}
+	if(!cb)throw new Error("No callback passed to fetch");
+	var xhr=new XMLHttpRequest();
+	xhr.onreadystatechange=function(ev){
+		if(xhr.readyState<4)return;
+		cb(xhr.status,xhr.responseText);
+	};
+	if(creds){
+		xhr.open(method,url,true,creds[0],creds[1]);
+	} else {
+		xhr.open(method,url);
+	}
+	xhr.send(data);
+}
+
+function asciiValid(str){
+	var i,c;
+	for(i=0;i<str.length;i++){
+		c=str.charCodeAt(i);
+		if(c<32||c>=127)return false;
+	}
+	return true;
+}
+
+function logoutReload(){
+	fetch("GET",ROOT_ENDPOINT+"/authfail",undefined,["baduser","badpass"],function(status,body){
+		location.href=location.href;
+	});
+}
+
+function doCreateUser(){
+	var username=document.getElementById("username").value;
+	var password=document.getElementById("password").value;
+	var fail=false;
+	[["Username",username],["Password",password]].forEach(function(name,value){
+		if(value.length<3){fail=true;alert(name+" too short!");}
+		else if(value.length>32){fail=true;alert(name+" too long!");}
+		else if(!asciiValid(value)){fail=true;alert("Invalid "+name.toLowerCase()+"! Please use only ASCII characters.");}
+	});
+	if(fail)return;
+
+	fetch("POST",ROOT_ENDPOINT+"/createuser",undefined,[username,password],function(status,body){
+		if(status==200){
+			alert("User \""+username+"\" created successfully. Please login.");
+			logoutReload();
+		} else {
+			alert("Error: "+body);
+		}
+	});
+}
+</script>
+<style>
+body{
+	font-family:Georgia,Times,serif;
+	font-size:14px;
+}
+</style>
+</head>
+<body>
+<h1>TimeTrack: Unknown user</h1>
+<p>The user you entered is not known in the system. You can use the form below to create a new user.
+Be aware: this system is not secure.</p>
+Username: <input type="text" id="username" placeholder="username"><br>
+Password: <input type="password" id="password" placeholder="password"><br>
+<input type="button" value="Create user" onclick="doCreateUser();">
+<br><br>
+<p>You can also <input type="button" onclick="logoutReload();" value="log out and try again"> if you just can't type.</p>
+</body>
+</html>
diff --git a/timetrack-migrate.js b/timetrack-migrate.js
new file mode 100644
index 0000000..c94b1b5
--- /dev/null
+++ b/timetrack-migrate.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+
+// accounts: {"user": hash (String)}
+const origaccounts_obj = JSON.parse(fs.readFileSync("persist/timetrack/7a90e38a211ece1c346928e7d1f3e968"));
+const origaccounts = origaccounts_obj.value;
+
+// events: {"user": [{id: Int,sheet: String,text: String,date: Date,type: String}]}
+//   type: "in" / "out"
+const origevents_obj = JSON.parse(fs.readFileSync("persist/timetrack/16908b0605f2645dfcb4c3a8d248cef3"));
+const origevents = origevents_obj.value;
+
+const destfname = "persist/timetrack2/7a90e38a211ece1c346928e7d1f3e968";
+
+const accounts = {};
+for (let user in origaccounts) {
+	accounts[user] = {
+		list: [],
+		current: null,
+		pwhash: origaccounts[user],
+	};
+
+	let current = null;
+	for (let ev of origevents[user]) {
+		if (ev.type == "in" && current == null) {
+			current = ev;
+		} else if (ev.type == "out" && current != null) {
+			if (current.sheet != ev.sheet) {
+				console.error("Inconsistent in/out pairs (sheet for id " + ev.id + ")");
+				process.exit(1);
+			}
+
+			const text = current.text.length > ev.text.length ? current.text : ev.text;
+
+			accounts[user].list.push({
+				id: current.id,  // arbitrarily choose check-in id
+				sheet: current.sheet,
+				text: text,
+				indate: current.date,
+				outdate: ev.date,
+			});
+
+			current = null;
+		} else {
+			console.error("Inconsistent in/out pairs at id=" + ev.id);
+			process.exit(1);
+		}
+	}
+}
+
+fs.writeFileSync(destfname, JSON.stringify({key:"accounts",value:accounts}));
-- 
cgit v1.2.3-70-g09d2