Compare commits

...

12 Commits

Author SHA1 Message Date
e7b8993446 Bump version to 1.1.0 2021-06-11 02:12:42 +02:00
39a461df56 Move VAPID public key config to backend for easier config
Also enables the frontend not asking for notiffication permission if it
doesn't need them.
Should also help if it ever needs to be changed to circumvent cache.
2021-06-11 01:57:32 +02:00
711fbfd821 Update docs 2021-06-11 01:28:56 +02:00
3a872bceb2 Enable Docker container to generate its own VAPID credentials 2021-06-11 01:06:59 +02:00
4ee4869f82 Minor fixes & improvements
- Move push subscription handling to Notifier class
- Allow duplicate options in config
- Only save subscription in frontend if sub call was successful
2021-06-11 01:04:44 +02:00
618f00a09a Send notifications N hours after arrival, polling once per hour 2021-06-10 23:14:15 +02:00
b8e704c300 Update frontend to allow requesting and receiving notifications
Includes requesting permissions and adding a basic service worker
as well as re-working and restructuring a bunch of the existing logic
for parsing URL parameters, sending data and adding another form field
2021-06-08 00:56:29 +02:00
e521edd62b Add basic webpush backend functionality and endpoints for testing 2021-06-08 00:48:11 +02:00
e7a3a0a673 Update config to search non-repo local files (in /etc) first
In order to allow for secrets in the local config file that should be
nowhere near a public repo
2021-06-08 00:47:00 +02:00
c70a419160 Minor cleanup/fixes 2021-06-07 17:41:04 +02:00
cb6568ea46 Allow giving test result even on retroactive arrivals (Resolves #25) 2021-05-27 16:26:25 +02:00
73241c4116 Split down files and add question about covid-test 2021-04-12 21:22:51 +02:00
17 changed files with 1097 additions and 686 deletions

View File

@ -1,7 +1,9 @@
FROM alpine:latest FROM alpine:latest
RUN apk add --update --no-cache \ RUN apk add --update --no-cache \
bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx npm jq
RUN npm install -g web-push

View File

@ -161,3 +161,40 @@ jänE doé
``` ```
would still work, but `Jane D` wouldn't). would still work, but `Jane D` wouldn't).
### Automatic data deletion (GDPR compliance)
The `delete_after_days` configuration option can be set to a number of days
after which attendance records are purged from the database. If it is not set
(or empty) automatic deletion is deactivated. Automatic deletion is final and
non-recoverable. This option is intended to help make the system fully GDPR
compliant by guaranteeing deletion after a certain period. Keep in mind that a
legally binding data protection guideline and user consent are still required.
### User notification on forgotten sign-out
`ftracker` is capable of notifying users if they forgot to sign-out at the end
of a day using modern web push notifications using the VAPID system. To make
this work, a few things are needed:
Firstly, you need an EC-Prime256v1 keypair in base64url encoding. If you're
using the Docker container, this is automatically generated for you. If not,
the easiest way to create one is to install the `web-push` `npm` package and
run it:
```bash
sudo npm install -g web-push
web-push generate-vapid-keys
```
The keys then need to be copied into the config options `push_public_key` and
`push_private_key` respectively so the backend can handle the rest.
Next, to be VAPID compliant you have to announce an contact address claim to
the push services so they can contact you if anything is going wrong with your
notifications. Do this by entering your email address as a `mailto:` link in
the `push_sender_info` option, like `mailto:it@fasttube.de`.
Finally, you can use the `notify_after_hrs` option to specify how long the
system should wait after a user's arrival to notify them of their missing
departure.

View File

@ -24,3 +24,13 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St
# JSON indentation for debugging # JSON indentation for debugging
json_indent = 4 json_indent = 4
# VAPID credentials for push notifications
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
# sender info: usually mailto link to responsible party to contact about issues
push_public_key = BBwBPYxhogHLU3B1FpxfQNzO3q7qZpmD1n1KaaL8WJbcVmJSHhi1uB-VmvsVjjUHWYCeqKyLT7w-1LBfpIcbbcg
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
push_sender_info = mailto:it@fasttube.de
# when to notify users, in hours after arrival
notify_after_hrs = 10

View File

@ -6,6 +6,6 @@
# Corona time tracker # Corona time tracker
VERSION = (1, 0, 0) VERSION = (1, 1, 0)
__version__ = '.'.join(map(str, VERSION)) __version__ = '.'.join(map(str, VERSION))

View File

@ -9,17 +9,17 @@ class Config:
def findConfigFile(): def findConfigFile():
if len(sys.argv) > 1: if len(sys.argv) > 1:
return sys.argv[1] return sys.argv[1]
elif Path('config.ini').is_file():
return 'config.ini'
elif Path('/etc/ftracker/config.ini').is_file(): elif Path('/etc/ftracker/config.ini').is_file():
return '/etc/ftracker/config.ini' return '/etc/ftracker/config.ini'
elif Path('config.ini').is_file():
return 'config.ini'
else: else:
return None return None
configfile = findConfigFile() configfile = findConfigFile()
if configfile: if configfile:
self.config = ConfigParser() self.config = ConfigParser(strict=False)
self.config.read(configfile) self.config.read(configfile)
else: else:
raise Exception("No config file found") raise Exception("No config file found")
@ -30,3 +30,6 @@ class Config:
def __getitem__(self, key): def __getitem__(self, key):
return self.config['global'].get(key) return self.config['global'].get(key)
def __repr__(self):
return repr(self.config.items('global'))

View File

@ -26,6 +26,15 @@ if config['delete_after_days']:
from .deleter import Deleter from .deleter import Deleter
deleter = Deleter(db, int(config['delete_after_days'])) deleter = Deleter(db, int(config['delete_after_days']))
if config['notify_after_hrs']:
from .notifier import Notifier
notifier = Notifier(db, int(config['notify_after_hrs']), {
'private_key': config['push_private_key'],
'claims': {'sub': config['push_sender_info']}
})
def shutdown(): def shutdown():
print('\rReceived stop signal, stopping threads...') print('\rReceived stop signal, stopping threads...')
deleter.stop() deleter.stop()
@ -33,6 +42,7 @@ def shutdown():
atexit.register(shutdown) atexit.register(shutdown)
@app.route('/guidelines') @app.route('/guidelines')
def get_guidelines(): def get_guidelines():
dest = config['guideline_url'] or None dest = config['guideline_url'] or None
@ -77,6 +87,7 @@ def post_arrival():
now = datetime.utcnow().isoformat() + 'Z' now = datetime.utcnow().isoformat() + 'Z'
db.insert({ db.insert({
'name': name, 'name': name,
'tested': data.get('tested') or False,
'room': data['room'], 'room': data['room'],
'arrival': data.get('arrival') or now, 'arrival': data.get('arrival') or now,
'departure': None 'departure': None
@ -158,3 +169,62 @@ def get_data():
) )
return json.dumps(r, indent=SPACES), 200 return json.dumps(r, indent=SPACES), 200
@app.route('/pushinfo')
def get_pushinfo():
if config['notify_after_hrs']:
r = {
'enabled': True,
'publickey': config['push_public_key']
}
else:
r = {
'enabled': False,
'publickey': None
}
return json.dumps(r, indent=SPACES), 200
@app.route('/pushsubscribe', methods=['POST'])
def post_pushsub():
try:
payload = request.data.decode('UTF-8')
data = json.loads(payload)
except ValueError as e:
return 'Error: JSON decode error:\n' + str(e), 400
notifier.subscribe_user(data)
return 'OK', 201
@app.route('/testpush/<name>')
def testpush(name):
if not 'Authorization' in request.headers:
return 'Error: No Authorization', 401, {'WWW-Authenticate': 'Basic'}
if request.authorization.username != config['admin_user']:
return "Wrong username", 403
if request.authorization.password != config['admin_pass']:
return "Wrong password", 403
Entry = Query()
arrivals = db.search((Entry.name == name) & (Entry.departure == None))
if len(arrivals) == 0:
print("User is not logged in :(")
return "Error: User is not logged in :(", 409
error = notifier.notify_user(arrivals[0])
if error:
return 'Error: ' + error, 404
return 'OK', 201

97
ftracker/notifier.py Normal file
View File

@ -0,0 +1,97 @@
import json
from slugify import slugify
from threading import Thread, Event
from datetime import datetime, timedelta
from tinydb import Query
from pywebpush import webpush, WebPushException
ONE_HOUR_IN_S = 60 * 60
class Notifier(Thread):
def subscribe_user(self, data):
pushsubs = self.db.table('pushsubs')
name = slugify(data['name'])
data['name'] = name
Entry = Query()
pushsubs.upsert(data, Entry.name == name)
def notify_user(self, arrival):
pushsubs = self.db.table('pushsubs')
Entry = Query()
ps = pushsubs.search(Entry.name == arrival['name'])
if len(ps) == 0:
print("User is not subscribed to notifications :(")
return "User is not subscribed to notifications :("
ps = ps[0]
print("Sending notification", arrival, ps)
subscription = ps['sub']
notification = {
'title': "Forgot to sign out?",
'body': "You didn't sign out of ftracker yet",
'arr': arrival
}
try:
webpush(
subscription,
json.dumps(notification),
vapid_private_key = self.vapid_creds['private_key'],
vapid_claims = self.vapid_creds['claims']
)
print("Notification sent")
return None
except WebPushException as exc:
print("Notification failed", exc)
return repr(exc)
def notify_logged_in_users(self):
print("Notifying users that aren't signed out yet...")
td = timedelta(hours=self.hours)
threshold = datetime.utcnow() - td
iso = threshold.isoformat() + 'Z'
Entry = Query()
arrivals = self.db.search(
(Entry.arrival < iso) & (Entry.departure == None)
)
for arrival in arrivals:
self.notify_user(arrival)
print("Notified everything until UTC", iso)
def __init__(self, db, hours, vapid_creds):
self.db = db
self.hours = hours
self.vapid_creds = vapid_creds
self.notify_logged_in_users()
Thread.__init__(self, daemon=True)
self.stopped = Event()
self.start()
def run(self):
while not self.stopped.wait(ONE_HOUR_IN_S):
self.notify_logged_in_users()
def stop(self):
self.stopped.set()

View File

@ -4,6 +4,9 @@
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage # Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
db_file = /var/ftracker/db.json db_file = /var/ftracker/db.json
# Delete all information after X days (e.g. for GDPR compliance)
delete_after_days = 28
# List of people to be allowed, in .csv format (comma, no delimiters) # List of people to be allowed, in .csv format (comma, no delimiters)
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail # Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
# Remove or leave empty for no check # Remove or leave empty for no check
@ -21,3 +24,13 @@ guideline_url = https://youtu.be/oHg5SJYRHA0
# JSON indentation for debugging # JSON indentation for debugging
json_indent = 4 json_indent = 4
# VAPID credentials for push notifications
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
# sender info: usually mailto link to responsible party to contact about issues
push_public_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
push_sender_info = mailto:admin@example.com
# when to notify users, in hours after arrival
notify_after_hrs = 10

View File

@ -1,5 +1,24 @@
#!/bin/bash #!/bin/bash
echo " >>> Checking / Creating & patching VAPID creds <<< "
VAPID_CREDS_FILE=/etc/ftracker/vapid-creds.json
if [[ ! -f $VAPID_CREDS_FILE ]]
then
echo "Generating keypair ..."
web-push generate-vapid-keys --json > $VAPID_CREDS_FILE
echo "Patching keypair into config ..."
PUB_KEY=`cat $VAPID_CREDS_FILE | jq -r .publicKey`
echo "pushServerPublicKey = ${PUB_KEY}" >> /var/www/html/ftracker/main.js
PRIV_KEY=`cat $VAPID_CREDS_FILE | jq -r .privateKey`
echo "push_private_key = ${PRIV_KEY}" >> /etc/ftracker/config.ini
fi
echo " >>> Starting nginx <<< " echo " >>> Starting nginx <<< "
mkdir /run/nginx # needed because of bug in package mkdir /run/nginx # needed because of bug in package

View File

@ -8,7 +8,7 @@ with open("LICENSE.md", "r") as f:
st.setup( st.setup(
name="ftracker", name="ftracker",
version="1.0.0", version="1.1.0",
author="Oskar @ FaSTTUBe", author="Oskar @ FaSTTUBe",
author_email="o.winkels@fasttube.de", author_email="o.winkels@fasttube.de",
description="Small webapp to track who was in which room at which time to backtrace potential viral infections", description="Small webapp to track who was in which room at which time to backtrace potential viral infections",
@ -20,6 +20,7 @@ st.setup(
"flask", "flask",
"tinydb", "tinydb",
"python-slugify", "python-slugify",
"pywebpush",
], ],
license=license_text, license=license_text,
classifiers=[ classifiers=[

View File

@ -2,313 +2,78 @@
<html> <html>
<head> <head>
<title>FTracker</title> <title>FTracker</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f"> <meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style> <link href="style.css" rel="stylesheet" type="text/css">
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form {
padding: 16px;
max-width: 512px;
margin: auto;
}
label {
display: block;
font-size: 16px;
margin-bottom: 16px;
color: #444;
}
label#agreelabel {
height: 32px;
line-height: 32px;
}
label span {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
line-height: normal;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit], input[type=button] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input[type=checkbox] {
transform: translateY(-3px);
float: left;
height: 32px;
width: 32px;
margin-right: 8px;
}
.request {
display: block;
position: fixed;
background: #ddd;
top: 16px;
left: 16px;
width: calc(100% - 32px);
box-shadow: 0 1px 4px 0;
}
input[type=datetime-local] {
width: calc(100% - 24px);
padding: 12px;
font-size: 12px;
background: #fff;
}
</style>
<script> <script>
// 1st script, prepares values needed for writing document // 1st script, prepares values needed for writing document
var cbt = { var cbt = {
'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>', 'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>',
'departure': 'I have cleaned my workspace' 'departure': 'I have cleaned my workspace'
} }
var testCheckBox = '<label class="checkbox"><input type="checkbox" name="tested" id="tested"><span>I have been tested negative for COVID in the last 24 hours</span></label>'
var editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>'
function getParams() { function getParams() {
var h = document.location.href var qparams = document.location.search.substr(1)
var qparam = h.split('?')[1] || null if (qparams == "") return {}
if (qparam == null) qparams = qparams.split('&')
return null var qps = {}
for (var qparam of qparams) {
var vals = qparam.split('=') var vals = qparam.split('=')
if (vals.length < 2 || !cbt.hasOwnProperty(vals[0])) qps[vals[0]] = vals[1] || null
return null
return {
action: vals[0],
room: vals[1]
} }
// Backwards compat
if (qps.arrival) {qps.action = 'arrival'; qps.room = qps.arrival}
if (qps.departure) {qps.action = 'departure'; qps.room = qps.departure}
return qps
} }
var qp = getParams() var qp = getParams()
</script> </script>
</head> </head>
<body> <body>
<h1><script> <h1><script>
if (qp) document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1')
document.write(qp.action + "<br>Room " + qp.room)
else
document.write('FTracker<br>V1')
</script></h1> </script></h1>
<form id="mainform"> <div id="startpage">
This is a web app to track which people
were in the same rooms at which times in order to backtrace
potential viral infections.<br><br>
If you've reached this page that either means your're
testing things or something has gone quite wrong with the\
URL.<br>
In the former case: Yay it works! In the latter you should
probably contact an admin or a dev nearby :(<br><br>
Here are a few links for testing:<br>
<a href="/view">View Data</a>,
<a href="/QRgen">Door Sign Generator</a>,
<a href="/?arrival=42">Test Arrival</a>,
<a href="/?departure=42">Test Departure</a><br><br>
&copy; 2020 made by <a target="_blank" href="mailto:&#111;&#46;&#119;&#105;&#110;&#107;&#101;&#108;&#115;&#64;&#102;&#97;&#115;&#116;&#116;&#117;&#98;&#101;&#46;&#100;&#101;">Oskar</a>
for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br>
For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/ftracker">git repo</a>
</div>
<form id="mainform" style="display: none">
<label> <label>
Full Name:<br> Full Name:<br>
<input type="text" name="name" id="name" placeholder="John Doe" required> <input type="text" name="name" id="name" placeholder="John Doe" required>
</label> </label>
<label id="agreelabel"> <script>
if (qp.edittime && qp.edittime == 1)
document.write(editTimeBox)
</script>
<label class="checkbox">
<input type="checkbox" name="agree" id="agree" required> <input type="checkbox" name="agree" id="agree" required>
<span><script> <span><script>
document.write(qp ? cbt[qp.action] : '') document.write(qp ? cbt[qp.action] : '')
</script></span> </script></span>
</label> </label>
<input type="submit">
</form>
<script> <script>
var mform = document.getElementById('mainform') if (qp.action && qp.action == 'arrival')
if (qp == null) { document.write(testCheckBox)
mform.innerHTML = 'This is a web app to track which people\
were in the same rooms at which times in order to backtrace\
potential viral infections.<br><br>\
If you\'ve reached this page that either means your\'re\
testing things or something has gone quite wrong with the\
URL.<br>\
In the former case: Yay it works! In the latter you should\
probably contact an admin or a dev nearby :(<br><br>\
Here are a few links for testing:<br>\
<a href="/view">View Data</a>, \
<a href="/QRgen">Door Sign Generator</a>, \
<a href="/?arrival=42">Test Arrival</a>, \
<a href="/?departure=42">Test Departure</a><br><br>\
&copy; 2020 made by <a target="_blank" href="mailto:&#111;&#46;&#119;&#105;&#110;&#107;&#101;&#108;&#115;&#64;&#102;&#97;&#115;&#116;&#116;&#117;&#98;&#101;&#46;&#100;&#101;">Oskar</a> \
for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br> \
For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/ftracker">git repo</a>'
}
// Prefill the name field if it was successfully entered before
var savedName = localStorage.getItem('name')
if (savedName && qp)
document.getElementById('name').value = savedName
// 2nd script, server API communication
var name, agreed
mform.onsubmit = function(e) {
e.preventDefault()
name = e.srcElement[0].value
agreed = e.srcElement[1].checked
sendMainData()
}
function sendMainData() {
// POST JSON. See docs/API.md
var payload = (qp.action == 'arrival') ?
{
'room': qp.room,
'name': name,
'agreetoguidelines': agreed
} :
{
'name': name,
'cleanedworkspace': agreed
}
post("/" + qp.action, payload)
}
function post(url, payload) {
console.log("Sending payload:", payload)
return fetch(url, {
method: "POST",
body: JSON.stringify(payload)
}).then(res => {
handleResponse(res)
})
}
function handleResponse(res) {
console.log("Request complete! response:", res)
if (Math.floor(res.status / 100) == 2) {
// Success
mform = document.getElementById('mainform')
mform.innerHTML = "<h2>Done. Thanks!</h2>"
localStorage.setItem('name', name)
} else if (res.status == 409) {
// Conflict, more data requested
handleRequest(res)
} else {
// Any other generic error
res.text().then(function (text) {
alert(text)
})
}
}
function handleRequestSubmit(e, json) {
e.preventDefault()
var input = e.srcElement[0].value
var iso = new Date(input).toISOString()
// POST JSON. See docs/API.md
var payload = (json.request == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': iso,
'agreetoguidelines': agreed
} :
{
'name': name,
'departure': iso,
'cleanedworkspace': agreed
}
return post("/" + json.request, payload)
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
function handleRequest(res) {
var reqt = {
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
}
mform.innerHTML = "<h2>Processing Request...</h2>"
res.json().then(function (json) {
var aInfo = ''
var minD = ''
var doubleT = ''
if (json.request == 'departure') {
var d = new Date(json.arrival.time)
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
minD = `min="${localISOTimeMinutes(d)}"`
if (new Date() - d < 3 * 60 * 1000) {
doubleT = '<b style="color:red">Your last sign in was less than 3 minutes ago. You might be accidentally trying to sign in twice. If you don\'t intend to log 2 arrivals within the last 3 minutes, please abort below.</b>'
}
}
var now = localISOTimeMinutes(new Date())
document.body.innerHTML +=
`<div class="request">
<h1>${json.request} missing!</h1>
<form id="reqform">
<label>
${reqt[json.request]}
<input type="datetime-local" id="datetime" ${minD} max="${now}" required>
${aInfo}
</label>
${doubleT}
<input type="submit">
<input type="button" value="Abort" onclick="document.body.innerHTML='<h1>Aborted</h1><form>Nothing was logged.<br>You can close this tab/window now.</form>'">
</form>
</div>`
rform = document.getElementById('reqform')
rform.onsubmit = async function(e) {
await handleRequestSubmit(e, json)
document.querySelector('.request').remove()
setTimeout(sendMainData, 200)
}
})
}
</script> </script>
<input type="submit">
</form>
<script src="main.js"></script>
</body> </body>
</html> </html>

278
web/main.js Normal file
View File

@ -0,0 +1,278 @@
var spage = document.getElementById('startpage')
var mform = document.getElementById('mainform')
if (qp.action) {
spage.style.display = 'none'
mform.style.display = 'block'
}
// Prefill the name field if it was successfully entered before
var savedName = localStorage.getItem('name')
if (savedName && qp)
document.getElementById('name').value = savedName
// 2nd script, server API communication
var name, datetime, agreed, tested
mform.onsubmit = function(e) {
e.preventDefault()
var i = 0;
name = e.srcElement[i++].value
if (qp.edittime && qp.edittime == 1) {
var value = e.srcElement[i++].value
datetime = new Date(value).toISOString()
}
agreed = e.srcElement[i++].checked
if (qp.action && qp.action == 'arrival')
tested = e.srcElement[i++].checked
sendMainData()
initPush(name)
}
function sendMainData() {
// POST JSON. See docs/API.md
var payload = (qp.action == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': datetime,
'agreetoguidelines': agreed,
'tested': tested
} :
{
'name': name,
'departure': datetime,
'cleanedworkspace': agreed
}
post("/" + qp.action, payload)
}
function post(url, payload) {
console.log("Sending payload:", payload)
return fetch(url, {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(res => {
handleResponse(res)
})
}
function handleResponse(res) {
console.log("Request complete! response:", res)
if (Math.floor(res.status / 100) == 2) {
// Success
mform = document.getElementById('mainform')
mform.innerHTML = "<h2>Done. Thanks!</h2>"
localStorage.setItem('name', name)
} else if (res.status == 409) {
// Conflict, more data requested
handleRequest(res)
} else {
// Any other generic error
res.text().then(function (text) {
alert(text)
})
}
}
function handleRequestSubmit(e, json) {
e.preventDefault()
var input = e.srcElement[0].value
var iso = new Date(input).toISOString()
if (e.srcElement.length > 1)
tested = e.srcElement[1].checked
// POST JSON. See docs/API.md
var payload = (json.request == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': iso,
'agreetoguidelines': agreed,
'tested': tested
} :
{
'name': name,
'departure': iso,
'cleanedworkspace': agreed
}
return post("/" + json.request, payload)
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
function handleRequest(res) {
var reqt = {
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
}
mform.innerHTML = "<h2>Processing Request...</h2>"
res.json().then(function (json) {
var aInfo = ''
var minD = ''
var doubleT = ''
var testCheck = ''
if (json.request == 'departure') {
var d = new Date(json.arrival.time)
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
minD = `min="${localISOTimeMinutes(d)}"`
if (new Date() - d < 3 * 60 * 1000) {
doubleT = '<b style="color:red">Your last sign in was less than 3 minutes ago. You might be accidentally trying to sign in twice. If you don\'t intend to log 2 arrivals within the last 3 minutes, please abort below.</b>'
}
} else if (json.request == 'arrival') {
testCheck = testCheckBox.replace('have', 'had then');
}
var now = localISOTimeMinutes(new Date())
document.body.innerHTML +=
`<div class="request">
<h1>${json.request} missing!</h1>
<form id="reqform">
<label>
${reqt[json.request]}
<input type="datetime-local" ${minD} max="${now}" required>
${aInfo}
</label>
${doubleT}
${testCheck}
<input type="submit">
<input type="button" value="Abort" onclick="document.body.innerHTML='<h1>Aborted</h1><form>Nothing was logged.<br>You can close this tab/window now.</form>'">
</form>
</div>`
var rform = document.getElementById('reqform')
rform.onsubmit = async function(e) {
await handleRequestSubmit(e, json)
document.querySelector('.request').remove()
setTimeout(sendMainData, 200)
}
})
}
if (qp.edittime && qp.edittime == 1) {
var now = localISOTimeMinutes(new Date())
document.getElementById('datetime').value = now;
document.getElementById('datetime').max = now;
}
/* Push Notifications */
function sendNotification() {
navigator.serviceWorker.ready.then(function(serviceWorker) {
serviceWorker.showNotification("Forgot to sign out?", {
body: "You didn't sign out of ftracker yet",
icon: "/favicon.ico",
actions: [{
action: "depart",
title: "Sign Out"
}]
})
})
}
function initPush(name) {
// Check availability
var supported = "serviceWorker" in navigator && "PushManager" in window
if (!supported) {
console.warn("Push Notifications not supported!")
return
}
fetch('/pushinfo').then(function(res) {
if (res.ok)
res.json().then(function(push) {
if (push.enabled)
registerPush(name, push.publickey);
});
});
}
function registerPush(name, pushServerPublicKey) {
// Register service worker
navigator.serviceWorker.register("/sw.js").then(function(swRegistration) {
console.log("ServiceWorker registered:", swRegistration)
})
// Request permission
// TODO: Only do this AFTER the first? SUCCESSFUL sign-in
Notification.requestPermission(function(result) {
return (result === 'granted')
}).then(function(consent) {
console.log('Notifications', consent ? 'enabled' : 'denied');
})
// Check if already initialized
if (localStorage.getItem('pushsub'))
return
// Register push service
navigator.serviceWorker.ready.then(function(serviceWorker) {
serviceWorker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: pushServerPublicKey
}).then(function(subscription) {
console.log("User is subscribed:", subscription);
fetch('/pushsubscribe', {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
name: name,
sub: subscription
})
}).then(function(res) {
if (res.ok)
localStorage.setItem('pushsub', subscription);
});
});
});
}

79
web/style.css Normal file
View File

@ -0,0 +1,79 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form, #startpage {
padding: 16px;
max-width: 512px;
margin: auto;
}
label {
display: block;
font-size: 16px;
margin-bottom: 16px;
color: #444;
}
label.checkbox {
height: 32px;
line-height: 32px;
}
label span {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
line-height: normal;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit], input[type=button] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input[type=checkbox] {
transform: translateY(-3px);
float: left;
height: 32px;
width: 32px;
margin-right: 8px;
}
.request {
display: block;
position: fixed;
background: #ddd;
top: 16px;
left: 16px;
width: calc(100% - 32px);
box-shadow: 0 1px 4px 0;
}
input[type=datetime-local] {
width: calc(100% - 24px);
padding: 12px;
font-size: 12px;
background: #fff;
}

35
web/sw.js Normal file
View File

@ -0,0 +1,35 @@
function receivePushNotification(event) {
var data = event.data.json();
console.log("[Service Worker] Push Received:", data)
var room = data.arr ? data.arr.room : 'test'
var options = {
data: `/?departure=${room}&edittime=1`,
body: data.body,
icon: "/favicon.ico",
actions: [{
action: "depart",
title: "Sign Out"
}]
};
event.waitUntil(self.registration.showNotification(data.title, options))
}
self.addEventListener("push", receivePushNotification)
function openPushNotification(event) {
console.log("[Service Worker] Notification click Received.", event.notification.data)
event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data))
}
self.addEventListener("notificationclick", openPushNotification)

121
web/view.css Normal file
View File

@ -0,0 +1,121 @@
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
}
header {
height: calc(38px - 16px);
padding: 8px;
white-space: nowrap;
overflow: hidden;
}
main {
height: calc(100% - 38px);
vertical-align: top;
}
main > section {
height: calc(100% - 32px);
display: inline-block;
vertical-align: top;
}
main > .viewheader {
display: inline-block;
vertical-align: top;
}
main > section.names, #nameheader {
width: 199px;
overflow: hidden;
border-right: 1px solid gray;
font-weight: bold;
text-transform: capitalize;
white-space: nowrap;
}
main > section.times, #timeheader {
width: calc(100% - 200px);
overflow: hidden;
}
.scroll {
height: 100%;
width: 100%;
overflow: scroll;
}
.row, .row #timelabels {
position: relative;
height: 32px;
background-size: 60px 100%;
}
.names .row:nth-child(even) { background: #ddd; }
.names .row:nth-child(odd) { background: #eee; }
.times .row:nth-child(even) {
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
}
.times .row:nth-child(odd) {
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
}
.row span {
height: 18px;
padding: 7px;
display: inline-block;
}
.times span, #timeheader span {
position: absolute;
margin-right: 16px;
}
.times span {
background: #c50e1f;
color: #ddd;
font-weight: bold;
-webkit-text-stroke: .4px #c50e1f;
}
.times span.implausible {
background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px);
}
.viewheader.row {
height: 30px;
background: #ddd !important;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
}
.viewheader span {
background: none !important;
color: #000 !important;
padding-left: 4px;
}
#credprompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 95%;
max-width: 320px;
margin: auto;
background: #ddd;
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
}
#credprompt h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
#credprompt input {
display: block;
border: none;
margin: 16px auto;
padding: 16px;
font-size: 16px;
}
#credprompt input[type=text],
#credprompt input[type=password] {
color: #000;
width: calc(100% - 64px);
}
#credprompt input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: calc(100% - 32px);
}

View File

@ -2,130 +2,10 @@
<html> <html>
<head> <head>
<title>FTracker Data</title> <title>FTracker Data</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f"> <meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style> <link href="view.css" rel="stylesheet" type="text/css">
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
}
header {
height: calc(38px - 16px);
padding: 8px;
white-space: nowrap;
overflow: hidden;
}
main {
height: calc(100% - 38px);
vertical-align: top;
}
main > section {
height: calc(100% - 32px);
display: inline-block;
vertical-align: top;
}
main > .viewheader {
display: inline-block;
vertical-align: top;
}
main > section.names, #nameheader {
width: 199px;
overflow: hidden;
border-right: 1px solid gray;
font-weight: bold;
text-transform: capitalize;
white-space: nowrap;
}
main > section.times, #timeheader {
width: calc(100% - 200px);
overflow: hidden;
}
.scroll {
height: 100%;
width: 100%;
overflow: scroll;
}
.row, .row #timelabels {
position: relative;
height: 32px;
background-size: 60px 100%;
}
.names .row:nth-child(even) { background: #ddd; }
.names .row:nth-child(odd) { background: #eee; }
.times .row:nth-child(even) {
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
}
.times .row:nth-child(odd) {
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
}
.row span {
padding: 7px;
display: inline-block;
}
.times span, #timeheader span {
position: absolute;
margin-right: 16px;
}
.times span {
background: #c50e1f;
color: #ddd;
font-weight: bold;
-webkit-text-stroke: .4px #c50e1f;
}
.times span.implausible {
background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px);
}
.viewheader.row {
height: 30px;
background: #ddd !important;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
}
.viewheader span {
background: none !important;
color: #000 !important;
padding-left: 4px;
}
#credprompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 95%;
max-width: 320px;
margin: auto;
background: #ddd;
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
}
#credprompt h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
#credprompt input {
display: block;
border: none;
margin: 16px auto;
padding: 16px;
font-size: 16px;
}
#credprompt input[type=text],
#credprompt input[type=password] {
color: #000;
width: calc(100% - 64px);
}
#credprompt input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: calc(100% - 32px);
}
</style>
</head> </head>
<body> <body>
<header> <header>
@ -151,283 +31,6 @@
</div> </div>
</section> </section>
</main> </main>
<script> <script src="view.js"></script>
var data = null;
var names = document.querySelector('main #names')
var times = document.querySelector('main #times')
function exportCSV() {
if (data == null) {
alert('No data found.')
return
}
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
csv = '"ftracker-export",'
days = []
var tc = new Date(startDate.getTime())
tc.setHours(1,0,0,0)
while (tc < endDate) {
var isodate = tc.toISOString().split('T')[0]
csv += ('"' + isodate + '",')
days.push(isodate)
tc.setDate(tc.getDate() + 1);
}
csv = csv.replace(/,$/, '')
csv += '\n'
for (var [name, list] of Object.entries(data)) {
csv += '"' + name + '"'
for (day of days) {
csv += ',"'
daytexts = []
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
var [arrDay, arrT] = localISOTimeMinutes(arrD).split('T')
var [depDay, depT] = localISOTimeMinutes(depD).split('T')
if ((arrDay == day) && (depDay == day)) {
daytexts.push(arrT + '-' + depT + ' (' + entry.room + ')')
} else if (arrDay == day) {
daytexts.push(arrT + '-... (' + entry.room + ')')
} else if (depDay == day) {
daytexts.push('...-' + depT + ' (' + entry.room + ')')
}
}
csv += daytexts.join('\n')
csv += '"'
}
csv += '\n'
}
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
element.setAttribute('download', 'ftracker-export.csv');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function renderData() {
if (data == null) {
alert('No data found.')
return
}
names = document.querySelector('main #names')
times = document.querySelector('main #times')
names.innerHTML = ''
times.innerHTML = ''
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
// Clear everything below hours because that would lead to
// misalignments with the day grid
startDate.setMinutes(0,0,0)
var tc = new Date(startDate.getTime())
var content = ''
while (tc < endDate) {
var h = tc.getHours()
var t = (h == 0) ?
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
h+':00'
var left = ((tc - startDate) / (1000 * 60))
content += '<span style="left:'+left+'px;">'+t+'</span>'
tc.setTime(tc.getTime() + (60*60*1000));
}
var timeheader = document.getElementById('timelabels')
timeheader.innerHTML = content
var viewwidth = ((endDate - startDate) / (1000 * 60))
timeheader.style.width = viewwidth + 'px'
times.style.width = viewwidth + 'px'
var rowCount = 0
for (var [name, list] of Object.entries(data)) {
var row = document.createElement('div')
row.classList.add('row')
var rowHasBlock = false
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
rowHasBlock = true
// Minutes since start date / beginning
var arr = (arrD - startDate) / (1000 * 60)
var dep = (depD - startDate) / (1000 * 60)
var dur = dep - arr
var block = document.createElement('span')
block.innerHTML = entry.room
block.style.left = arr + 'px' // 1px/min
block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min
if (dur > 60 * 24)
block.classList.add('implausible')
row.appendChild(block)
}
if (rowHasBlock) {
var vname = name.replace(/-/g, ' ')
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
times.appendChild(row)
rowCount += 12
}
}
//var viewheight = rowCount * 32;
//times.style.height = viewheight + 'px'
var tw = document.querySelector('main .scroll')
tw.scrollLeft = tw.scrollWidth
}
function saveData(rdata) {
data = rdata.reduce((acc, entry) => {
var name = entry.name
delete entry.name
acc[name] = [...acc[name] || [], entry];
return acc;
}, {});
console.log(data)
renderData()
}
function submitCredentials() {
var cp = document.querySelector('#credprompt')
var user = cp.querySelector('#user').value
var pass = cp.querySelector('#pass').value
cp.remove()
var auth = btoa(user + ":" + pass)
localStorage.setItem('dataauth', auth)
loadData()
}
function loadData() {
var auth = localStorage.getItem('dataauth')
if (auth == null) {
var prompt = document.createElement('div')
prompt.id = 'credprompt'
prompt.innerHTML = '<h1>Credentials Required</h1>\
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="submit" onclick="submitCredentials()">'
document.body.appendChild(prompt)
document.querySelector('#credprompt #user').focus()
return // Abort load, wait for submit
}
var headers = new Headers()
headers.append('Authorization', 'Basic ' + auth)
var fetchopts = {
method: 'GET',
headers: headers
}
fetch('/data', fetchopts)
.then(res => {
if (Math.floor(res.status / 100) == 2)
return res.json()
else
localStorage.removeItem('dataauth')
res.text().then(function (text) {
alert(text)
location.reload()
})
})
.then(rdata => saveData(rdata))
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
var now = new Date()
var startDate = new Date()
startDate.setDate(now.getDate() - (4*7))
startDate.setHours(0,0,0,0)
document.querySelector('input#start').value = localISOTimeMinutes(startDate)
document.querySelector('input#end').value = localISOTimeMinutes(now)
var scrollbox = document.querySelector('.scroll')
var timehead = document.querySelector('#timeheader')
var namebox = document.querySelector('section.names')
scrollbox.onscroll = function() {
timehead.scrollLeft = scrollbox.scrollLeft
namebox.scrollTop = scrollbox.scrollTop
}
loadData()
</script>
</body> </body>
</html> </html>

278
web/view.js Normal file
View File

@ -0,0 +1,278 @@
var data = null;
var names = document.querySelector('main #names')
var times = document.querySelector('main #times')
function exportCSV() {
if (data == null) {
alert('No data found.')
return
}
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
csv = '"ftracker-export",'
days = []
var tc = new Date(startDate.getTime())
tc.setHours(1,0,0,0)
while (tc < endDate) {
var isodate = tc.toISOString().split('T')[0]
csv += ('"' + isodate + '",')
days.push(isodate)
tc.setDate(tc.getDate() + 1);
}
csv = csv.replace(/,$/, '')
csv += '\n'
for (var [name, list] of Object.entries(data)) {
csv += '"' + name + '"'
for (day of days) {
csv += ',"'
daytexts = []
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
var [arrDay, arrT] = localISOTimeMinutes(arrD).split('T')
var [depDay, depT] = localISOTimeMinutes(depD).split('T')
if ((arrDay == day) && (depDay == day)) {
daytexts.push(arrT + '-' + depT + ' (' + entry.room + ')')
} else if (arrDay == day) {
daytexts.push(arrT + '-... (' + entry.room + ')')
} else if (depDay == day) {
daytexts.push('...-' + depT + ' (' + entry.room + ')')
}
}
csv += daytexts.join('\n')
csv += '"'
}
csv += '\n'
}
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
element.setAttribute('download', 'ftracker-export.csv');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function renderData() {
if (data == null) {
alert('No data found.')
return
}
names = document.querySelector('main #names')
times = document.querySelector('main #times')
names.innerHTML = ''
times.innerHTML = ''
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
// Clear everything below hours because that would lead to
// misalignments with the day grid
startDate.setMinutes(0,0,0)
var tc = new Date(startDate.getTime())
var content = ''
while (tc < endDate) {
var h = tc.getHours()
var t = (h == 0) ?
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
h+':00'
var left = ((tc - startDate) / (1000 * 60))
content += '<span style="left:'+left+'px;">'+t+'</span>'
tc.setTime(tc.getTime() + (60*60*1000));
}
var timeheader = document.getElementById('timelabels')
timeheader.innerHTML = content
var viewwidth = ((endDate - startDate) / (1000 * 60))
timeheader.style.width = viewwidth + 'px'
times.style.width = viewwidth + 'px'
var rowCount = 0
for (var [name, list] of Object.entries(data)) {
var row = document.createElement('div')
row.classList.add('row')
var rowHasBlock = false
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
rowHasBlock = true
// Minutes since start date / beginning
var arr = (arrD - startDate) / (1000 * 60)
var dep = (depD - startDate) / (1000 * 60)
var dur = dep - arr
var block = document.createElement('span')
block.innerHTML = entry.room
block.style.left = arr + 'px' // 1px/min
block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min
if (entry.tested)
block.style.background = '#080'
if (dur > 60 * 24)
block.classList.add('implausible')
row.appendChild(block)
}
if (rowHasBlock) {
var vname = name.replace(/-/g, ' ')
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
times.appendChild(row)
rowCount += 12
}
}
//var viewheight = rowCount * 32;
//times.style.height = viewheight + 'px'
var tw = document.querySelector('main .scroll')
tw.scrollLeft = tw.scrollWidth
}
function saveData(rdata) {
data = rdata.reduce((acc, entry) => {
var name = entry.name
delete entry.name
acc[name] = [...acc[name] || [], entry];
return acc;
}, {});
console.log(data)
renderData()
}
function submitCredentials() {
var cp = document.querySelector('#credprompt')
var user = cp.querySelector('#user').value
var pass = cp.querySelector('#pass').value
cp.remove()
var auth = btoa(user + ":" + pass)
localStorage.setItem('dataauth', auth)
loadData()
}
function loadData() {
var auth = localStorage.getItem('dataauth')
if (auth == null) {
var prompt = document.createElement('div')
prompt.id = 'credprompt'
prompt.innerHTML = '<h1>Credentials Required</h1>\
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="submit" onclick="submitCredentials()">'
document.body.appendChild(prompt)
document.querySelector('#credprompt #user').focus()
return // Abort load, wait for submit
}
var headers = new Headers()
headers.append('Authorization', 'Basic ' + auth)
var fetchopts = {
method: 'GET',
headers: headers
}
fetch('/data', fetchopts)
.then(res => {
if (Math.floor(res.status / 100) == 2)
return res.json()
else
localStorage.removeItem('dataauth')
res.text().then(function (text) {
alert(text)
location.reload()
})
})
.then(rdata => saveData(rdata))
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
var now = new Date()
var startDate = new Date()
startDate.setDate(now.getDate() - (4*7))
startDate.setHours(0,0,0,0)
document.querySelector('input#start').value = localISOTimeMinutes(startDate)
document.querySelector('input#end').value = localISOTimeMinutes(now)
var scrollbox = document.querySelector('.scroll')
var timehead = document.querySelector('#timeheader')
var namebox = document.querySelector('section.names')
scrollbox.onscroll = function() {
timehead.scrollLeft = scrollbox.scrollLeft
namebox.scrollTop = scrollbox.scrollTop
}
loadData()