fix: preserve deadline calendar days (Closes #719)
This commit is contained in:
parent
9ec88179c3
commit
dce9ae04c9
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user