Preserve deadline calendar days across mobile time zones #720
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user