Configure the deadline reminder horizon per device #728
|
|
@ -40,7 +40,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||||
.device-setup-step p { margin:5px 0 0; }
|
.device-setup-step p { margin:5px 0 0; }
|
||||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; }
|
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||||
|
|
|
||||||
|
|
@ -6104,6 +6104,13 @@
|
||||||
qs('#device-setup-deadline-hour').addEventListener('change', event => {
|
qs('#device-setup-deadline-hour').addEventListener('change', event => {
|
||||||
qs('#push-deadline-hour').value = event.target.value;
|
qs('#push-deadline-hour').value = event.target.value;
|
||||||
});
|
});
|
||||||
|
qs('#push-deadline-days').addEventListener('change', event => {
|
||||||
|
qs('#device-setup-deadline-days').value = event.target.value;
|
||||||
|
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
|
||||||
|
});
|
||||||
|
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
||||||
|
qs('#push-deadline-days').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 () => {
|
||||||
|
|
@ -6114,6 +6121,7 @@
|
||||||
deadlineControl:qs('#push-deadlines'),
|
deadlineControl:qs('#push-deadlines'),
|
||||||
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'),
|
||||||
notification:window.Notification,
|
notification:window.Notification,
|
||||||
serviceWorker:navigator.serviceWorker,
|
serviceWorker:navigator.serviceWorker,
|
||||||
fetchJson:fetchReviewJson,
|
fetchJson:fetchReviewJson,
|
||||||
|
|
@ -6121,6 +6129,7 @@
|
||||||
await controller.init();
|
await controller.init();
|
||||||
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;
|
||||||
return controller;
|
return controller;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
qs('#push-updates').disabled = true;
|
qs('#push-updates').disabled = true;
|
||||||
|
|
@ -6169,6 +6178,7 @@
|
||||||
const controller = await pushControllerReady;
|
const controller = await pushControllerReady;
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||||||
|
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||||||
await controller.enableDeadline();
|
await controller.enableDeadline();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@
|
||||||
<div class="device-setup-deadline-controls">
|
<div class="device-setup-deadline-controls">
|
||||||
<label for="device-setup-deadline-hour">Local hour</label>
|
<label for="device-setup-deadline-hour">Local hour</label>
|
||||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||||
|
<label for="device-setup-deadline-days">Warn me</label>
|
||||||
|
<select id="device-setup-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>
|
||||||
<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>
|
||||||
|
|
@ -139,6 +141,7 @@
|
||||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</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-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||||
|
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
<span class="small" id="push-deadline-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>
|
||||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
else root.createPushNotifications = factory;
|
else root.createPushNotifications = factory;
|
||||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||||
control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson,
|
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||||
}) {
|
}) {
|
||||||
let configuration = null;
|
let configuration = null;
|
||||||
|
|
||||||
|
|
@ -10,12 +10,18 @@
|
||||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enabledDeadlineText(hour, days) {
|
||||||
|
const value = Number(days ?? 2);
|
||||||
|
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||||
|
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function deadlineReadiness() {
|
function deadlineReadiness() {
|
||||||
if (!configuration?.available || deadlineControl?.disabled) {
|
if (!configuration?.available || deadlineControl?.disabled) {
|
||||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||||
}
|
}
|
||||||
return configuration.deadline_enabled
|
return configuration.deadline_enabled
|
||||||
? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`}
|
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +90,7 @@
|
||||||
async function changeDeadline() {
|
async function changeDeadline() {
|
||||||
deadlineControl.disabled = true;
|
deadlineControl.disabled = true;
|
||||||
if (deadlineHour) deadlineHour.disabled = true;
|
if (deadlineHour) deadlineHour.disabled = true;
|
||||||
|
if (deadlineDays) deadlineDays.disabled = true;
|
||||||
try {
|
try {
|
||||||
const registration = await serviceWorker.ready;
|
const registration = await serviceWorker.ready;
|
||||||
let subscription = await registration.pushManager.getSubscription();
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
@ -95,16 +102,18 @@
|
||||||
}
|
}
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||||
|
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||||
method:'PUT',
|
method:'PUT',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}),
|
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||||
});
|
});
|
||||||
configuration.deadline_enabled = deadlineControl.checked;
|
configuration.deadline_enabled = deadlineControl.checked;
|
||||||
configuration.reminder_hour = reminderHour;
|
configuration.reminder_hour = reminderHour;
|
||||||
|
configuration.reminder_days = reminderDays;
|
||||||
configuration.timezone = timezone;
|
configuration.timezone = timezone;
|
||||||
deadlineStatus.textContent = deadlineControl.checked
|
deadlineStatus.textContent = deadlineControl.checked
|
||||||
? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.`
|
? enabledDeadlineText(reminderHour, reminderDays)
|
||||||
: 'Deadline reminders are off for this device.';
|
: 'Deadline reminders are off for this device.';
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -114,6 +123,7 @@
|
||||||
} finally {
|
} finally {
|
||||||
deadlineControl.disabled = false;
|
deadlineControl.disabled = false;
|
||||||
if (deadlineHour) deadlineHour.disabled = false;
|
if (deadlineHour) deadlineHour.disabled = false;
|
||||||
|
if (deadlineDays) deadlineDays.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,11 +146,12 @@
|
||||||
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 (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);
|
||||||
status.textContent = configuration.subscribed
|
status.textContent = configuration.subscribed
|
||||||
? 'New update notifications enabled for this device.'
|
? 'New update notifications enabled for this device.'
|
||||||
: 'New update notifications are off for this device.';
|
: 'New update notifications are off for this device.';
|
||||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||||
? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`
|
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||||
: 'Deadline reminders are off for this device.';
|
: 'Deadline reminders are off for this device.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,7 @@ class DeadlineReminderPayload(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
timezone: str = Field(min_length=1, max_length=64)
|
timezone: str = Field(min_length=1, max_length=64)
|
||||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||||
|
reminder_days: Literal[0, 2, 7] = 2
|
||||||
|
|
||||||
@field_validator("timezone")
|
@field_validator("timezone")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -1939,6 +1940,7 @@ async def push_status(request: Request):
|
||||||
"deadline_enabled": preferences["enabled"],
|
"deadline_enabled": preferences["enabled"],
|
||||||
"timezone": preferences["timezone"],
|
"timezone": preferences["timezone"],
|
||||||
"reminder_hour": preferences["reminder_hour"],
|
"reminder_hour": preferences["reminder_hour"],
|
||||||
|
"reminder_days": preferences["reminder_days"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2007,11 +2009,13 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
timezone=payload.timezone,
|
timezone=payload.timezone,
|
||||||
reminder_hour=payload.reminder_hour,
|
reminder_hour=payload.reminder_hour,
|
||||||
|
reminder_days=payload.reminder_days,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"deadline_enabled": payload.enabled,
|
"deadline_enabled": payload.enabled,
|
||||||
"timezone": payload.timezone,
|
"timezone": payload.timezone,
|
||||||
"reminder_hour": payload.reminder_hour,
|
"reminder_hour": payload.reminder_hour,
|
||||||
|
"reminder_days": payload.reminder_days,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||||
due_counts = {}
|
due_counts = {}
|
||||||
for device in eligible_devices:
|
for device in eligible_devices:
|
||||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||||
local_cutoff = local_now.date() + timedelta(days=2)
|
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||||
if due_count:
|
if due_count:
|
||||||
due_counts[device.session_id] = due_count
|
due_counts[device.session_id] = due_count
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class DeadlineReminderDevice:
|
||||||
subscription: dict
|
subscription: dict
|
||||||
timezone: str
|
timezone: str
|
||||||
reminder_hour: int
|
reminder_hour: int
|
||||||
|
reminder_days: int
|
||||||
delivered_local_day: str | None
|
delivered_local_day: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,6 +93,7 @@ class PushSubscriptionStore:
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||||
|
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||||
delivered_local_day TEXT,
|
delivered_local_day TEXT,
|
||||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
|
|
@ -131,6 +133,14 @@ class PushSubscriptionStore:
|
||||||
DROP TABLE push_dispatch_lease_legacy;
|
DROP TABLE push_dispatch_lease_legacy;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
preference_columns = {
|
||||||
|
row[1]
|
||||||
|
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||||
|
}
|
||||||
|
if "reminder_days" not in preference_columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||||
|
)
|
||||||
os.chmod(self.path, 0o600)
|
os.chmod(self.path, 0o600)
|
||||||
|
|
||||||
def _connect(self):
|
def _connect(self):
|
||||||
|
|
@ -192,24 +202,26 @@ class PushSubscriptionStore:
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
def set_deadline_preferences(
|
def set_deadline_preferences(
|
||||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||||
|
reminder_days: int = 2,
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""INSERT INTO push_deadline_preferences(
|
"""INSERT INTO push_deadline_preferences(
|
||||||
session_id, enabled, timezone, reminder_hour
|
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||||
) VALUES (?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(session_id) DO UPDATE SET
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
reminder_hour = excluded.reminder_hour""",
|
reminder_hour = excluded.reminder_hour,
|
||||||
(session_id, int(enabled), timezone, reminder_hour),
|
reminder_days = excluded.reminder_days""",
|
||||||
|
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||||
)
|
)
|
||||||
|
|
||||||
def deadline_preferences(self, session_id: str) -> dict:
|
def deadline_preferences(self, session_id: str) -> dict:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""SELECT enabled, timezone, reminder_hour
|
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -217,19 +229,20 @@ class PushSubscriptionStore:
|
||||||
"enabled": bool(row[0]) if row else False,
|
"enabled": bool(row[0]) if row else False,
|
||||||
"timezone": row[1] if row else "UTC",
|
"timezone": row[1] if row else "UTC",
|
||||||
"reminder_hour": row[2] if row else 9,
|
"reminder_hour": row[2] if row else 9,
|
||||||
|
"reminder_days": row[3] if row else 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||||
p.reminder_hour, p.delivered_local_day
|
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||||
FROM push_subscriptions s
|
FROM push_subscriptions s
|
||||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
|
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,48 @@ async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_pa
|
||||||
assert sent == {"tokyo": 2, "la": 1}
|
assert sent == {"tokyo": 2, "la": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
for device, reminder_days in (("today", 0), ("week", 7)):
|
||||||
|
store.upsert(device, {
|
||||||
|
"endpoint": f"https://push.example/{device}",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
device,
|
||||||
|
enabled=True,
|
||||||
|
timezone="UTC",
|
||||||
|
reminder_hour=9,
|
||||||
|
reminder_days=reminder_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def assigned():
|
||||||
|
return {"complete": True, "items": [
|
||||||
|
{"id": 1, "due_date": "2026-08-13"},
|
||||||
|
{"id": 2, "due_date": "2026-08-15"},
|
||||||
|
{"id": 3, "due_date": "2026-08-20"},
|
||||||
|
{"id": 4, "due_date": "2026-08-21"},
|
||||||
|
]}
|
||||||
|
|
||||||
|
sent = {}
|
||||||
|
|
||||||
|
async def send(subscription, payload):
|
||||||
|
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||||
|
sent[device] = json.loads(payload)["deadline_count"]
|
||||||
|
|
||||||
|
delivered = await dispatch_deadline_reminders(
|
||||||
|
store,
|
||||||
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||||
|
assigned,
|
||||||
|
send,
|
||||||
|
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert delivered == 2
|
||||||
|
assert sent == {"today": 1, "week": 3}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
@ -194,9 +236,37 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"timezone": "Europe/London",
|
"timezone": "Europe/London",
|
||||||
"reminder_hour": 8,
|
"reminder_hour": 8,
|
||||||
|
"reminder_days": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||||
|
database = tmp_path / "push.sqlite3"
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.executescript("""
|
||||||
|
CREATE TABLE push_subscriptions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
subscription_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE push_deadline_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_local_day TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||||
|
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||||
|
""")
|
||||||
|
|
||||||
|
store = PushSubscriptionStore(database)
|
||||||
|
|
||||||
|
assert store.deadline_preferences("device-a")["reminder_days"] == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||||
pages = {
|
pages = {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const deadlineControl = {
|
||||||
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
||||||
};
|
};
|
||||||
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 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 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 existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||||
|
|
@ -27,7 +28,7 @@ const registration = {pushManager:{
|
||||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||||
}};
|
}};
|
||||||
const feature = createPushNotifications({
|
const feature = createPushNotifications({
|
||||||
control, status, deadlineControl, deadlineStatus, deadlineHour,
|
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||||
serviceWorker: {ready:Promise.resolve(registration)},
|
serviceWorker: {ready:Promise.resolve(registration)},
|
||||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||||
|
|
@ -80,7 +81,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert body["enabled"] is True
|
assert body["enabled"] is True
|
||||||
assert body["reminder_hour"] == 9
|
assert body["reminder_hour"] == 9
|
||||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
|
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||||
|
|
@ -100,7 +101,35 @@ process.stdout.write(JSON.stringify(state));
|
||||||
body = json.loads(result["requests"][-1][2])
|
body = json.loads(result["requests"][-1][2])
|
||||||
assert body["enabled"] is True
|
assert body["enabled"] is True
|
||||||
assert body["reminder_hour"] == 8
|
assert body["reminder_hour"] == 8
|
||||||
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
|
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days."
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_setup_persists_and_restores_confirmed_horizon():
|
||||||
|
saved = run_scenario("""
|
||||||
|
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'};
|
||||||
|
state.current = existing;
|
||||||
|
await feature.init();
|
||||||
|
deadlineDays.value = '7';
|
||||||
|
await feature.changeDeadline();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7
|
||||||
|
assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days."
|
||||||
|
|
||||||
|
restored = run_scenario("""
|
||||||
|
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'};
|
||||||
|
state.current = existing;
|
||||||
|
await feature.init();
|
||||||
|
process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()}));
|
||||||
|
""")
|
||||||
|
assert restored == {
|
||||||
|
"days": "0",
|
||||||
|
"readiness": {
|
||||||
|
"state": "complete",
|
||||||
|
"detail": "Deadline reminders enabled for 17:00 local time, due today.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
|
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
|
||||||
|
|
@ -112,7 +141,7 @@ process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineCo
|
||||||
""")
|
""")
|
||||||
assert restored == {
|
assert restored == {
|
||||||
"hour": "17", "checked": True,
|
"hour": "17", "checked": True,
|
||||||
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
|
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."},
|
||||||
}
|
}
|
||||||
|
|
||||||
denied = run_scenario("""
|
denied = run_scenario("""
|
||||||
|
|
@ -142,6 +171,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
assert 'id="push-update-status"' in html
|
assert 'id="push-update-status"' in html
|
||||||
assert 'id="push-deadlines"' in html
|
assert 'id="push-deadlines"' in html
|
||||||
assert 'id="push-deadline-hour"' in html
|
assert 'id="push-deadline-hour"' in html
|
||||||
|
assert 'id="push-deadline-days"' in html
|
||||||
|
assert 'id="device-setup-deadline-days"' in html
|
||||||
assert 'id="push-deadline-status"' in html
|
assert 'id="push-deadline-status"' in html
|
||||||
assert '<script src="static/push-notifications.js"></script>' in html
|
assert '<script src="static/push-notifications.js"></script>' in html
|
||||||
assert "createPushNotifications({" in dashboard
|
assert "createPushNotifications({" in dashboard
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
"deadline_enabled": False,
|
"deadline_enabled": False,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"reminder_hour": 9,
|
"reminder_hour": 9,
|
||||||
|
"reminder_days": 2,
|
||||||
}
|
}
|
||||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
assert (await main.push_status(request))["subscribed"] is True
|
assert (await main.push_status(request))["subscribed"] is True
|
||||||
|
|
@ -1002,15 +1003,17 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
|
||||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||||
payload = main.DeadlineReminderPayload(
|
payload = main.DeadlineReminderPayload(
|
||||||
enabled=True, timezone="America/New_York", reminder_hour=9
|
enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await main.update_deadline_reminders(payload, request) == {
|
assert await main.update_deadline_reminders(payload, request) == {
|
||||||
"deadline_enabled": True,
|
"deadline_enabled": True,
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
"reminder_hour": 9,
|
"reminder_hour": 9,
|
||||||
|
"reminder_days": 7,
|
||||||
}
|
}
|
||||||
assert store.deadline_preferences("session-a")["enabled"] is True
|
assert store.deadline_preferences("session-a")["enabled"] is True
|
||||||
|
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user