feat: launch planned days with morning reminders (Closes #1166)
This commit is contained in:
parent
99b60e5f5f
commit
d86e84253a
|
|
@ -476,10 +476,15 @@
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
warmTodayOffline();
|
warmTodayOffline();
|
||||||
},
|
},
|
||||||
onRemotePlan: plan => {
|
onRemotePlan: async plan => {
|
||||||
if (!planningOwnerLogin) return;
|
if (!planningOwnerLogin) return;
|
||||||
latestTodayPlan = plan;
|
latestTodayPlan = plan;
|
||||||
promoteTomorrowIfDue(plan);
|
const startDayLaunch = window.location.hash === '#/my-work/start-day';
|
||||||
|
await promoteTomorrowIfDue(plan);
|
||||||
|
if (startDayLaunch) {
|
||||||
|
window.history.replaceState({}, '', '#/my-work/today');
|
||||||
|
openMobileStartDay();
|
||||||
|
}
|
||||||
todayWork.replacePlanning({
|
todayWork.replacePlanning({
|
||||||
capacity_minutes: plan.capacity_minutes ?? null,
|
capacity_minutes: plan.capacity_minutes ?? null,
|
||||||
estimates: plan.estimates || {},
|
estimates: plan.estimates || {},
|
||||||
|
|
@ -8089,7 +8094,10 @@
|
||||||
if (workSession.active()) workSession.reconcile();
|
if (workSession.active()) workSession.reconcile();
|
||||||
});
|
});
|
||||||
let pushController = null;
|
let pushController = null;
|
||||||
for (const selector of [qs('#push-deadline-hour'), qs('#device-setup-deadline-hour')]) {
|
for (const selector of [
|
||||||
|
qs('#push-deadline-hour'), qs('#device-setup-deadline-hour'),
|
||||||
|
qs('#push-start-day-hour'), qs('#device-setup-start-day-hour'),
|
||||||
|
]) {
|
||||||
for (let hour = 0; hour < 24; hour += 1) {
|
for (let hour = 0; hour < 24; hour += 1) {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = String(hour);
|
option.value = String(hour);
|
||||||
|
|
@ -8112,6 +8120,13 @@
|
||||||
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
||||||
qs('#push-deadline-days').value = event.target.value;
|
qs('#push-deadline-days').value = event.target.value;
|
||||||
});
|
});
|
||||||
|
qs('#push-start-day-hour').addEventListener('change', event => {
|
||||||
|
qs('#device-setup-start-day-hour').value = event.target.value;
|
||||||
|
if (qs('#push-start-day').checked) pushController?.changeStartDay();
|
||||||
|
});
|
||||||
|
qs('#device-setup-start-day-hour').addEventListener('change', event => {
|
||||||
|
qs('#push-start-day-hour').value = event.target.value;
|
||||||
|
});
|
||||||
let pushControllerReady = Promise.resolve(null);
|
let pushControllerReady = Promise.resolve(null);
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||||
|
|
@ -8124,6 +8139,9 @@
|
||||||
deadlineStatus:qs('#push-deadline-status'),
|
deadlineStatus:qs('#push-deadline-status'),
|
||||||
deadlineHour:qs('#push-deadline-hour'),
|
deadlineHour:qs('#push-deadline-hour'),
|
||||||
deadlineDays:qs('#push-deadline-days'),
|
deadlineDays:qs('#push-deadline-days'),
|
||||||
|
startDayControl:qs('#push-start-day'),
|
||||||
|
startDayStatus:qs('#push-start-day-status'),
|
||||||
|
startDayHour:qs('#push-start-day-hour'),
|
||||||
deadlineSnooze:qs('#deadline-snooze'),
|
deadlineSnooze:qs('#deadline-snooze'),
|
||||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||||
|
|
@ -8142,6 +8160,7 @@
|
||||||
pushController = controller;
|
pushController = controller;
|
||||||
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
|
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
|
||||||
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
|
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
|
||||||
|
qs('#device-setup-start-day-hour').value = qs('#push-start-day-hour').value;
|
||||||
return controller;
|
return controller;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
qs('#push-updates').disabled = true;
|
qs('#push-updates').disabled = true;
|
||||||
|
|
@ -8152,6 +8171,14 @@
|
||||||
qs('#push-updates').disabled = true;
|
qs('#push-updates').disabled = true;
|
||||||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||||||
}
|
}
|
||||||
|
qs('#device-setup-start-day').addEventListener('click', async () => {
|
||||||
|
const controller = await pushControllerReady;
|
||||||
|
if (!controller) return;
|
||||||
|
qs('#push-start-day-hour').value = qs('#device-setup-start-day-hour').value;
|
||||||
|
qs('#push-start-day').checked = true;
|
||||||
|
await controller.changeStartDay();
|
||||||
|
qs('#device-setup-start-day-status').textContent = qs('#push-start-day-status').textContent;
|
||||||
|
});
|
||||||
let deferredInstallPrompt = null;
|
let deferredInstallPrompt = null;
|
||||||
window.addEventListener('beforeinstallprompt', event => {
|
window.addEventListener('beforeinstallprompt', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,14 @@
|
||||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="device-setup-step">
|
||||||
|
<div><strong>Start my planned day</strong><p class="small" id="device-setup-start-day-status" role="status">Get one private reminder when Tomorrow is ready.</p></div>
|
||||||
|
<div class="device-setup-deadline-controls">
|
||||||
|
<label for="device-setup-start-day-hour">Local hour</label>
|
||||||
|
<select id="device-setup-start-day-hour" aria-label="Start-day reminder local hour"></select>
|
||||||
|
<button class="device-setup-action" id="device-setup-start-day" type="button">Enable</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -209,6 +217,9 @@
|
||||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
<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>
|
<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>
|
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||||
|
<label class="push-update-control" for="push-start-day"><input id="push-start-day" type="checkbox" /> Remind me when my planned day is ready</label>
|
||||||
|
<label for="push-start-day-hour">Start-day hour <select id="push-start-day-hour" aria-label="Start-day reminder local hour"></select></label>
|
||||||
|
<span class="small" id="push-start-day-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>
|
<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>
|
<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="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
else root.createPushNotifications = factory;
|
else root.createPushNotifications = factory;
|
||||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||||
|
startDayControl, startDayStatus, startDayHour,
|
||||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||||
notification, serviceWorker, fetchJson,
|
notification, serviceWorker, fetchJson,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -127,8 +128,10 @@
|
||||||
control.checked = false;
|
control.checked = false;
|
||||||
if (testControl) testControl.hidden = true;
|
if (testControl) testControl.hidden = true;
|
||||||
if (deadlineControl) deadlineControl.checked = false;
|
if (deadlineControl) deadlineControl.checked = false;
|
||||||
|
if (startDayControl) startDayControl.checked = false;
|
||||||
configuration.subscribed = false;
|
configuration.subscribed = false;
|
||||||
configuration.deadline_enabled = false;
|
configuration.deadline_enabled = false;
|
||||||
|
configuration.start_day_enabled = false;
|
||||||
pendingIntent = null;
|
pendingIntent = null;
|
||||||
status.textContent = 'New update notifications are off for this device.';
|
status.textContent = 'New update notifications are off for this device.';
|
||||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||||
|
|
@ -225,13 +228,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeStartDay() {
|
||||||
|
startDayControl.disabled = true;
|
||||||
|
if (startDayHour) startDayHour.disabled = true;
|
||||||
|
try {
|
||||||
|
const registration = await serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (startDayControl.checked) pendingIntent = 'start-day';
|
||||||
|
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
||||||
|
if (startDayControl.checked && !subscription) {
|
||||||
|
startDayControl.checked = false;
|
||||||
|
startDayStatus.textContent = status.textContent;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
||||||
|
await fetchJson('api/v1/push-subscription/start-day', {
|
||||||
|
method:'PUT',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
||||||
|
});
|
||||||
|
configuration.start_day_enabled = startDayControl.checked;
|
||||||
|
configuration.start_day_timezone = timezone;
|
||||||
|
configuration.start_day_reminder_hour = reminderHour;
|
||||||
|
pendingIntent = null;
|
||||||
|
startDayStatus.textContent = startDayControl.checked
|
||||||
|
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
||||||
|
: 'Start-day reminders are off for this device.';
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
startDayControl.checked = !startDayControl.checked;
|
||||||
|
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
startDayControl.disabled = false;
|
||||||
|
if (startDayHour) startDayHour.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function enableDeadline() {
|
async function enableDeadline() {
|
||||||
deadlineControl.checked = true;
|
deadlineControl.checked = true;
|
||||||
return changeDeadline();
|
return changeDeadline();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recoverPermission(intent = null) {
|
async function recoverPermission(intent = null) {
|
||||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
|
||||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||||
if (recoveryPromise) return recoveryPromise;
|
if (recoveryPromise) return recoveryPromise;
|
||||||
recoveryPromise = (async () => {
|
recoveryPromise = (async () => {
|
||||||
|
|
@ -239,6 +280,10 @@
|
||||||
deadlineControl.checked = true;
|
deadlineControl.checked = true;
|
||||||
return changeDeadline();
|
return changeDeadline();
|
||||||
}
|
}
|
||||||
|
if (pendingIntent === 'start-day') {
|
||||||
|
startDayControl.checked = true;
|
||||||
|
return changeStartDay();
|
||||||
|
}
|
||||||
return Boolean(await enable());
|
return Boolean(await enable());
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
|
|
@ -253,24 +298,31 @@
|
||||||
control.addEventListener('change', change);
|
control.addEventListener('change', change);
|
||||||
testControl?.addEventListener('click', testDelivery);
|
testControl?.addEventListener('click', testDelivery);
|
||||||
deadlineControl?.addEventListener('change', changeDeadline);
|
deadlineControl?.addEventListener('change', changeDeadline);
|
||||||
|
startDayControl?.addEventListener('change', changeStartDay);
|
||||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||||
configuration = await fetchJson('api/v1/push-subscription');
|
configuration = await fetchJson('api/v1/push-subscription');
|
||||||
if (!configuration.available) {
|
if (!configuration.available) {
|
||||||
control.disabled = true;
|
control.disabled = true;
|
||||||
if (deadlineControl) deadlineControl.disabled = true;
|
if (deadlineControl) deadlineControl.disabled = true;
|
||||||
|
if (startDayControl) startDayControl.disabled = true;
|
||||||
status.textContent = 'New update notifications are not available on this server.';
|
status.textContent = 'New update notifications are not available on this server.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
control.checked = Boolean(configuration.subscribed);
|
control.checked = Boolean(configuration.subscribed);
|
||||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||||
|
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||||
|
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||||
renderDeliveryHealth();
|
renderDeliveryHealth();
|
||||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||||
: 'Deadline reminders are off for this device.';
|
: 'Deadline reminders are off for this device.';
|
||||||
|
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||||
|
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||||
|
: 'Start-day reminders are off for this device.';
|
||||||
renderDeadlineSnooze();
|
renderDeadlineSnooze();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v124';
|
const CACHE = 'stackchain-dashboard-shell-v125';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
@ -500,6 +500,20 @@ self.addEventListener('push', event => {
|
||||||
const notificationId = Number(payload.notification_id);
|
const notificationId = Number(payload.notification_id);
|
||||||
const updateCount = Number(payload.update_count);
|
const updateCount = Number(payload.update_count);
|
||||||
const deadlineCount = Number(payload.deadline_count);
|
const deadlineCount = Number(payload.deadline_count);
|
||||||
|
const planDate = String(payload.plan_date || '');
|
||||||
|
if (
|
||||||
|
route === '#/my-work/start-day'
|
||||||
|
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
|
||||||
|
&& tag === 'stackchain-start-day-' + planDate
|
||||||
|
) {
|
||||||
|
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
|
||||||
|
body: 'Open Stackchain to prepare Today.',
|
||||||
|
tag,
|
||||||
|
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
|
||||||
|
data: {route},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
route === '#/my-work/agenda'
|
route === '#/my-work/agenda'
|
||||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||||
|
|
|
||||||
68
src/main.py
68
src/main.py
|
|
@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
|
||||||
from src.push_notifications import (
|
from src.push_notifications import (
|
||||||
PushConfiguration,
|
PushConfiguration,
|
||||||
dispatch_deadline_reminders,
|
dispatch_deadline_reminders,
|
||||||
|
dispatch_start_day_reminders,
|
||||||
dispatch_unread_updates,
|
dispatch_unread_updates,
|
||||||
send_web_push,
|
send_web_push,
|
||||||
)
|
)
|
||||||
|
|
@ -121,6 +122,14 @@ async def _push_channel_loop(dispatch, *, interval: float) -> None:
|
||||||
next_tick += elapsed_intervals * interval
|
next_tick += elapsed_intervals * interval
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_day_plan_snapshot() -> dict:
|
||||||
|
user = await current_user()
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
return {}
|
||||||
|
return await asyncio.to_thread(_today_store().get_tomorrow, login)
|
||||||
|
|
||||||
|
|
||||||
async def _push_poll_loop() -> None:
|
async def _push_poll_loop() -> None:
|
||||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||||
deadline_interval = max(
|
deadline_interval = max(
|
||||||
|
|
@ -163,11 +172,24 @@ async def _push_poll_loop() -> None:
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def dispatch_start_day() -> None:
|
||||||
|
await dispatch_start_day_reminders(
|
||||||
|
_push_subscription_store,
|
||||||
|
_push_configuration(),
|
||||||
|
_start_day_plan_snapshot,
|
||||||
|
session_statuses=dashboard_auth.managed_session_statuses,
|
||||||
|
send_timeout_seconds=send_timeout,
|
||||||
|
lease_seconds=lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
channel_tasks = (
|
channel_tasks = (
|
||||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||||
),
|
),
|
||||||
|
asyncio.create_task(
|
||||||
|
_push_channel_loop(dispatch_start_day, interval=deadline_interval)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
done, _pending = await asyncio.wait(
|
done, _pending = await asyncio.wait(
|
||||||
|
|
@ -433,6 +455,23 @@ class DeadlineReminderPayload(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class StartDayReminderPayload(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
timezone: str = Field(min_length=1, max_length=64)
|
||||||
|
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, value: str) -> str:
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError as error:
|
||||||
|
raise ValueError("Valid IANA timezone required") from error
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
StepUpAction = Literal[
|
StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
|
|
@ -2198,6 +2237,9 @@ async def push_status(request: Request):
|
||||||
preferences = await asyncio.to_thread(
|
preferences = await asyncio.to_thread(
|
||||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||||
)
|
)
|
||||||
|
start_day_preferences = await asyncio.to_thread(
|
||||||
|
_push_subscription_store.start_day_preferences, device_id
|
||||||
|
)
|
||||||
delivery_health = await asyncio.to_thread(
|
delivery_health = await asyncio.to_thread(
|
||||||
_push_subscription_store.delivery_health, device_id
|
_push_subscription_store.delivery_health, device_id
|
||||||
)
|
)
|
||||||
|
|
@ -2210,6 +2252,9 @@ async def push_status(request: Request):
|
||||||
"reminder_hour": preferences["reminder_hour"],
|
"reminder_hour": preferences["reminder_hour"],
|
||||||
"reminder_days": preferences["reminder_days"],
|
"reminder_days": preferences["reminder_days"],
|
||||||
"snoozed_until": preferences["snoozed_until"],
|
"snoozed_until": preferences["snoozed_until"],
|
||||||
|
"start_day_enabled": start_day_preferences["enabled"],
|
||||||
|
"start_day_timezone": start_day_preferences["timezone"],
|
||||||
|
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||||
"delivery_health": delivery_health,
|
"delivery_health": delivery_health,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2335,6 +2380,29 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/push-subscription/start-day")
|
||||||
|
async def update_start_day_reminders(payload: StartDayReminderPayload, request: Request):
|
||||||
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
request.state.dashboard_session
|
||||||
|
)
|
||||||
|
if payload.enabled and not await asyncio.to_thread(
|
||||||
|
_push_subscription_store.is_subscribed, device_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_push_subscription_store.set_start_day_preferences,
|
||||||
|
device_id,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
timezone=payload.timezone,
|
||||||
|
reminder_hour=payload.reminder_hour,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"start_day_enabled": payload.enabled,
|
||||||
|
"start_day_timezone": payload.timezone,
|
||||||
|
"start_day_reminder_hour": payload.reminder_hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
||||||
async def snooze_deadline_reminder(request: Request):
|
async def snooze_deadline_reminder(request: Request):
|
||||||
device_id = await dashboard_auth.session_management_id(
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,108 @@ async def dispatch_deadline_reminders(
|
||||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_start_day_reminders(
|
||||||
|
store: PushSubscriptionStore,
|
||||||
|
configuration: PushConfiguration,
|
||||||
|
tomorrow: Callable[[], Awaitable[dict]],
|
||||||
|
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||||
|
send_timeout_seconds: float = 10.0,
|
||||||
|
lease_seconds: float = 60.0,
|
||||||
|
) -> int:
|
||||||
|
if not configuration.enabled:
|
||||||
|
return 0
|
||||||
|
owner = secrets.token_urlsafe(18)
|
||||||
|
acquired = await asyncio.to_thread(
|
||||||
|
store.acquire_dispatch_lease,
|
||||||
|
owner,
|
||||||
|
channel="start-day",
|
||||||
|
now=time.time(),
|
||||||
|
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||||
|
)
|
||||||
|
if not acquired:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
devices = await asyncio.to_thread(store.start_day_reminder_devices)
|
||||||
|
if not devices:
|
||||||
|
return 0
|
||||||
|
current = now or datetime.now(timezone.utc)
|
||||||
|
due_devices = []
|
||||||
|
for device in devices:
|
||||||
|
try:
|
||||||
|
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
continue
|
||||||
|
if local_now.hour >= device.reminder_hour:
|
||||||
|
due_devices.append((device, local_now.date().isoformat()))
|
||||||
|
if not due_devices:
|
||||||
|
return 0
|
||||||
|
plan = await tomorrow()
|
||||||
|
ids = plan.get("ids") if isinstance(plan, dict) else None
|
||||||
|
plan_date = plan.get("plan_date") if isinstance(plan, dict) else None
|
||||||
|
if not isinstance(ids, list) or not ids or not isinstance(plan_date, str):
|
||||||
|
return 0
|
||||||
|
due_devices = [
|
||||||
|
(device, local_day) for device, local_day in due_devices
|
||||||
|
if local_day >= plan_date and device.delivered_plan_date != plan_date
|
||||||
|
]
|
||||||
|
if not due_devices:
|
||||||
|
return 0
|
||||||
|
if session_statuses is not None:
|
||||||
|
try:
|
||||||
|
statuses = await session_statuses(
|
||||||
|
[device.session_id for device, _local_day in due_devices]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
for device, _local_day in due_devices:
|
||||||
|
if statuses.get(device.session_id) != "active":
|
||||||
|
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||||
|
due_devices = [
|
||||||
|
pair for pair in due_devices
|
||||||
|
if statuses.get(pair[0].session_id) == "active"
|
||||||
|
]
|
||||||
|
payload = json.dumps({
|
||||||
|
"title": "Your planned day is ready",
|
||||||
|
"body": "Open Stackchain to prepare Today.",
|
||||||
|
"route": "#/my-work/start-day",
|
||||||
|
"tag": f"stackchain-start-day-{plan_date}",
|
||||||
|
"plan_date": plan_date,
|
||||||
|
}, separators=(",", ":"))
|
||||||
|
delivered = 0
|
||||||
|
for device, _local_day in due_devices:
|
||||||
|
try:
|
||||||
|
operation = (
|
||||||
|
send(device.subscription, payload) if send is not None
|
||||||
|
else send_web_push(device.subscription, payload, configuration)
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||||
|
except Exception as error:
|
||||||
|
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||||
|
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||||
|
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
store.mark_delivery_failed,
|
||||||
|
device.session_id,
|
||||||
|
"start-day",
|
||||||
|
_delivery_failure_reason(error),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
await asyncio.to_thread(
|
||||||
|
store.mark_delivery_succeeded, device.session_id, "start-day"
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
store.mark_start_day_reminder_delivered, device.session_id, plan_date
|
||||||
|
)
|
||||||
|
delivered += 1
|
||||||
|
return delivered
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="start-day")
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_deadline_reminders_unlocked(
|
async def _dispatch_deadline_reminders_unlocked(
|
||||||
store: PushSubscriptionStore,
|
store: PushSubscriptionStore,
|
||||||
configuration: PushConfiguration,
|
configuration: PushConfiguration,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ class DeadlineReminderDevice:
|
||||||
snoozed_until: float | None
|
snoozed_until: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StartDayReminderDevice:
|
||||||
|
session_id: str
|
||||||
|
subscription: dict
|
||||||
|
timezone: str
|
||||||
|
reminder_hour: int
|
||||||
|
delivered_plan_date: str | None
|
||||||
|
|
||||||
|
|
||||||
class DisabledPushSubscriptionStore:
|
class DisabledPushSubscriptionStore:
|
||||||
"""No-persistence store used when Web Push is not configured."""
|
"""No-persistence store used when Web Push is not configured."""
|
||||||
|
|
||||||
|
|
@ -66,6 +75,12 @@ class DisabledPushSubscriptionStore:
|
||||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def start_day_preferences(self, session_id: str) -> dict:
|
||||||
|
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
|
||||||
|
|
||||||
|
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||||
|
return []
|
||||||
|
|
||||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -96,6 +111,12 @@ class DisabledPushSubscriptionStore:
|
||||||
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def set_start_day_preferences(self, *args, **kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -206,6 +227,15 @@ class PushSubscriptionStore:
|
||||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||||
|
delivered_plan_date TEXT,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
delivery_columns = {
|
delivery_columns = {
|
||||||
|
|
@ -485,6 +515,60 @@ class PushSubscriptionStore:
|
||||||
(local_day, session_id),
|
(local_day, session_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_start_day_preferences(
|
||||||
|
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO push_start_day_preferences(
|
||||||
|
session_id, enabled, timezone, reminder_hour
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
timezone = excluded.timezone,
|
||||||
|
reminder_hour = excluded.reminder_hour""",
|
||||||
|
(session_id, int(enabled), timezone, reminder_hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_day_preferences(self, session_id: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""SELECT enabled, timezone, reminder_hour
|
||||||
|
FROM push_start_day_preferences WHERE session_id = ?""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"enabled": bool(row[0]) if row else False,
|
||||||
|
"timezone": row[1] if row else "UTC",
|
||||||
|
"reminder_hour": row[2] if row else 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||||
|
p.reminder_hour, p.delivered_plan_date
|
||||||
|
FROM push_subscriptions s
|
||||||
|
JOIN push_start_day_preferences p ON p.session_id = s.session_id
|
||||||
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
StartDayReminderDevice(
|
||||||
|
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_start_day_reminder_delivered(
|
||||||
|
self, session_id: str, plan_date: str
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
|
||||||
|
WHERE session_id = ? AND enabled = 1""",
|
||||||
|
(plan_date, session_id),
|
||||||
|
)
|
||||||
|
|
||||||
def claim_unseen(
|
def claim_unseen(
|
||||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||||
) -> list[PushDelivery]:
|
) -> list[PushDelivery]:
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v124" in worker
|
assert "stackchain-dashboard-shell-v125" in worker
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v124" in worker
|
assert "stackchain-dashboard-shell-v125" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v124" in worker
|
assert "stackchain-dashboard-shell-v125" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v124" in worker
|
assert "stackchain-dashboard-shell-v125" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in worker
|
assert "stackchain-dashboard-shell-v125" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v124" in service_worker
|
assert "stackchain-dashboard-shell-v125" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ const deadlineControl = {
|
||||||
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
||||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||||
|
const startDayControl = {
|
||||||
|
checked:false, disabled:false,
|
||||||
|
addEventListener:(_name, callback) => state.startDayChange = callback,
|
||||||
|
};
|
||||||
|
const startDayHour = {value:'9', disabled:false};
|
||||||
|
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
|
||||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||||
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
||||||
const deadlineSnooze = {hidden:true};
|
const deadlineSnooze = {hidden:true};
|
||||||
|
|
@ -33,6 +39,7 @@ const registration = {pushManager:{
|
||||||
}};
|
}};
|
||||||
const feature = createPushNotifications({
|
const feature = createPushNotifications({
|
||||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||||
|
startDayControl, startDayStatus, startDayHour,
|
||||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||||
onReviewDeadlines:() => { state.reviewed = true; },
|
onReviewDeadlines:() => { state.reviewed = true; },
|
||||||
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
||||||
|
|
@ -111,6 +118,26 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_day_opt_in_reuses_subscription_and_persists_local_hour():
|
||||||
|
result = run_scenario("""
|
||||||
|
state.current = existing;
|
||||||
|
state.server = {available:true,subscribed:true,start_day_enabled:false,start_day_reminder_hour:9,public_key:'AQID'};
|
||||||
|
await feature.init();
|
||||||
|
startDayHour.value = '8';
|
||||||
|
startDayControl.checked = true;
|
||||||
|
await state.startDayChange();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["prompts"] == 0
|
||||||
|
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/start-day", "PUT"]
|
||||||
|
body = json.loads(result["requests"][-1][2])
|
||||||
|
assert body["enabled"] is True
|
||||||
|
assert body["reminder_hour"] == 8
|
||||||
|
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||||
|
assert result["startDayText"] == "Start-day reminder enabled for 08:00 local time."
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||||
result = run_scenario("""
|
result = run_scenario("""
|
||||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@ async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_ca
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
hold_other_channels = asyncio.Event()
|
||||||
|
|
||||||
async def no_wait(_seconds):
|
async def no_wait(_seconds):
|
||||||
return None
|
return None
|
||||||
|
|
@ -407,10 +408,15 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
raise asyncio.CancelledError
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
async def hold_dispatch(*_args, **_kwargs):
|
||||||
|
await hold_other_channels.wait()
|
||||||
|
|
||||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
||||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||||
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||||
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await main._push_poll_loop()
|
await main._push_poll_loop()
|
||||||
|
|
@ -431,8 +437,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||||
async def no_wait(_seconds):
|
async def no_wait(_seconds):
|
||||||
nonlocal sleeps
|
nonlocal sleeps
|
||||||
sleeps += 1
|
sleeps += 1
|
||||||
if sleeps <= 2:
|
if sleeps <= 3:
|
||||||
if sleeps == 2:
|
if sleeps == 3:
|
||||||
first_tick.set()
|
first_tick.set()
|
||||||
await first_tick.wait()
|
await first_tick.wait()
|
||||||
else:
|
else:
|
||||||
|
|
@ -445,14 +451,18 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||||
async def dispatch_deadlines(*_args, **_kwargs):
|
async def dispatch_deadlines(*_args, **_kwargs):
|
||||||
calls.append("deadline")
|
calls.append("deadline")
|
||||||
|
|
||||||
|
async def dispatch_start_day(*_args, **_kwargs):
|
||||||
|
calls.append("start-day")
|
||||||
|
|
||||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||||
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await main._push_poll_loop()
|
await main._push_poll_loop()
|
||||||
|
|
||||||
assert sorted(calls) == ["deadline", "unread"]
|
assert sorted(calls) == ["deadline", "start-day", "unread"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -472,9 +482,13 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
|
||||||
deadline_started.set()
|
deadline_started.set()
|
||||||
await release_unread.wait()
|
await release_unread.wait()
|
||||||
|
|
||||||
|
async def dispatch_start_day(*_args, **_kwargs):
|
||||||
|
await release_unread.wait()
|
||||||
|
|
||||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||||
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||||
|
|
||||||
poll = asyncio.create_task(main._push_poll_loop())
|
poll = asyncio.create_task(main._push_poll_loop())
|
||||||
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
|
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
|
||||||
|
|
@ -499,7 +513,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
||||||
|
|
||||||
await main._push_poll_loop()
|
await main._push_poll_loop()
|
||||||
|
|
||||||
assert sorted(intervals) == [30.0, 600.0]
|
assert sorted(intervals) == [30.0, 600.0, 600.0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -1261,6 +1275,9 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
"reminder_hour": 9,
|
"reminder_hour": 9,
|
||||||
"reminder_days": 2,
|
"reminder_days": 2,
|
||||||
"snoozed_until": None,
|
"snoozed_until": None,
|
||||||
|
"start_day_enabled": False,
|
||||||
|
"start_day_timezone": "UTC",
|
||||||
|
"start_day_reminder_hour": 9,
|
||||||
"delivery_health": {},
|
"delivery_health": {},
|
||||||
}
|
}
|
||||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -824,6 +824,36 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
await dispatchPush({
|
||||||
|
title:'must-not-render', body:'private/repo#42',
|
||||||
|
tag:'stackchain-start-day-2026-08-20', route:'#/my-work/start-day',
|
||||||
|
plan_date:'2026-08-20',
|
||||||
|
});
|
||||||
|
await dispatchNotificationClick(
|
||||||
|
'#/my-work/start-day','prepare-today',null,'stackchain-start-day-2026-08-20'
|
||||||
|
);
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["notifications"] == [{
|
||||||
|
"title": "Your planned day is ready",
|
||||||
|
"options": {
|
||||||
|
"body": "Open Stackchain to prepare Today.",
|
||||||
|
"tag": "stackchain-start-day-2026-08-20",
|
||||||
|
"actions": [{"action": "prepare-today", "title": "Prepare Today"}],
|
||||||
|
"data": {"route": "#/my-work/start-day"},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
assert result["opened"] == [
|
||||||
|
"https://forge.example/dashboard/#/my-work/start-day"
|
||||||
|
]
|
||||||
|
assert "private/repo" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
|
def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -1137,7 +1167,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
148
tests/test_start_day_reminders.py
Normal file
148
tests/test_start_day_reminders.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.push_notifications import PushConfiguration, dispatch_start_day_reminders
|
||||||
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_start_day_reminder_sends_one_private_prompt_for_due_plan(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_start_day_preferences(
|
||||||
|
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def tomorrow():
|
||||||
|
return {
|
||||||
|
"revision": 3,
|
||||||
|
"ids": ["private/repo#42"],
|
||||||
|
"plan_date": "2026-08-20",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(json.loads(payload))
|
||||||
|
|
||||||
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
now = datetime(2026, 8, 20, 13, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert await dispatch_start_day_reminders(
|
||||||
|
store, configuration, tomorrow, send, now=now
|
||||||
|
) == 1
|
||||||
|
assert await dispatch_start_day_reminders(
|
||||||
|
store, configuration, tomorrow, send, now=now
|
||||||
|
) == 0
|
||||||
|
assert sent == [{
|
||||||
|
"title": "Your planned day is ready",
|
||||||
|
"body": "Open Stackchain to prepare Today.",
|
||||||
|
"route": "#/my-work/start-day",
|
||||||
|
"tag": "stackchain-start-day-2026-08-20",
|
||||||
|
"plan_date": "2026-08-20",
|
||||||
|
}]
|
||||||
|
assert "private/repo" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_start_day_reminder_waits_for_hour_and_nonempty_due_plan(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_start_day_preferences(
|
||||||
|
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(payload)
|
||||||
|
|
||||||
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
before_hour = datetime(2026, 8, 20, 15, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def due_plan():
|
||||||
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||||
|
|
||||||
|
assert await dispatch_start_day_reminders(
|
||||||
|
store, configuration, due_plan, send, now=before_hour
|
||||||
|
) == 0
|
||||||
|
|
||||||
|
async def empty_plan():
|
||||||
|
return {"ids": [], "plan_date": "2026-08-20"}
|
||||||
|
|
||||||
|
assert await dispatch_start_day_reminders(
|
||||||
|
store, configuration, empty_plan, send,
|
||||||
|
now=datetime(2026, 8, 20, 16, 0, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_device_can_enable_start_day_reminders(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||||
|
|
||||||
|
async def management_id(_session):
|
||||||
|
return "device-a"
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||||
|
payload = main.StartDayReminderPayload(
|
||||||
|
enabled=True, timezone="America/New_York", reminder_hour=8
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await main.update_start_day_reminders(payload, request) == {
|
||||||
|
"start_day_enabled": True,
|
||||||
|
"start_day_timezone": "America/New_York",
|
||||||
|
"start_day_reminder_hour": 8,
|
||||||
|
}
|
||||||
|
assert store.start_day_preferences("device-a") == {
|
||||||
|
"enabled": True, "timezone": "America/New_York", "reminder_hour": 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_worker_snapshot_reads_the_confirmed_accounts_tomorrow_plan(monkeypatch):
|
||||||
|
async def current_user():
|
||||||
|
return {"login": "Timmy"}
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def get_tomorrow(self, login):
|
||||||
|
assert login == "Timmy"
|
||||||
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", current_user)
|
||||||
|
monkeypatch.setattr(main, "_today_store", lambda: Store())
|
||||||
|
|
||||||
|
assert await main._start_day_plan_snapshot() == {
|
||||||
|
"ids": ["one"], "plan_date": "2026-08-20",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion():
|
||||||
|
frontend = Path(__file__).parents[1] / "frontend"
|
||||||
|
html = (frontend / "index.html").read_text()
|
||||||
|
dashboard = (frontend / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert 'id="push-start-day"' in html
|
||||||
|
assert 'id="push-start-day-hour"' in html
|
||||||
|
assert 'id="device-setup-start-day"' in html
|
||||||
|
assert "startDayControl:qs('#push-start-day')" in dashboard
|
||||||
|
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
||||||
|
assert "window.location.hash === '#/my-work/start-day'" in dashboard
|
||||||
|
assert "await promoteTomorrowIfDue(plan)" in dashboard
|
||||||
|
assert "openMobileStartDay()" in dashboard
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v124';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v125';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v124" in source
|
assert "stackchain-dashboard-shell-v125" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user