diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 198d6e4..6b297f7 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -479,7 +479,12 @@ onRemotePlan: plan => { if (!planningOwnerLogin) return; latestTodayPlan = plan; - promoteTomorrowIfDue(plan); + const startDayLaunch = window.location.hash === '#/my-work/start-day'; + promoteTomorrowIfDue(plan).finally(() => { + if (!startDayLaunch) return; + window.history.replaceState({}, '', '#/my-work/today'); + openMobileStartDay(); + }); todayWork.replacePlanning({ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, @@ -8089,7 +8094,10 @@ if (workSession.active()) workSession.reconcile(); }); 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) { const option = document.createElement('option'); option.value = String(hour); @@ -8112,6 +8120,13 @@ qs('#device-setup-deadline-days').addEventListener('change', event => { 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); if ('serviceWorker' in navigator) { pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => { @@ -8124,6 +8139,9 @@ deadlineStatus:qs('#push-deadline-status'), deadlineHour:qs('#push-deadline-hour'), 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'), deadlineSnoozeStatus:qs('#deadline-snooze-status'), deadlineSnoozeReview:qs('#review-snoozed-deadlines'), @@ -8142,6 +8160,7 @@ pushController = controller; 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-start-day-hour').value = qs('#push-start-day-hour').value; return controller; }).catch(error => { qs('#push-updates').disabled = true; @@ -8152,6 +8171,14 @@ qs('#push-updates').disabled = true; 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; window.addEventListener('beforeinstallprompt', event => { event.preventDefault(); diff --git a/frontend/index.html b/frontend/index.html index 4f5b6a3..2b73137 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -143,6 +143,14 @@ +
  • +
    Start my planned day

    Get one private reminder when Tomorrow is ready.

    +
    + + + +
    +
  • @@ -209,6 +217,9 @@ + + + diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index dbeb24c..8f760b2 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -3,6 +3,7 @@ else root.createPushNotifications = factory; })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, + startDayControl, startDayStatus, startDayHour, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines, notification, serviceWorker, fetchJson, }) { @@ -127,8 +128,10 @@ control.checked = false; if (testControl) testControl.hidden = true; if (deadlineControl) deadlineControl.checked = false; + if (startDayControl) startDayControl.checked = false; configuration.subscribed = false; configuration.deadline_enabled = false; + configuration.start_day_enabled = false; pendingIntent = null; status.textContent = 'New update notifications 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() { deadlineControl.checked = true; return changeDeadline(); } 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 (recoveryPromise) return recoveryPromise; recoveryPromise = (async () => { @@ -239,6 +280,10 @@ deadlineControl.checked = true; return changeDeadline(); } + if (pendingIntent === 'start-day') { + startDayControl.checked = true; + return changeStartDay(); + } return Boolean(await enable()); })(); try { @@ -253,24 +298,31 @@ control.addEventListener('change', change); testControl?.addEventListener('click', testDelivery); deadlineControl?.addEventListener('change', changeDeadline); + startDayControl?.addEventListener('change', changeStartDay); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); configuration = await fetchJson('api/v1/push-subscription'); if (!configuration.available) { control.disabled = true; if (deadlineControl) deadlineControl.disabled = true; + if (startDayControl) startDayControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; } control.checked = Boolean(configuration.subscribed); 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 (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); + if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); renderDeliveryHealth(); if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled ? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days) : '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(); } - return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; + return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index eddc21b..db5ca3f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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-v124'; +const CACHE = 'stackchain-dashboard-shell-v125'; 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; @@ -500,6 +500,20 @@ self.addEventListener('push', event => { const notificationId = Number(payload.notification_id); const updateCount = Number(payload.update_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 ( route === '#/my-work/agenda' && protectRoute === '#/my-work/agenda/protect-today' diff --git a/src/main.py b/src/main.py index 1d6c9d7..3844b75 100644 --- a/src/main.py +++ b/src/main.py @@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore from src.push_notifications import ( PushConfiguration, dispatch_deadline_reminders, + dispatch_start_day_reminders, dispatch_unread_updates, send_web_push, ) @@ -121,6 +122,14 @@ async def _push_channel_loop(dispatch, *, interval: float) -> None: 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: interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30"))) deadline_interval = max( @@ -163,11 +172,24 @@ async def _push_poll_loop() -> None: 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 = ( asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)), asyncio.create_task( _push_channel_loop(dispatch_deadlines, interval=deadline_interval) ), + asyncio.create_task( + _push_channel_loop(dispatch_start_day, interval=deadline_interval) + ), ) try: done, _pending = await asyncio.wait( @@ -433,6 +455,23 @@ class DeadlineReminderPayload(BaseModel): 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[ "merge_pull", "submit_pull_review", @@ -2198,6 +2237,9 @@ async def push_status(request: Request): preferences = await asyncio.to_thread( _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( _push_subscription_store.delivery_health, device_id ) @@ -2210,6 +2252,9 @@ async def push_status(request: Request): "reminder_hour": preferences["reminder_hour"], "reminder_days": preferences["reminder_days"], "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, } @@ -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") async def snooze_deadline_reminder(request: Request): device_id = await dashboard_auth.session_management_id( diff --git a/src/push_notifications.py b/src/push_notifications.py index 52e50cd..87bbfa0 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -356,6 +356,108 @@ async def dispatch_deadline_reminders( 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( store: PushSubscriptionStore, configuration: PushConfiguration, diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 64691d6..162595a 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -43,6 +43,15 @@ class DeadlineReminderDevice: 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: """No-persistence store used when Web Push is not configured.""" @@ -66,6 +75,12 @@ class DisabledPushSubscriptionStore: def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]: 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]: return [] @@ -96,6 +111,12 @@ class DisabledPushSubscriptionStore: def mark_deadline_reminder_delivered(self, *args, **kwargs) -> 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: return None @@ -206,6 +227,15 @@ class PushSubscriptionStore: FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) 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 = { @@ -485,6 +515,60 @@ class PushSubscriptionStore: (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( self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]] ) -> list[PushDelivery]: diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 1d2b9c0..b1271f9 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 button { min-height:44px;' in html 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 diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index cbbf381..da4b465 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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(): 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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 14fad4b..dfc5d63 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 pre { max-width:100%; overflow-x:auto;" 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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index a21ab0a..4c41c7b 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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])) 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(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 885b063..32c5ff8 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard 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-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 8984a91..f51323b 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): 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 diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index d7b362a..10d978b 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -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 "max-width:100%; overflow-wrap:anywhere;" in html 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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 4b19a67..0306f29 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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(): 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-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index 907465d..067cc9d 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -21,6 +21,12 @@ const deadlineControl = { 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 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 testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback}; const deadlineSnooze = {hidden:true}; @@ -33,6 +39,7 @@ const registration = {pushManager:{ }}; const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, + startDayControl, startDayStatus, startDayHour, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, 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." +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(): result = run_scenario(""" state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'}; diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 05123ff..5103e2e 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -398,6 +398,7 @@ async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_ca @pytest.mark.anyio async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch): captured = {} + hold_other_channels = asyncio.Event() async def no_wait(_seconds): return None @@ -407,10 +408,15 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch captured.update(kwargs) 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_INDIVIDUAL_NOTIFICATIONS", "4") monkeypatch.setattr(main.asyncio, "sleep", no_wait) 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): 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): nonlocal sleeps sleeps += 1 - if sleeps <= 2: - if sleeps == 2: + if sleeps <= 3: + if sleeps == 3: first_tick.set() await first_tick.wait() else: @@ -445,14 +451,18 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m async def dispatch_deadlines(*_args, **_kwargs): calls.append("deadline") + async def dispatch_start_day(*_args, **_kwargs): + calls.append("start-day") + monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread) monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines) + monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) with pytest.raises(asyncio.CancelledError): await main._push_poll_loop() - assert sorted(calls) == ["deadline", "unread"] + assert sorted(calls) == ["deadline", "start-day", "unread"] @pytest.mark.anyio @@ -472,9 +482,13 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon deadline_started.set() 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, "dispatch_unread_updates", blocked_unread) 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()) 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() - assert sorted(intervals) == [30.0, 600.0] + assert sorted(intervals) == [30.0, 600.0, 600.0] @pytest.mark.anyio @@ -1261,6 +1275,9 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( "reminder_hour": 9, "reminder_days": 2, "snoozed_until": None, + "start_day_enabled": False, + "start_day_timezone": "UTC", + "start_day_reminder_hour": 9, "delivery_health": {}, } assert await main.subscribe_push(payload, request) == {"subscribed": True} diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 007e763..29cd616 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -168,13 +168,13 @@ async function dispatchPush(payload) {{ def test_offline_activation_migration_rolls_the_shell_cache(): 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(): 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/dashboard.js'" 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(): 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/authored-outbox.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(): 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-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(): 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 def test_offline_review_next_ships_today_completion_atomically(): 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/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(): 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/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(): 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/checklist-conflict.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(): 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 def test_navigation_deadline_ships_in_a_new_shell_cache(): 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.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(): 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 def test_mobile_search_viewport_ships_in_a_new_offline_shell(): 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 def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): 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 @@ -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"]) +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(): 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(): 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 diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py new file mode 100644 index 0000000..b127467 --- /dev/null +++ b/tests/test_start_day_reminders.py @@ -0,0 +1,149 @@ +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 "promoteTomorrowIfDue(plan).finally(() =>" in dashboard + assert "onRemotePlan: async plan =>" not in dashboard + assert "openMobileStartDay()" in dashboard diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 099cf54..0649f1b 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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(): 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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 0fa94f0..99c208e 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): 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