feat: add timed mobile Today breaks (Closes #1036)
This commit is contained in:
1 parent
ca671f0d00
commit
ea08f566ff
11 files changed
+474
-5
No files matched your search
+18
-1
@@ -254,6 +254,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-break-panel h2, .today-break-panel p { margin-top:0; }
|
||||
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
|
||||
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
|
||||
.today-break-actions button { min-width:0; width:100%; }
|
||||
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
|
||||
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
|
||||
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
|
||||
.today-break-status:has(> span[hidden]) { display:none; }
|
||||
.today-break-status button { min-height:44px; min-width:112px; }
|
||||
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -1072,8 +1087,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-today-break-open] { grid-column:1 / -1; }
|
||||
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
.today-completion-undo { bottom:calc(168px + env(safe-area-inset-bottom)); }
|
||||
.today-completion-undo { bottom:calc(224px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1828,6 +1828,7 @@
|
||||
selectTodayWork();
|
||||
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
||||
},
|
||||
onResume: () => resumeTodaySession(),
|
||||
onComplete: identity => {
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||||
return completeTodayItem(item);
|
||||
|
||||
@@ -1536,8 +1536,30 @@
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-today-break-open type="button">Take break</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<div class="today-break-status" role="status" aria-live="polite">
|
||||
<span id="today-break-status" hidden></span>
|
||||
<button id="resume-today-break" type="button" hidden>Resume Today</button>
|
||||
</div>
|
||||
<dialog class="today-break-sheet" id="today-break-sheet" aria-labelledby="today-break-title">
|
||||
<section class="today-break-panel">
|
||||
<header><div><p class="small muted">Today timer</p><h2 id="today-break-title">Take a timed break</h2></div><button id="cancel-today-break" type="button">Cancel</button></header>
|
||||
<p class="small">Your current item stays selected and tracked time remains paused until you explicitly resume.</p>
|
||||
<div class="today-break-actions" aria-label="Break duration">
|
||||
<button type="button" data-today-break-minutes="5">5 minutes</button>
|
||||
<button type="button" data-today-break-minutes="15">15 minutes</button>
|
||||
<button type="button" data-today-break-minutes="30">30 minutes</button>
|
||||
</div>
|
||||
<form id="today-break-custom-form" class="today-break-custom">
|
||||
<label for="today-break-custom-minutes">Custom minutes</label>
|
||||
<input id="today-break-custom-minutes" type="number" inputmode="numeric" min="1" max="120" step="1" required />
|
||||
<button type="submit">Start break</button>
|
||||
<p id="today-break-error" class="small" role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
@@ -1632,6 +1654,7 @@
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-break.js"></script>
|
||||
<script src="static/today-lock-screen.js"></script>
|
||||
<script src="static/today-session-sync.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
|
||||
@@ -51,6 +51,7 @@ const SHELL = [
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-break.js',
|
||||
BASE + 'static/today-lock-screen.js',
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
function createTodayBreak({
|
||||
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
|
||||
minutesInput = null, error = null, status, resumeButton,
|
||||
qs = null, queryAll = null,
|
||||
historyRef = null, windowRef = null,
|
||||
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
|
||||
onChange = () => {}, onResume = () => {},
|
||||
}) {
|
||||
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
|
||||
historyRef ||= globalThis.history;
|
||||
windowRef ||= globalThis.window;
|
||||
sheet ||= qs?.('#today-break-sheet');
|
||||
openButtons ||= queryAll?.('[data-today-break-open]') || [];
|
||||
cancelButton ||= qs?.('#cancel-today-break');
|
||||
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
|
||||
form ||= qs?.('#today-break-custom-form');
|
||||
minutesInput ||= qs?.('#today-break-custom-minutes');
|
||||
error ||= qs?.('#today-break-error');
|
||||
status ||= qs?.('#today-break-status');
|
||||
resumeButton ||= qs?.('#resume-today-break');
|
||||
let ticker = null;
|
||||
let historyEntry = false;
|
||||
|
||||
const stopTicker = () => {
|
||||
if (ticker !== null) clearIntervalRef(ticker);
|
||||
ticker = null;
|
||||
};
|
||||
const render = () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!pending) {
|
||||
stopTicker();
|
||||
status.hidden = true;
|
||||
resumeButton.hidden = true;
|
||||
openButtons.forEach(button => { button.hidden = false; });
|
||||
return false;
|
||||
}
|
||||
status.hidden = false;
|
||||
resumeButton.hidden = false;
|
||||
openButtons.forEach(button => { button.hidden = true; });
|
||||
const remaining = Math.max(0, pending.deadline_at - now());
|
||||
if (pending.expired || remaining <= 0) {
|
||||
status.textContent = 'Break over · ready to resume';
|
||||
} else {
|
||||
const seconds = Math.ceil(remaining / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
|
||||
}
|
||||
if (ticker === null) ticker = setIntervalRef(render, 1000);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
historyRef?.back();
|
||||
}
|
||||
};
|
||||
const start = minutes => {
|
||||
if (!timer.startBreak(Number(minutes))) return false;
|
||||
if (error) error.textContent = '';
|
||||
close();
|
||||
render();
|
||||
onChange();
|
||||
return true;
|
||||
};
|
||||
const open = () => {
|
||||
if (!timer.snapshot().identity) return false;
|
||||
if (!sheet.open) {
|
||||
sheet.showModal();
|
||||
if (historyRef) {
|
||||
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
|
||||
historyEntry = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
openButtons.forEach(button => button.addEventListener('click', open));
|
||||
cancelButton?.addEventListener('click', close);
|
||||
windowRef?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
if (sheet.open) sheet.close();
|
||||
});
|
||||
presetButtons.forEach(button => button.addEventListener('click', () =>
|
||||
start(button.dataset.todayBreakMinutes)
|
||||
));
|
||||
form?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (!start(Number(minutesInput?.value))) {
|
||||
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
|
||||
minutesInput?.focus();
|
||||
}
|
||||
});
|
||||
resumeButton.addEventListener('click', () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!timer.resumeBreak()) return;
|
||||
onResume(pending.identity);
|
||||
render();
|
||||
onChange();
|
||||
});
|
||||
render();
|
||||
|
||||
return { open, close, start, render };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;
|
||||
+42
-2
@@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
pending_interruption:null, attention_interruption:null, timed_break:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
@@ -42,6 +42,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
const value = state.timed_break;
|
||||
return value && typeof value.identity === 'string' && value.identity &&
|
||||
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
|
||||
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
@@ -75,6 +81,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
@@ -90,6 +97,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
@@ -106,6 +114,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
startBreak(minutes) {
|
||||
const duration = Number(minutes);
|
||||
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
|
||||
const state = read();
|
||||
const identity = state.active_identity;
|
||||
if (!identity || !state.entries[identity]) return false;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
resumeBreak() {
|
||||
const state = read();
|
||||
const pending = validBreak(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
|
||||
state.timed_break = null;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
@@ -114,6 +148,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
@@ -217,9 +252,13 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
let breakView = null;
|
||||
if (typeof createTodayBreak === 'function') {
|
||||
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
|
||||
}
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
@@ -265,6 +304,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
breakView?.render();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
|
||||
Reference in new issue
Block a user