fix: preserve deadline calendar days (Closes #719)
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 07:56:26 +00:00
parent 9ec88179c3
commit dce9ae04c9
7 changed files with 135 additions and 26 deletions

View File

@ -9,6 +9,10 @@ function createAgendaReplan({ now = () => new Date(), update }) {
String(date.getMonth() + 1).padStart(2, '0'), String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'), String(date.getDate()).padStart(2, '0'),
].join('-'); ].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 current = () => active ? items[index] || null : null;
const snapshot = () => ({ const snapshot = () => ({
active, active,
@ -40,7 +44,7 @@ function createAgendaReplan({ now = () => new Date(), update }) {
return { return {
start(overdue) { start(overdue) {
items = (overdue || []).slice().sort((left, right) => 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 || '')) || String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0) Number(left.number || 0) - Number(right.number || 0)
); );

View File

@ -1524,6 +1524,14 @@
qs('[data-find-work-estimate]')?.focus(); 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; let planTodayTrigger = null;
function formatPlanMinutes(minutes) { function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set'; if (!Number.isInteger(minutes)) return 'Not set';
@ -3008,7 +3016,7 @@
qs('#save-issue-due-date').disabled = false; qs('#save-issue-due-date').disabled = false;
qs('#clear-issue-due-date').disabled = !detail.due_date; qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = 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 (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) { if (offlineDetail) {
setOfflineDetailControls('issue'); setOfflineDetailControls('issue');

View File

@ -1,15 +1,26 @@
function issueDueState(dueDate, now) { function calendarDay(value) {
if (!dueDate) return null; const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
const due = new Date(dueDate); if (!match) return '';
if (Number.isNaN(due.getTime())) return null; const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' + 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'); 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: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 }; if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return { 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, priority: 4,
}; };
} }
@ -569,18 +580,22 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
function agendaMyWork(items, now = new Date()) { function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(start); tomorrow.setDate(tomorrow.getDate() + 1); const today = localDay(start);
const afterTomorrow = new Date(start); afterTomorrow.setDate(afterTomorrow.getDate() + 2); const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
const horizon = new Date(start); horizon.setDate(horizon.getDate() + 7); 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 => { return (items || []).flatMap(item => {
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return []; if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
const due = new Date(item.due_date); const due = calendarDay(item.due_date);
if (Number.isNaN(due.getTime()) || due >= horizon) return []; if (!due || due >= horizon) return [];
const group = due < start ? 'Overdue' : due < tomorrow ? 'Today' : const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days'; 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) => }).sort((left, right) =>
left._agenda_due - right._agenda_due || left._agenda_due.localeCompare(right._agenda_due) ||
String(left.repository || '').localeCompare(String(right.repository || '')) || String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0) Number(left.number || 0) - Number(right.number || 0)
).map(({ _agenda_due, ...item }) => item); ).map(({ _agenda_due, ...item }) => item);

View File

@ -306,20 +306,17 @@ async def _dispatch_deadline_reminders_unlocked(
snapshot = await assigned() snapshot = await assigned()
if snapshot.get("complete") is False: if snapshot.get("complete") is False:
return 0 return 0
due_cutoff = current + timedelta(hours=48) due_days = []
due_count = 0
for item in snapshot.get("items", []): for item in snapshot.get("items", []):
if not isinstance(item, dict) or not item.get("due_date"): if not isinstance(item, dict) or not item.get("due_date"):
continue continue
raw_due = str(item["due_date"])
try: 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: except ValueError:
continue continue
if due.tzinfo is None: due_days.append(due_day)
due = due.replace(tzinfo=timezone.utc) if not due_days:
if due <= due_cutoff:
due_count += 1
if not due_count:
return 0 return 0
semaphore = asyncio.Semaphore(max(1, max_concurrency)) semaphore = asyncio.Semaphore(max(1, max_concurrency))
@ -335,6 +332,10 @@ async def _dispatch_deadline_reminders_unlocked(
or device.delivered_local_day == local_day or device.delivered_local_day == local_day
): ):
return 0 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): if session_active is not None and not await session_active(device.session_id):
await asyncio.to_thread(store.delete_session, device.session_id) await asyncio.to_thread(store.delete_session, device.session_id)
return 0 return 0

View File

@ -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) 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 @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")

View File

@ -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 @pytest.mark.anyio
async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation(): async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation():
html = await dashboard() html = await dashboard()

View File

@ -109,6 +109,14 @@ async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches()
assert "max-width:100%" in html 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(): def test_queue_finder_matches_repository_number_and_title_without_reordering():
script = f""" script = f"""
const work = require({json.dumps(str(MY_WORK))}); 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"] 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(): def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page():
script = f""" script = f"""
const work = require({json.dumps(str(MY_WORK))}); const work = require({json.dumps(str(MY_WORK))});