From dce9ae04c9953f452f56de9b89d3f7ad92fdf6d6 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 07:56:26 +0000 Subject: [PATCH] fix: preserve deadline calendar days (Closes #719) --- frontend/agenda-replan.js | 6 +++- frontend/dashboard.js | 10 ++++++- frontend/my-work.js | 47 ++++++++++++++++++++---------- src/push_notifications.py | 17 ++++++----- tests/test_deadline_reminders.py | 30 +++++++++++++++++++ tests/test_mobile_agenda_replan.py | 18 ++++++++++++ tests/test_my_work.py | 33 +++++++++++++++++++++ 7 files changed, 135 insertions(+), 26 deletions(-) diff --git a/frontend/agenda-replan.js b/frontend/agenda-replan.js index 250bd78..6807b6b 100644 --- a/frontend/agenda-replan.js +++ b/frontend/agenda-replan.js @@ -9,6 +9,10 @@ function createAgendaReplan({ now = () => new Date(), update }) { String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0'), ].join('-'); + const dueDay = value => { + const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/); + return match ? match[1] : ''; + }; const current = () => active ? items[index] || null : null; const snapshot = () => ({ active, @@ -40,7 +44,7 @@ function createAgendaReplan({ now = () => new Date(), update }) { return { start(overdue) { items = (overdue || []).slice().sort((left, right) => - new Date(left.due_date).getTime() - new Date(right.due_date).getTime() || + dueDay(left.due_date).localeCompare(dueDay(right.due_date)) || String(left.repository || '').localeCompare(String(right.repository || '')) || Number(left.number || 0) - Number(right.number || 0) ); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7264658..3b1b666 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1524,6 +1524,14 @@ qs('[data-find-work-estimate]')?.focus(); } + function formatCalendarDueDate(value) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/); + if (!match) return ''; + return new Intl.DateTimeFormat(undefined).format( + new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + ); + } + let planTodayTrigger = null; function formatPlanMinutes(minutes) { if (!Number.isInteger(minutes)) return 'Not set'; @@ -3008,7 +3016,7 @@ qs('#save-issue-due-date').disabled = false; qs('#clear-issue-due-date').disabled = !detail.due_date; qs('#issue-due-status').textContent = detail.due_date ? - 'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.'; + 'Due ' + formatCalendarDueDate(detail.due_date) : 'No due date set.'; if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning(); if (offlineDetail) { setOfflineDetailControls('issue'); diff --git a/frontend/my-work.js b/frontend/my-work.js index ca8a4a1..cc3aabc 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -1,15 +1,26 @@ -function issueDueState(dueDate, now) { - if (!dueDate) return null; - const due = new Date(dueDate); - if (Number.isNaN(due.getTime())) return null; - const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' + +function calendarDay(value) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/); + if (!match) return ''; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 && + date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : ''; +} + +function localDay(value) { + return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' + String(value.getDate()).padStart(2, '0'); - const dueDay = day(due); - const today = day(now); +} + +function issueDueState(dueDate, now) { + const dueDay = calendarDay(dueDate); + if (!dueDay) return null; + const today = localDay(now); if (dueDay < today) return { label: 'Overdue', priority: 2 }; if (dueDay === today) return { label: 'Due today', priority: 2.5 }; return { - label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + label: 'Due ' + new Date( + Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10)) + ).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), priority: 4, }; } @@ -569,18 +580,22 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { function agendaMyWork(items, now = new Date()) { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const tomorrow = new Date(start); tomorrow.setDate(tomorrow.getDate() + 1); - const afterTomorrow = new Date(start); afterTomorrow.setDate(afterTomorrow.getDate() + 2); - const horizon = new Date(start); horizon.setDate(horizon.getDate() + 7); + const today = localDay(start); + const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1); + const tomorrow = localDay(tomorrowDate); + const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2); + const afterTomorrow = localDay(afterTomorrowDate); + const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7); + const horizon = localDay(horizonDate); return (items || []).flatMap(item => { if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return []; - const due = new Date(item.due_date); - if (Number.isNaN(due.getTime()) || due >= horizon) return []; - const group = due < start ? 'Overdue' : due < tomorrow ? 'Today' : + const due = calendarDay(item.due_date); + if (!due || due >= horizon) return []; + const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' : due < afterTomorrow ? 'Tomorrow' : 'Next 7 days'; - return [{ ...item, agenda_group: group, _agenda_due: due.getTime() }]; + return [{ ...item, agenda_group: group, _agenda_due: due }]; }).sort((left, right) => - left._agenda_due - right._agenda_due || + left._agenda_due.localeCompare(right._agenda_due) || String(left.repository || '').localeCompare(String(right.repository || '')) || Number(left.number || 0) - Number(right.number || 0) ).map(({ _agenda_due, ...item }) => item); diff --git a/src/push_notifications.py b/src/push_notifications.py index d29d547..3f4e376 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -306,20 +306,17 @@ async def _dispatch_deadline_reminders_unlocked( snapshot = await assigned() if snapshot.get("complete") is False: return 0 - due_cutoff = current + timedelta(hours=48) - due_count = 0 + due_days = [] for item in snapshot.get("items", []): if not isinstance(item, dict) or not item.get("due_date"): continue + raw_due = str(item["due_date"]) try: - due = datetime.fromisoformat(str(item["due_date"]).replace("Z", "+00:00")) + due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date() except ValueError: continue - if due.tzinfo is None: - due = due.replace(tzinfo=timezone.utc) - if due <= due_cutoff: - due_count += 1 - if not due_count: + due_days.append(due_day) + if not due_days: return 0 semaphore = asyncio.Semaphore(max(1, max_concurrency)) @@ -335,6 +332,10 @@ async def _dispatch_deadline_reminders_unlocked( or device.delivered_local_day == local_day ): return 0 + local_cutoff = local_now.date() + timedelta(days=2) + due_count = sum(due_day <= local_cutoff for due_day in due_days) + if not due_count: + return 0 if session_active is not None and not await session_active(device.session_id): await asyncio.to_thread(store.delete_session, device.session_id) return 0 diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py index 0d4c40a..7a451fe 100644 --- a/tests/test_deadline_reminders.py +++ b/tests/test_deadline_reminders.py @@ -54,6 +54,36 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic assert "private/repo" not in json.dumps(sent) +@pytest.mark.anyio +async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")): + store.upsert(device, { + "endpoint": f"https://push.example/{device}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0) + + async def assigned(): + return {"complete": True, "items": [ + {"id": 1, "due_date": "2026-08-15T23:59:59Z"}, + {"id": 2, "due_date": "2026-08-16T23:59:59Z"}, + ]} + + 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, 23, 30, tzinfo=timezone.utc), + ) + + assert delivered == 2 + assert sent == {"tokyo": 2, "la": 1} + + @pytest.mark.anyio async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") diff --git a/tests/test_mobile_agenda_replan.py b/tests/test_mobile_agenda_replan.py index d6929f0..276026f 100644 --- a/tests/test_mobile_agenda_replan.py +++ b/tests/test_mobile_agenda_replan.py @@ -83,6 +83,24 @@ run(); } +def test_overdue_replan_orders_by_calendar_day_without_timezone_drift(): + script = f""" +const createSweep = require({json.dumps(str(REPLAN))}); +const sweep = createSweep({{update:async()=>({{}})}}); +const started = sweep.start([ + {{key:'o/r#2',repository:'o/r',number:2,due_date:'2026-08-13T01:00:00Z'}}, + {{key:'o/r#1',repository:'o/r',number:1,due_date:'2026-08-12T23:59:59Z'}}, +]); +process.stdout.write(JSON.stringify(started)); +""" + environment = {**__import__('os').environ, "TZ": "Asia/Tokyo"} + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, env=environment + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["current"] == "o/r#1" + + @pytest.mark.anyio async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation(): html = await dashboard() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 99c2550..06fe037 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -109,6 +109,14 @@ async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches() assert "max-width:100%" in html +@pytest.mark.anyio +async def test_issue_sheet_formats_due_date_as_a_calendar_day_without_local_timestamp_conversion(): + source = await dashboard() + + assert "formatCalendarDueDate(detail.due_date)" in source + assert "new Date(detail.due_date).toLocaleDateString()" not in source + + def test_queue_finder_matches_repository_number_and_title_without_reordering(): script = f""" const work = require({json.dumps(str(MY_WORK))}); @@ -173,6 +181,31 @@ process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-1 assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"] +@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"]) +def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name): + script = f""" +const work = require({json.dumps(str(MY_WORK))}); +const now = new Date(2026, 7, 13, 12, 0, 0); +const built = work({{ + user:{{login:'timmy'}}, notifications:[], pull_requests:[], + issues:[{{number:1,title:'Ship',repository:'o/r',labels:[],assignees:['timmy'],due_date:'2026-08-13T23:59:59Z'}}], +}}, now); +process.stdout.write(JSON.stringify({{ + dueLabel:built[0].due_label, + agenda:work.agendaMyWork(built, now).map(item => [item.key, item.agenda_group]), +}})); +""" + environment = {**os.environ, "TZ": timezone_name} + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, env=environment + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "dueLabel": "Due today", + "agenda": [["o/r#1", "Today"]], + } + + def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page(): script = f""" const work = require({json.dumps(str(MY_WORK))});