feat: launch planned days with morning reminders (Closes #1166)
This commit is contained in:
1 parent
99b60e5f5f
commit
d86e84253a
21 files changed
+614
-34
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user