A Pen by André Givenchy on CodePen.
Last active
August 22, 2019 07:18
-
-
Save andregivenchy/cfa3e98c852e27fc6c60941381b54f13 to your computer and use it in GitHub Desktop.
zero time
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<audio id="audio-break" src="https://a.clyp.it/obco0n3a.mp3"></audio> | |
<audio id="audio-work" src="https://a.clyp.it/5tyslehe.mp3"></audio> | |
<div class="row"> | |
<div id="left" class="column"> | |
<h1>zero time</h1> | |
<p>Similar to the "Zero Dollar" approach finances, which is the act of telling every dollar where to go. Applying the same methodology to time is where the magic happens. | |
</p> | |
<div> | |
<h4>01 — Start by deciding what task to focus on.</h4> | |
<h4>02 — Assign a block of time to spend solely on that task. </h4> | |
<h5>We recommend ninety (90) minutes.</h5> | |
<h4>03 — Set phone to DND mode.</h4> | |
<h4>04 — Start times, get to work on task until timer ends.</h4> | |
<h4>05 — Take a short break.</h4> | |
<h5>We recommend ten (10) to fifteen (15) minutes.</h5> | |
<h4>06 — Repeat with a new task.</h4> | |
<h5 class="caption">After you've completed three (3) zero time blocks, take a long break of 30 minutes before starting another round. This will allow your mind the needed time to decompress, rest, and reset.</h5> | |
</div> | |
<form id="settings" action="javascript:void(0)"> | |
<div class="group"> | |
<label class="group-label" for="work-time">Focus for:</label> | |
<span class="number"> | |
<button type="button" class="btn-rocker left" id="work-time-minus1">-</button> | |
<input class="input-number" inputmode="numeric" step="1" id="work-time" value="25" min="1" max="60"> | |
<button type="button" class="btn-rocker right" id="work-time-plus1">+</button> | |
</span> | |
<span class="unit">min</span> | |
</div> | |
<div class="group"> | |
<hr class="gradient"> | |
<label class="group-label" for="break-time">Break for:</label> | |
<span class="number"> | |
<button type="button" class="btn-rocker left" id="break-time-minus1">-</button> | |
<input class="input-number" inputmode="numeric" step="1" id="break-time" value="5" min="1" max="60"> | |
<button type="button" class="btn-rocker right" id="break-time-plus1">+</button> | |
</span> | |
<span class="unit"> min</span> | |
</div> | |
<div class="group"> | |
<hr class="gradient"> | |
<label class="group-label" for="pomodori">Repeat for:</label> | |
<span class="number"> | |
<button type="button" class="btn-rocker left" id="pomodori-minus1">-</button> | |
<input class="input-number" inputmode="numeric" step="1" id="pomodori" value="3" min="5" max="120"> | |
<button type="button" class="btn-rocker right" id="pomodori-plus1">+</button> | |
</span> | |
<span class="unit">rnd</span> | |
</div> | |
<hr class="gradient"> | |
<div class="group"> | |
<label class="group-label">Notify me:</label> | |
<span class="group-col"> | |
<div id="system-notification-pref"> | |
<input type="checkbox" id="check-notify"> | |
<label for="check-notify"> | |
<abbr title="A message shown on screen, even when this page is not visible">?</abbr> | |
</label> | |
</div> | |
</span> | |
</div> | |
<div class="group hide" id="volume-group"> | |
<hr class="gradient"> | |
<label class="group-label" for="volume-slider">Volume:</label> | |
<input type="range" min="0" step="5" max="100" id="volume-slider" value="75"> | |
<span id="volume-value">75%</span> | |
<button type="button" id="btn-volume-test">Test</button> | |
</div> | |
</form> | |
</div> | |
<div id="right" class="column"> | |
<div id="pomodoro"> | |
<svg viewBox="0 0 100 100" xmlns="https://www.w3.org/2000/svg"> | |
<path id="next" class="break azulrite" d="M 50 4 A 46 46 0 1 1 49.99919714854413 4.0000000070062" /> | |
<path id="current" class="work azulrite" d="M 50 4 A 46 46 0 1 1 49.99919714854413 4.0000000070062" /> | |
</svg> | |
<div id="inner"> | |
<span id="timer"> | |
<div> | |
<span id="timer-status">Stopped</span> | |
</div> | |
<div id="timer-display"></div> | |
<div id="timer-controls"> | |
<button class="btn-icon azulrite" id="btn-start"> | |
<span id="btn-start-sr" class="sr-only" accesskey="p">Start [p]</span> | |
</button> | |
<button class="btn-icon invisible azulrite" id="btn-stop"> | |
<span id="btn-stop-sr" class="sr-only" accesskey="s">Stop [s]</span> | |
</button> | |
</div> | |
</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="spacer right"></div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
window.onload = app; | |
// 98% ui related stuff in here, get out while you can! | |
function app() { | |
"use strict"; | |
var currentArc = document.getElementById('current'), | |
nextArc = document.getElementById('next'), | |
audioBreak = document.getElementById('audio-break'), | |
audioWork = document.getElementById('audio-work'), | |
timerStatus = document.getElementById('timer-status'), | |
timerDisplay = document.getElementById('timer-display'), | |
btnPlay = document.getElementById('btn-start'), | |
btnStop = document.getElementById('btn-stop'), | |
btnPlaySr = document.getElementById('btn-start-sr'), | |
// preferences: | |
colorSchemeSlider = document.getElementById('color-scheme-slider'), | |
workTimeInput = document.getElementById('work-time'), | |
workTimePlus1 = document.getElementById('work-time-plus1'), | |
workTimeMinus1 = document.getElementById('work-time-minus1'), | |
breakTimeInput = document.getElementById('break-time'), | |
breakTimePlus1 = document.getElementById('break-time-plus1'), | |
breakTimeMinus1 = document.getElementById('break-time-minus1'), | |
pomodoriInput = document.getElementById('pomodori'), | |
pomodoriPlus1 = document.getElementById('pomodori-plus1'), | |
pomodoriMinus1 = document.getElementById('pomodori-minus1'), | |
checkSound = document.getElementById('check-sound'), | |
checkNotify = document.getElementById('check-notify'), | |
sysNotifyPrefs = document.getElementById('system-notification-pref'), | |
volumeGroup = document.getElementById('volume-group'), | |
volumeSlider = document.getElementById('volume-slider'), | |
volumeValue = document.getElementById('volume-value'), | |
chromeSoundNote = document.getElementById('chrome-sound-note'), | |
btnVolumeTest = document.getElementById('btn-volume-test'); | |
//-- color scheme setup -- | |
var currentScheme = 0; | |
var colorSchemes = ['azulrite', 'plasma']; | |
function changeColorScheme(oldScheme, newScheme) { | |
// get all elements with the oldScheme class: | |
var elems = Array.from(document.querySelectorAll('.' + oldScheme)); | |
elems.forEach(function(elem) { | |
// replace oldScheme with newScheme | |
removeClass(elem, oldScheme, newScheme); | |
}); | |
} | |
btnVolumeTest.onclick = function() { | |
audioWork.load(); // be kind, rewind | |
audioWork.play(); | |
} | |
//-- +/- rocker button setup -- | |
function onRockerClick(step, input, min, max) { | |
var val = Number.parseInt(input.value) || min; | |
input.value = Math.min(max, Math.max(val+step, min)); | |
var event; | |
// trigger a 'change' event which other code reacts to: | |
if ('InputEvent' in window) { // new school | |
event = new InputEvent('change', { | |
'view': window, | |
'bubbles': true, | |
'cancelable': true | |
}); | |
} else { // old school | |
event = document.createEvent("HTMLEvents"); | |
event.initEvent("change", true, true); | |
}; | |
if (!event) return; | |
input.dispatchEvent(event); | |
} | |
workTimePlus1.onclick = onRockerClick.bind(null, +1, workTimeInput, 1, 60); | |
workTimeMinus1.onclick = onRockerClick.bind(null, -1, workTimeInput, 1, 60); | |
breakTimePlus1.onclick = onRockerClick.bind(null, +1, breakTimeInput, 1, 60); | |
breakTimeMinus1.onclick = onRockerClick.bind(null, -1, breakTimeInput, 1, 60); | |
pomodoriPlus1.onclick = onRockerClick.bind(null, +1, pomodoriInput, 1, 20); | |
pomodoriMinus1.onclick = onRockerClick.bind(null, -1, pomodoriInput, 1, 20); | |
// after holding the mouse button down on the element for 500ms, | |
// dispatch click-events in a 200ms interval until the mouse button is released | |
rockerBtn(workTimePlus1); | |
rockerBtn(workTimeMinus1); | |
rockerBtn(breakTimePlus1); | |
rockerBtn(breakTimeMinus1); | |
rockerBtn(pomodoriPlus1); | |
rockerBtn(pomodoriMinus1); | |
//-- timer controls/behaviour setup -- | |
btnPlay.onclick = btnPlayClick; | |
btnStop.onclick = btnStopClick; | |
// whether currentArc should have 360-degrees instead of degrees. | |
// during work, the arc is inverted = it gets smaller over time, | |
// during break the arc is not inverted = it gets bigger again | |
var invertArc = true; | |
// handles desktop notifications (I like the name :): | |
var naughty = naughtify('lincore.pomodoro'); | |
if (!naughty.support) addClass(checkNotify, 'invisible'); | |
// if the user has not made any preferences yet, use these: | |
var defaultPrefs = { | |
worktime: 90, | |
breaktime: 15, | |
pomodori: 3, | |
colorScheme: 0, | |
playSound: true, | |
systemNotification: false, | |
volume: Math.floor(audioBreak.volume * 100) | |
}; | |
// this thing contains all timer logic (defined further down): | |
var pomodoro = new Pomodoro( | |
defaultPrefs.worktime, | |
defaultPrefs.breaktime, | |
defaultPrefs.pomodori); | |
// options for my options tool | |
var localPrefs_options = { | |
debug: false, | |
exclusive: true, // preferences not declared in prefs are invalid | |
storageKey: 'lincore.pomodoro.prefs', | |
autoLoad: true, // automatically load on startup | |
autoSave: true, // auto save when changes are applied | |
autoApply: true, // apply changes as soon as they occur | |
// callback when changes have been applied: | |
/*onApply: function(localPrefs) { | |
console.log('prefs:\n ' + JSON.stringify(localPrefs.prefs)); | |
},*/ | |
prefs: { | |
colorScheme: { | |
value: colorSchemeSlider, // element or selector repesenting a preference | |
change: function(val, change) { // side effects of preference change | |
changeColorScheme(colorSchemes[currentScheme], colorSchemes[val]); | |
currentScheme = val; | |
} | |
}, | |
worktime: { | |
value: workTimeInput, | |
change: function(work, change) { | |
work = Number.parseInt(work); | |
if (!work) { | |
change.accept = false; | |
} else { | |
pomodoro.changeTime(work*1000*60); | |
setDisplay(pomodoro.timeLeft); | |
} | |
} | |
}, | |
breaktime: { | |
value: breakTimeInput, | |
change: function(breaktime, change) { | |
breaktime = Number.parseInt(breaktime); | |
if (!breaktime) { | |
change.accept = false; | |
} else { | |
pomodoro.changeTime(0, breaktime*1000*60); | |
} | |
} | |
}, | |
pomodori: { | |
value: pomodoriInput, | |
change: function(pomodori, change) { | |
pomodori = Number.parseInt(pomodori); | |
if (!pomodori) { | |
change.accept = false; | |
} else { | |
pomodoro.pomodori = pomodori; | |
} | |
} | |
}, | |
playSound: { | |
value: checkSound, | |
change: function(checked, change) { | |
//toggleClass(volumeGroup, 'hidden', !checked); | |
//var note = window.Notification && checked && | |
// !change.localPrefs.prefs.systemNotification; | |
//toggleClass(chromeSoundNote, 'hidden', note); | |
} | |
}, | |
systemNotification: { | |
value: checkNotify, | |
change: function(checked, change) { | |
if (!checked) return; | |
// websites must have permission to show notifications: | |
naughty.askForPermission(function(permission) { | |
console.log('permission?', permission); | |
if (permission !== 'granted') | |
change.localPrefs.set('systemNotification', false); | |
}); | |
} | |
}, | |
volume: { | |
value: volumeSlider, | |
change: function(vol, change) { | |
volumeValue.innerHTML = vol + '%'; | |
audioWork.volume = vol/100; | |
audioBreak.volume = vol/100; | |
} | |
} | |
} | |
}; | |
var localPrefs = new LocalPrefs(localPrefs_options, defaultPrefs); | |
// for debugging/messing around: | |
window.pomodoro = pomodoro; | |
window.localPrefs = localPrefs; | |
window.naughty = naughty; | |
//-- pomodoro callback functions -- | |
// called about every second | |
pomodoro.onTick = function() { | |
setDisplay(pomodoro.timeLeft); | |
var degrees = Math.round(pomodoro.progress * 360); | |
drawPieArc(currentArc, degrees, invertArc); | |
drawPieArc(nextArc, degrees, !invertArc); | |
}; | |
pomodoro.onTick(); | |
// called whenever a work or break period ended: | |
pomodoro.onPhaseChange = function() { | |
timerStatus.innerHTML = asOrdinal(pomodoro.currentPomodoro) + ', ' + | |
pomodoro.currentPhase; | |
invertArc = pomodoro.currentPhase === 'block'; | |
// play sound, show notification: | |
if (pomodoro.state === 'running') { | |
if (pomodoro.currentPhase === 'break') { | |
if (localPrefs.prefs.playSound) { | |
audioBreak.load(); | |
audioBreak.play(); | |
} | |
if (localPrefs.prefs.systemNotification) { | |
naughty.message("Time for a break."); | |
} | |
} else { | |
if (localPrefs.prefs.playSound) { | |
audioWork.load(); | |
audioWork.play(); | |
} | |
if (localPrefs.prefs.systemNotification) { | |
var remain = pomodoro.pomodori - pomodoro.currentPomodoro + 1; | |
naughty.message("Let's get back to work", 'Only ' + remain + ' pomodori left.'); | |
} | |
} | |
} | |
// update the display: | |
pomodoro.onTick(); | |
}; | |
pomodoro.onFinish = function() { | |
btnStopClick(); | |
timerStatus.innerHTML = "You're done!"; | |
if (localPrefs.prefs.systemNotification) naughty.message("You're done, good job!"); | |
if (localPrefs.prefs.playSound) { | |
audioBreak.load(); | |
audioBreak.play(); | |
} | |
} | |
// now that everything is neadly set up, localPrefs | |
// applys all changes by updating elements and calling | |
// change functions: | |
localPrefs.apply(); | |
// now I no longer need to manually apply changes: | |
localPrefs.options.autoApply = true; | |
// depending on the timer's current state, start, pause or resume: | |
function btnPlayClick() { | |
switch (pomodoro.state) { | |
case 'stopped': | |
// start the timer | |
btnPlay.id = 'btn-pause'; | |
btnPlaySr.innerHTML = 'Pause [p]'; | |
removeClass(btnStop, 'invisible'); | |
pomodoro.onPhaseChange(); | |
pomodoro.start(); | |
break; | |
case 'running': | |
// pause the timer | |
btnPlay.id = 'btn-play'; | |
btnPlaySr.innerHTML = 'Resume [p]'; | |
pomodoro.pause(); | |
break; | |
case 'paused': | |
// resume the timer | |
btnPlay.id = 'btn-pause'; | |
btnPlaySr.innerHTML = 'Pause [p]'; | |
pomodoro.resume(); | |
break; | |
default: | |
throw new Error('Invalid timer state: ' + pomodoro.state); | |
} | |
} | |
function btnStopClick() { | |
pomodoro.stop(); | |
pomodoro.onPhaseChange(); | |
timerStatus.innerHTML = 'Stopped'; | |
addClass(btnStop, 'invisible'); | |
btnPlay.id = 'btn-start'; | |
} | |
function setDisplay(timeLeft) { | |
// round to whole seconds | |
timeLeft = Math.round(timeLeft / 1000); | |
var minutes = Math.floor(timeLeft / 60); | |
var seconds = Math.floor(timeLeft % 60); | |
var minutesStr = padLeft(minutes + '', 2, '0'); | |
var secondsStr = padLeft(seconds + '', 2, '0'); | |
var display = minutesStr + ':' + secondsStr; | |
// wrap each digit in a separate fixed-width span: | |
var html = display.split('').map(function(char) { | |
return char === ':' ? ':' : '<span class="display-segment">' + char + '</span>'; | |
}).join(''); | |
timerDisplay.innerHTML = html; | |
} | |
} | |
// encapsulates all timer logic | |
function Pomodoro(worktime, breaktime, pomodori, interval) { | |
// from here on I use only milliseconds: | |
this.worktime = (worktime || 90) * 1000 * 60; | |
this.breaktime = (breaktime || 15) * 1000 * 60; | |
this.pomodori = pomodori || 3; | |
this.interval = interval || 1000; | |
this.reset(); | |
// assign callback function(pomodoro)s to these members: | |
this.onTick = null; | |
this.onFinish = null; | |
this.onPhaseChange = null; | |
// timer callback, runs every sec: | |
this._tick = (function() { | |
var now = Date.now(); | |
var delta = now - this.lastTick; | |
this.lastTick = now; | |
this.timeLeft -= delta; | |
var timePassed = Math.min(this.goal - this.timeLeft, this.goal); | |
this.progress = timePassed / this.goal; | |
if (this.onTick) this.onTick(this); | |
if (this.timeLeft <= 0) { | |
this._onTimerEnd(); | |
return; | |
} | |
// I don't use an interval, so I need to start the timer again: | |
this._startTimer(); | |
}).bind(this); | |
} | |
Pomodoro.prototype.changeTime = function(worktime, breaktime) { | |
if (worktime) this.worktime = worktime; | |
if (breaktime) this.breaktime = breaktime; | |
if (this.state === 'stopped') this.reset(); | |
}; | |
Pomodoro.prototype.reset = function() { | |
this.state = 'stopped'; | |
this.currentPhase = 'block'; | |
this.currentPomodoro = 1; | |
this.timeLeft = this.worktime; | |
this.goal = this.worktime; | |
this.progress = 0; | |
this._timerId = undefined; | |
}; | |
Pomodoro.prototype.start = function() { | |
this.reset(); | |
this.state = 'running'; | |
this._startTimer(); | |
}; | |
Pomodoro.prototype.stop = function() { | |
this._stopTimer(); | |
this.state = 'stopped'; | |
this.reset(); | |
if (this.onTick) this.onTick(this); | |
}; | |
Pomodoro.prototype.pause = function() { | |
this.state = 'paused'; | |
this._stopTimer(); | |
}; | |
Pomodoro.prototype.resume = function() { | |
this.state = 'running'; | |
this._startTimer(); | |
}; | |
Pomodoro.prototype._startTimer = function() { | |
// try to compensate for slight deviations: | |
var timeout = this.timeLeft % this.interval || this.interval; | |
this._timerId = setTimeout(this._tick, timeout); | |
this.lastTick = Date.now(); | |
}; | |
Pomodoro.prototype._stopTimer = function() { | |
clearTimeout(this._timerId); | |
}; | |
// actually, this is called when the current phase ends, | |
// not the timer, which "ends" every second. | |
Pomodoro.prototype._onTimerEnd = function() { | |
if (this.currentPomodoro === this.pomodori && this.currentPhase === 'block') { | |
// done! | |
this.stop(); | |
if (this.onFinish) this.onFinish(this); | |
return; | |
} | |
// do some book keeping: | |
this.currentPhase = this.currentPhase === 'block'? 'break' : 'block'; | |
if (this.currentPhase === 'break') { | |
this.goal = this.breaktime; | |
} else { | |
this.currentPomodoro++; | |
this.goal = this.worktime; | |
} | |
this.timeLeft += this.goal; | |
this.progress = 0; | |
if (this.onPhaseChange) this.onPhaseChange(this); | |
this._startTimer(); | |
}; | |
/** | |
* while depressed, trigger a button's click event every <repeatInterval> ms, | |
* after an initial interval of <initialInterval> | |
* works not very well on mobile, at least not on my Android tablet. | |
*/ | |
function rockerBtn(button, initialInterval, repeatInterval) { | |
initialInterval = initialInterval || 500; | |
repeatInterval = repeatInterval || 200; | |
var timer; | |
var mousedown = false; | |
button.addEventListener('mousedown', press); | |
//button.addEventListener('mouseup', release); | |
function press() { | |
if (mousedown) return; | |
// the mouse could be everywhere when the button is released, so I use | |
// window to catch the release no matter what: | |
window.addEventListener('mouseup', release); | |
mousedown = true; | |
// wait <initialInterval> ms: | |
timer = setInterval(function() { | |
clearInterval(timer); | |
// set the <repeatInterval> interval: | |
timer = setInterval(function() { | |
// TODO: MouseEvent may not be supported on all browsers | |
var event = new MouseEvent('click', { | |
'view': window, | |
'bubbles': true, | |
'cancelable': true | |
}); | |
button.dispatchEvent(event); | |
}, repeatInterval); | |
}, initialInterval); | |
} | |
function release() { | |
window.removeEventListener('mouseup', release); | |
clearInterval(timer); | |
mousedown = false; | |
} | |
} | |
/** | |
* convert an angle in degrees to a point on the circle's circumference | |
* defined by centerx, centery, radius. 0 degrees are equal to centerx+radius, centery. | |
*/ | |
function degreesToPoint(degrees, centerx, centery, radius) { | |
var radians = degrees / 180 * Math.PI; | |
return { | |
x: centerx + Math.cos(radians) * radius, | |
y: centery + Math.sin(radians) * radius | |
}; | |
} | |
// draw an SVG arc. It's weird. | |
function drawPieArc(path, degrees, inverted) { | |
// I am assuming degrees divisible by 360 are full circles. | |
// Arcs ending at their origin are of zero length, so I use a | |
// value very close to 360 to represent a full circle. | |
if (degrees === 0 && !inverted || degrees >= 360 && inverted) { | |
path.setAttribute('d', ''); | |
} | |
if (degrees > 360) { | |
degrees %= 360; | |
if (degrees === 0) degrees = 359.999; | |
} else if (degrees === 360) degrees = 359.999; | |
// svg's viewBox has a size of 100x100. I choose a smaller | |
// radius to compensate for the line thickness | |
var radius = 46; | |
var centerx = 50, | |
centery = 50; | |
// Four different arcs can be drawn between two fixed points: | |
// - a short arc (less than 180 degrees) and a long arc | |
// If largeArg is 1, the long arc will be drawn. | |
var largeArc = degrees > 180 ? 1 : 0; | |
// - an arc that sweeps to the left and one that sweeps to the right | |
// If sweep is 1, the arc goes to the right, otherwise to the left. | |
var sweep = 1; | |
if (inverted) { | |
largeArc = degrees > 180 ? 0 : 1; | |
sweep = 0; | |
if (degrees === 0) degrees = 0.001; | |
} | |
// 0 degrees point to the right, but I want them to point up: | |
degrees = (degrees - 90) % 360; | |
var point = degreesToPoint(degrees, centerx, centery, radius); | |
var x = point.x; | |
var y = point.y; | |
// Move to X Y | |
var moveToStart = 'M ' + centerx + ' ' + (centery - radius); | |
// Arc radiusX radiusY rotationXAxis largeArc? sweep? toX toY | |
var drawArc = 'A ' + radius+ ' ' +radius + ' 0 ' + largeArc + ' ' + sweep + ' ' + x + ' ' + y; | |
// PS: rotationXAxis would skew/rotate the arc, but only if the radii were different. | |
var d = moveToStart + ' ' + drawArc; | |
path.setAttribute('d', d); | |
} | |
// return English ordinal string representing | |
// the given number, i.e. 1st, 2nd, 3rd etc. | |
function asOrdinal(number) { | |
if (number < 0) number *= -1; | |
if (number > 3 && number <= 20) return number + 'th'; | |
var str = '' + number; | |
switch (str[str.length - 1]) { | |
case '1': | |
return str + 'st'; | |
case '2': | |
return str + 'nd'; | |
case '3': | |
return str + 'rd'; | |
default: | |
return str + 'th'; | |
} | |
} | |
// Synchronize locally stored user preferences between | |
// local storage, ui and code. | |
// This is a longer one. | |
function LocalPrefs(options, defaultPrefs) { | |
"use strict"; | |
if (!options || typeof(options) !== 'object') | |
throw new Error('Invalid argument: options.'); | |
this._applying = false; | |
this.defaultPrefs = defaultPrefs || {}; | |
this.options = options || {}; | |
this.prefs = {}; | |
this.changes = defaultPrefs; | |
this._init(); | |
if (this.options.autoLoad) { | |
if (!this.load()) this.apply(); | |
} else if (this.options.autoApply) { | |
this.apply(); | |
} | |
if (this.options.debug) console.log(this); | |
} | |
LocalPrefs.prototype._init = function() { | |
function getElementValue(element) { | |
var tag = element.tagName.toLowerCase(); | |
if (tag === 'input') { | |
var type = (element.getAttribute('type') || '').toLowerCase(); | |
if (type === 'checkbox' || type === 'radio' && typeof(value) === 'boolean') { | |
return element.checked; | |
} else { | |
return element.value; | |
} | |
} else if (tag === 'select' || tag === 'textarea') { | |
return element.value; | |
} else { | |
return element.innerHTML; | |
} | |
} | |
if (this.options.debug) console.log('localPrefs._init'); | |
var keys = Object.keys(this.options.prefs); | |
keys.forEach((function(key) { | |
if (!this.options.prefs.hasOwnProperty(key)) return; | |
var pref = this.options.prefs[key]; | |
if (!pref || typeof(pref) !== 'object') | |
throw new Error('localPrefs: options.prefs.' + key + ' must be an object.'); | |
if (!pref.value) return; | |
pref._value_elem = typeof(pref.value) === 'string' ? | |
document.querySelector(pref.value) : pref.value; | |
if (!pref.change) return; | |
pref._onchange = (function() { | |
if (this._applying) return; | |
this.set(key, getElementValue(pref._value_elem)); | |
}).bind(this); | |
pref._value_elem.addEventListener('change', pref._onchange); | |
}).bind(this)); | |
}; | |
LocalPrefs.prototype.set = function(key, value) { | |
if (this.options.debug) console.log('localPrefs.set'); | |
if (this.prefs[key] === value) { | |
delete this.changes[key]; | |
return; | |
} | |
this.changes[key] = value; | |
if (this.options.autoApply) this.apply(); | |
}; | |
LocalPrefs.prototype.get = function(key, defaultValue) { | |
if (this.options.debug) console.log('localPrefs.get'); | |
return (this.prefs.hasOwnProperty(key)) ? this.prefs[key] : defaultValue; | |
}; | |
LocalPrefs.prototype.purgeStorage = function() { | |
if (this.options.debug) console.log('localPrefs.purgeStorage'); | |
localStorage.removeItem(this.storageKey); | |
} | |
LocalPrefs.prototype.cleanUp = function() { | |
if (this.options.debug) console.log('localPrefs.cleanUp'); | |
var keys = Object.keys(this.options.prefs); | |
keys.forEach((function(key) { | |
if (!this.options.prefs.hasOwnProperty(key)) return; | |
var pref = this.options.prefs; | |
if (pref._onchange) pref._value_elem.removeEventListener(pref._onchange); | |
}).bind(this)); | |
this.prefs = null; | |
this.options = null; | |
this.defaultPrefs = null; | |
}; | |
LocalPrefs.prototype.apply = function(changes, noAutoSave) { | |
var each = (function(key) { | |
if (this.prefs.hasOwnProperty(key) && changes[key] === this.prefs[key]) return; | |
var val = changes[key]; | |
if (!this.options.prefs.hasOwnProperty(key)) { | |
if (this.options.exclusive) | |
throw new Error('localPrefs.apply: Undefined preference key: ' + key); | |
return val; | |
} | |
var pref = this.options.prefs[key]; | |
if (pref.change) { | |
var ctx = { | |
val: val, | |
accept: true, | |
localPrefs: this | |
}; | |
pref.change(val, ctx); | |
if (!ctx.accept) return; | |
val = ctx.val; | |
} | |
this.prefs[key] = val; | |
if (pref._value_elem) updateElement(pref._value_elem, val); | |
}).bind(this); | |
if (this.options.debug) console.log('localPrefs.apply'); | |
changes = changes || this.changes; | |
var keys = Object.keys(changes); | |
keys.forEach((function(key) { | |
var val = each(key); | |
if (val === undefined) return; | |
this.prefs[key] = val; | |
}).bind(this)); | |
if (this.options.onApply) this.options.onApply(this); | |
if (!noAutoSave && this.options.autoSave) this.save(); | |
function updateElement(element, value) { | |
var tag = element.tagName.toLowerCase(); | |
if (tag === 'input') { | |
var type = (element.getAttribute('type') || '').toLowerCase(); | |
if (type === 'checkbox' || type === 'radio' && typeof(value) === 'boolean') { | |
element.checked = value; | |
} else { | |
element.value = value; | |
} | |
} else if (tag === 'select' || tag === 'textarea') { | |
element.value = value; | |
} else { | |
element.innerHTML = value; | |
} | |
} | |
}; | |
LocalPrefs.prototype.save = function(storageKey) { | |
if (this.options.debug) console.log('localPrefs.save'); | |
storageKey = storageKey || this.options.storageKey; | |
if (!storageKey) throw new Error('localPrefs.save: Missing storage key.'); | |
if (!localStorage) { | |
if (this.options.debug) console.log('localPrefs.save: localStorage API not supported.'); | |
return false; | |
} | |
localStorage.setItem(storageKey, JSON.stringify(this.prefs)); | |
if (this.options.onSave) this.options.onSave(this); | |
}; | |
LocalPrefs.prototype.load = function(storageKey) { | |
if (this.options.debug) console.log('localPrefs.load'); | |
storageKey = storageKey || this.options.storageKey; | |
if (!storageKey) throw new Error('localPrefs.load: Missing storage key.'); | |
if (!localStorage) { | |
if (this.options.debug) console.log('localPrefs.load: localStorage API not supported.'); | |
return false; | |
} | |
var json = localStorage.getItem(this.options.storageKey); | |
if (!json) return false; | |
var loaded; | |
try { | |
loaded = JSON.parse(json); | |
} catch (e) { | |
if (e.constructor !== SyntaxError) throw e; | |
if (this.options.debug) console.log('localPrefs.load: malformed json: ', json, e.message); | |
return false; | |
} | |
var keys = Object.keys(loaded); | |
if (keys === 0) return false; | |
keys.forEach((function(key) { | |
if (!loaded.hasOwnProperty(key)) return; | |
this.changes[key] = loaded[key]; | |
}).bind(this)); | |
this.apply(undefined, true); | |
if (this.options.onLoad) this.options.onLoad(this); | |
return true; | |
}; | |
// handle desktop notifications (naughtifications?) | |
function naughtify(tag) { | |
var naughty = {}; | |
naughty.support = "Notification" in window; | |
naughty.hasPermission = function() { | |
if (!naughty.support) return false; | |
return Notification.permission === 'granted'; | |
}; | |
naughty.isPermissionDenied = function() { | |
if (!naughty.support) return true; | |
return Notification.permission === 'denied'; | |
}; | |
naughty.askForPermission = function(callback) { | |
if (!naughty.support || Notification.permission === 'denied') { | |
if (callback) callback('denied'); | |
} else if (Notification.permission === 'granted') { | |
if (callback) callback('granted'); | |
} else { | |
Notification.requestPermission(callback); | |
} | |
}; | |
naughty.message = function(title, text) { | |
if (!naughty.support || !naughty.hasPermission()) return false; | |
return new Notification(title, { | |
tag: tag, | |
renotify: true, | |
body: text | |
}); | |
}; | |
return naughty; | |
} | |
// more utils: | |
function padLeft(str, tolength, char) { | |
char = char || ' '; | |
if (str.length >= tolength) return str; | |
return new Array(tolength - str.length + 1).join(char) + str; | |
} | |
function hasClass(elem, clazz) { | |
return (Array.from(elem.classList).indexOf(clazz) > -1); | |
} | |
function getClassName(elem) { | |
var name = elem.className; | |
if (name.constructor === SVGAnimatedString) { | |
name = name.baseVal; | |
} | |
return name; | |
} | |
function removeClass(elem, clazz, replaceWith) { | |
replaceWith = replaceWith || ''; | |
if (hasClass(elem, clazz)) { | |
var classname = getClassName(elem).replace(clazz, replaceWith); | |
elem.setAttribute('class', classname); | |
return true; | |
} | |
return false; | |
} | |
function toggleClass(elem, clazz, value) { | |
value = value || !hasClass(elem, clazz); | |
if (value) { | |
addClass(elem, clazz); | |
} else { | |
removeClass(elem, clazz); | |
} | |
} | |
function addClass(elem, clazz) { | |
if (!hasClass(elem, clazz)) { | |
var classname = getClassName(elem); | |
if (elem.className !== '') classname += ' '; | |
classname += clazz; | |
elem.setAttribute('class', classname); | |
return true; | |
} | |
return false; | |
} | |
function getRootWindow() { | |
var root = window; | |
while (root.parent !== root) { | |
root = root.parent; | |
} | |
return root; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import url('https://rsms.me/inter/inter.css'); | |
html { font-family: 'Inter', sans-serif; } | |
@supports (font-variation-settings: normal) { | |
html { font-family: 'Inter var', sans-serif; } | |
} | |
html { | |
background-color: #000; | |
min-height: 100vh; | |
margin: 0px 8%; | |
} | |
body { | |
font-family: 'Inter', sans-serif; | |
font-size: 16px; | |
font-weight: 400; | |
color: #fff; | |
background-color: inherit; | |
margin: 8px 20px 8px; | |
position: flex; | |
justify-content: center; | |
align-items; | |
min-height: 100vh; | |
max-height: 100vh; | |
min-width: 100vw; | |
} | |
h1 { | |
font-size: 80px; | |
margin-top: 16px; | |
margin-bottom: 4px; | |
} | |
h2 { | |
font-size: 64px; | |
margin-top: 16px; | |
margin-bottom: 4px; | |
} | |
h3 { | |
font-size: 48px; | |
margin-top: 16px; | |
margin-bottom: 4px; | |
} | |
h4 { | |
font-size: 16px; | |
margin-top: 16px; | |
margin-bottom: 4px; | |
} | |
h5 { | |
font-size: 12px; | |
color: #555; | |
} | |
.row { | |
display: flex; | |
justify-content: center; | |
} | |
.hide { | |
display: none; | |
} | |
@media (max-width: 700px) { | |
.row { | |
flex-direction: column-reverse; | |
} | |
} | |
@media (min-width: 701px) { | |
.row { | |
flex-direction: row; | |
} | |
/* not using bootstrap columns because they don't work with flex */ | |
.column + .column { | |
margin-left: 30px; | |
} | |
.column { | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
} | |
} | |
/* left side: */ | |
#left { | |
display: flex; | |
flex-direction: column; | |
justify-content: top left; | |
height: 100vh; | |
min-width: 50vw; | |
max-width: 50vw; | |
padding: 8%; | |
} | |
p { | |
font-weight: 200; | |
font-style: default; | |
text-align: left; | |
margin-top: 16px; | |
margin-bottom: 32px; | |
max-width: 90%; | |
} | |
.caption { | |
max-width: 55%; | |
} | |
hr { | |
border: none; | |
border-top: 1px solid #333; | |
} | |
a { | |
color: #999; | |
text-decoration: underline; | |
} | |
a:hover { | |
color: #eee; | |
} | |
form { | |
margin-top: 56px; | |
} | |
hr.gradient { | |
height: 2px; | |
width: 100%; | |
background: linear-gradient(to right, #000, #000); | |
border: none; | |
margin: 8px 0px; | |
} | |
label { | |
font-weight: 400; | |
} | |
.group-label { | |
font-weight: 400; | |
text-align: left; | |
width: 10ch; | |
display: inline-block; | |
height: 100%; | |
vertical-align: top; | |
} | |
.group-col { | |
display: inline-block; | |
} | |
.input-number { | |
width: 40px; | |
background: none; | |
border: 1px solid #333333; | |
border-left: 0px; | |
border-right: 0px; | |
padding: 0px; | |
color: inherit; | |
font-family: sans-serif; | |
text-align: center; | |
} | |
.btn-rocker { | |
background: #333333; | |
width: 30px; | |
height: 25px; | |
color: white; | |
border: none; | |
margin: 8px; | |
padding: 0px; | |
} | |
.btn-rocker.left { | |
border-radius: 4px 0px 0px 4px; | |
} | |
.btn-rocker.right { | |
border-radius: 0px 4px 4px 0px; | |
} | |
abbr { | |
text-decoration: none; | |
text-align: center; | |
border: 1px solid #999; | |
width: 1.5em; | |
border-radius: 50%; | |
display: inline-block; | |
} | |
/* all other input related stuff is at the bottom */ | |
.note { | |
border: 1px #333 dotted; | |
padding: 8px 16px; | |
margin: 12px 0px; | |
border-radius: 8px; | |
font-style: italic; | |
font-size: small; | |
color: #555; | |
} | |
/* right side: */ | |
#right { | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
height: 100vh; | |
min-width: 50vw; | |
max-width: 50vw; | |
padding; 8%; | |
} | |
/* svg */ | |
path { | |
fill: none; | |
stroke-width: 3px; | |
} | |
path.work { | |
stroke: #512bee; | |
} | |
path.work.azulrite { | |
stroke: #222; | |
} | |
path.break { | |
stroke: #f0d145; | |
} | |
path.break.azulrite { | |
stroke: #512bee; | |
} | |
#pomodoro { | |
min-width: 300px; | |
min-height: 300px; | |
max-width: 512px; | |
max-height: 512px; | |
position: relative; | |
} | |
#inner { | |
top: 0px; | |
left: 0px; | |
bottom: 0px; | |
right: 0px; | |
position: absolute; | |
text-align: center; | |
} | |
#timer { | |
width: 100%; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
} | |
#timer-display { | |
font-size: 3em; | |
} | |
.display-segment { | |
width: 1ch; | |
display: inline-block; | |
} | |
.btn-icon { | |
background: none; | |
border: 1px none; | |
color: #333; | |
font-size: 3em; | |
width: 1.5em; | |
margin-top: 40px; | |
} | |
.btn-icon:focus { | |
border: 1px #555 solid; | |
border-radius: 50%; | |
} | |
.btn-icon.azulrite:focus { | |
border-color: #333; | |
} | |
.btn-icon.azulrite { | |
color: #555; | |
} | |
.btn-icon:before { | |
font-family: FontAwesome; | |
} | |
#btn-play:before, | |
#btn-start:before { | |
content: "\f144"; | |
/* play-circle */ | |
} | |
#btn-pause:before { | |
content: "\f28b"; | |
} | |
#btn-stop:before { | |
content: "\f28d"; | |
/* stop-circle */ | |
} | |
.invisible { | |
display: none; | |
} | |
/* the element still occupies the same space, | |
but is otherwise not visible */ | |
.hidden { | |
visibility: hidden; | |
} | |
/* (hopefully) improve usability for disabled users */ | |
input:focus, button:focus { | |
border: 1px solid #999; | |
outline: none; | |
} | |
input, button { | |
border: 1px none; | |
} | |
/* you're gonna look sooo pretty */ | |
input[type=checkbox] { | |
/* This hides the actual checkbox from view, | |
while it is still focusable */ | |
position: fixed; | |
left: -1000px; | |
} | |
input + label { | |
padding-right: 1em; | |
cursor: pointer; | |
user-select: none; | |
-moz-user-select: none; | |
-webkit-user-select: none; | |
-ms-user-select: none; | |
} | |
input[type=checkbox] + label:before { | |
display: inline-block; | |
color: #999; | |
content: "\f00d"; | |
/* cross, times */ | |
cursor: pointer; | |
font-family: FontAwesome; | |
font-size: 1.3em; | |
margin-right: 4px; | |
min-width: 1em; | |
vertical-align: middle; | |
} | |
input[type=checkbox]:checked + label:before { | |
content: "\f00c"; | |
/* checkmark */ | |
} | |
/* indicate when an offscreen checkbox is focused: */ | |
input[type=checkbox]:focus + label:before { | |
outline: 1px solid #999; | |
} | |
input[type=range].binary-slider { | |
width: 48px; | |
} | |
input[type=range]#volume-slider { | |
width: 110px; | |
} | |
input[type=range] { | |
-webkit-appearance: none; | |
margin: 0px 0; | |
display: inline-block; | |
width: auto; | |
vertical-align: middle; | |
background-color: transparent; | |
border-radius: 11px; | |
} | |
input[type=range]::-moz-range-track { | |
height: 22px; | |
cursor: pointer; | |
background: #333; | |
border-radius: 11px; | |
} | |
input[type=range]::-moz-range-thumb { | |
border: none; | |
height: 22px; | |
width: 22px; | |
border-radius: 50%; | |
background: #999; | |
cursor: pointer; | |
} | |
input[type=range]::-webkit-slider-runnable-track { | |
height: 22px; | |
cursor: pointer; | |
background: #333; | |
border-radius: 11px; | |
} | |
input[type=range]::-webkit-slider-thumb { | |
height: 22px; | |
width: 22px; | |
border-radius: 11px; | |
background: #999; | |
cursor: pointer; | |
-webkit-appearance: none; | |
margin-top: 0px; | |
} | |
input[type=range]::-ms-track { | |
height: 22px; | |
cursor: pointer; | |
background: transparent; | |
border-color: transparent; | |
color: transparent; | |
} | |
input[type=range]::-ms-fill-lower { | |
background: #333; | |
border-radius: 11px; | |
} | |
input[type=range]::-ms-fill-upper { | |
background: #333; | |
border-radius: 11px; | |
} | |
input[type=range]::-ms-thumb { | |
width: 22px; | |
height: 22px; | |
border-radius: 11px; | |
background: #999; | |
cursor: pointer; | |
} | |
button#btn-volume-test { | |
vertical-align: middle; | |
padding: 4px; | |
background-color: #333; | |
color: #999; | |
border-radius: 8px; | |
} | |
button#btn-volume-test:hover { | |
background-color: #444; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" rel="stylesheet" /> | |
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" /> | |
<link href="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js" rel="stylesheet" /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment