feat: control Today from mobile lock screen (Closes #1026)
This commit is contained in:
1 parent
7ba238fb53
commit
a7f4f9714f
17 files changed
+424
-29
No files matched your search
+29
-1
@@ -1807,10 +1807,16 @@
|
||||
},
|
||||
});
|
||||
let todaySessionSync = null;
|
||||
let todayLockScreen = null;
|
||||
const timer = createTodayTimer({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onChange: snapshot => todaySessionSync?.publish(snapshot),
|
||||
onChange: snapshot => {
|
||||
todaySessionSync?.publish(snapshot);
|
||||
queueMicrotask(() => {
|
||||
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
|
||||
});
|
||||
},
|
||||
});
|
||||
const timerView = createTodayTimerView({
|
||||
timer,
|
||||
@@ -1939,6 +1945,28 @@
|
||||
todayRecapView.finish(selectedWorkFilter);
|
||||
},
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
NotificationRef:window.Notification,
|
||||
control:qs('#today-lock-screen'),
|
||||
status:qs('#today-lock-screen-status'),
|
||||
locationRef:window.location,
|
||||
historyRef:window.history,
|
||||
onAction:action => {
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity || (action === 'pause' && !state.running) || (action === 'resume' && state.running)) return;
|
||||
const changed = action === 'pause' ? timer.pause() : timer.resume();
|
||||
if (changed === false) return;
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
|
||||
if (item) workSession.reopen(item);
|
||||
timerView.render();
|
||||
},
|
||||
});
|
||||
todayLockScreen.consumeLaunchAction();
|
||||
todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="today-lock-screen"><input id="today-lock-screen" type="checkbox" /> Show active Today controls on my lock screen</label>
|
||||
<span class="small" id="today-lock-screen-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -1624,6 +1626,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-lock-screen.js"></script>
|
||||
<script src="static/today-session-sync.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v118';
|
||||
const CACHE = 'stackchain-dashboard-shell-v119';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -51,6 +51,7 @@ const SHELL = [
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-lock-screen.js',
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
@@ -419,6 +420,26 @@ self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
async function updateTodayLockScreen(active, running) {
|
||||
const tag = 'stackchain-today-session';
|
||||
if (!active) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach(notification => notification.close());
|
||||
return;
|
||||
}
|
||||
await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', {
|
||||
body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.',
|
||||
tag,
|
||||
renotify:false,
|
||||
silent:true,
|
||||
actions: [
|
||||
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
||||
{ action:'open-today', title:'Open Today' },
|
||||
],
|
||||
data: { route:'#/my-work/today' },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
@@ -431,11 +452,15 @@ self.addEventListener('message', event => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabases();
|
||||
await updateTodayLockScreen(false, false);
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil(updateTodayLockScreen(event.data.active === true, event.data.running === true));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
@@ -528,9 +553,35 @@ async function openCanonicalIssueUrl(rawUrl) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function applyTodayTimerAction(action) {
|
||||
if (!['pause', 'resume'].includes(action)) return;
|
||||
const route = '#/my-work/today';
|
||||
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (client) {
|
||||
client.postMessage?.({ type:'stackchain-today-timer-action', action });
|
||||
return client.focus?.();
|
||||
}
|
||||
const target = new URL(BASE + '?today_timer_action=' + action + route, self.location.origin).href;
|
||||
return self.clients.openWindow(target);
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
const issueUrl = String(event.notification.data?.url || '');
|
||||
if (
|
||||
event.notification.tag === 'stackchain-today-session'
|
||||
&& route === '#/my-work/today'
|
||||
&& ['pause-today', 'resume-today', 'open-today', ''].includes(event.action)
|
||||
) {
|
||||
event.notification.close();
|
||||
if (event.action === 'pause-today' || event.action === 'resume-today') {
|
||||
event.waitUntil(applyTodayTimerAction(event.action === 'pause-today' ? 'pause' : 'resume'));
|
||||
} else {
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (issueUrl) {
|
||||
event.notification.close();
|
||||
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
function createTodayLockScreen({
|
||||
storage,
|
||||
getLogin,
|
||||
serviceWorker,
|
||||
NotificationRef,
|
||||
control,
|
||||
status,
|
||||
locationRef,
|
||||
historyRef,
|
||||
onAction = () => {},
|
||||
}) {
|
||||
const preferenceKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const supported = Boolean(serviceWorker && NotificationRef);
|
||||
const enabled = () => {
|
||||
const key = preferenceKey();
|
||||
return Boolean(key && storage?.getItem(key) === '1');
|
||||
};
|
||||
const setStatus = message => {
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
const post = async message => {
|
||||
const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active;
|
||||
target?.postMessage?.(message);
|
||||
};
|
||||
const hide = () => post({
|
||||
type:'stackchain-today-lock-screen', active:false, running:false,
|
||||
});
|
||||
const render = () => {
|
||||
if (control) {
|
||||
control.checked = enabled();
|
||||
control.disabled = !supported;
|
||||
}
|
||||
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
|
||||
else if (enabled()) setStatus('Lock-screen Today controls are on.');
|
||||
};
|
||||
const consumeAction = action => {
|
||||
if (!enabled() || !['pause', 'resume'].includes(action)) return false;
|
||||
onAction(action);
|
||||
return true;
|
||||
};
|
||||
const api = {
|
||||
enabled,
|
||||
async enable() {
|
||||
if (!supported) {
|
||||
render();
|
||||
return false;
|
||||
}
|
||||
const permission = NotificationRef.permission === 'granted' ?
|
||||
'granted' : await NotificationRef.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
if (control) control.checked = false;
|
||||
setStatus(permission === 'denied' ?
|
||||
'Lock-screen controls are blocked in browser settings.' :
|
||||
'Lock-screen controls were not enabled.');
|
||||
return false;
|
||||
}
|
||||
const key = preferenceKey();
|
||||
if (!key) return false;
|
||||
storage?.setItem(key, '1');
|
||||
render();
|
||||
return true;
|
||||
},
|
||||
async disable() {
|
||||
const key = preferenceKey();
|
||||
if (key) storage?.removeItem(key);
|
||||
if (control) control.checked = false;
|
||||
setStatus('Lock-screen Today controls are off.');
|
||||
await hide();
|
||||
return true;
|
||||
},
|
||||
async sync(snapshot, active) {
|
||||
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
|
||||
const visible = Boolean(active && snapshot?.identity);
|
||||
await post({
|
||||
type:'stackchain-today-lock-screen',
|
||||
active:visible,
|
||||
running:visible && Boolean(snapshot.running),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
consumeLaunchAction() {
|
||||
let url;
|
||||
try { url = new URL(locationRef?.href || ''); }
|
||||
catch (_error) { return false; }
|
||||
const action = url.searchParams.get('today_timer_action');
|
||||
if (!['pause', 'resume'].includes(action)) return false;
|
||||
url.searchParams.delete('today_timer_action');
|
||||
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
|
||||
return consumeAction(action);
|
||||
},
|
||||
render,
|
||||
};
|
||||
control?.addEventListener?.('change', () => {
|
||||
if (control.checked) api.enable();
|
||||
else api.disable();
|
||||
});
|
||||
serviceWorker?.addEventListener?.('message', event => {
|
||||
if (event.data?.type === 'stackchain-today-timer-action') consumeAction(String(event.data.action || ''));
|
||||
});
|
||||
render();
|
||||
return api;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen;
|
||||
Reference in new issue
Block a user