1/* ========================================================================= 2 features/tasks/dueMenu â the due-date popover shared by the quick-add date 3 chip and the task detail panel: quick presets (Today / Tomorrow / Next 4 week / No date) above the shared date-only month picker (datePicker.js in 5 its compact .sp-dtp dress), plus a "Time" button that swaps the calendar 6 for a scrollable time-range picker (tap a start slot, then an end slot â 7 the span between highlights, calendar-range style). Anchored fixed on 8 desktop; on mobile it joins the shared FilterSheet shell (bottom sheet + 9 scrim + standard header). Closes on outside-click / Escape, one open at a 10 time. 11 12 onPick(dueDateKey | null, timeObj) â timeObj = { dueTime, dueEndTime } 13 ("HH:MM" | null). A date/preset pick carries the currently-chosen time 14 through; "No date" passes null to clear both date and time. 15 ========================================================================= */ 16import "../../../scripts/icons.js"; // window.icon must exist before this module evaluates (bundle chunk order is not the HTML order) 17import { el, esc } from "../../shared/lib/dom.js"; 18import { dateTimePickerHTML, wireDateTimePicker } from "../../shared/lib/datePicker.js"; 19import { sheetHeadHTML, wireSheetDrag, dismissSheet } from "../../app/drawerShell.js"; 20import { fmtTime } from "../../shared/lib/format.js"; 21import { timeRangeLabel } from "./nlDate.js"; 22import { addDaysKey, todayKey, titlePlain } from "./tasksHelpers.js"; 23import { tasksState } from "./tasksState.js"; 24 25const I = window.icon; 26 27let dueMenuState = null; 28export function closeDueMenu(opts) { 29 opts = opts || {}; 30 if (!dueMenuState) return; 31 const state = dueMenuState; 32 dismissSheet(state.menu, state.scrim); // slides down on mobile 33 document.removeEventListener("click", state.onDoc, true); 34 document.removeEventListener("keydown", state.onKey, true); 35 dueMenuState = null; 36 if (opts.restoreFocus !== false && state.restoreFocusEl && state.restoreFocusEl.isConnected) { 37 try { state.restoreFocusEl.focus({ preventScroll: true }); } 38 catch (_) { state.restoreFocusEl.focus(); } 39 } 40} 41 42const SLOT_STEP = 15; // minute granularity of the scrollable time list 43const SLOT_MIN = 4 * 60 + 30; // first slot: 4:30 AM 44const SLOT_MAX = 23 * 60; // last slot: 11:00 PM 45function minsOf(hhmm) { if (!hhmm) return null; const [h, m] = hhmm.split(":").map(Number); return h * 60 + m; } 46function hhmmOf(mins) { return `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}`; } 47 48/** 49 * openDueMenu(anchorEl, currentKey, onPick, opts) 50 * currentKey â the task's current "YYYY-MM-DD" due date (or null) 51 * onPick(dueDateKey|null, timeObj) â null means "No date" 52 * opts.dueTime / opts.dueEndTime â the task's current "HH:MM" time range 53 * opts.allowPast â let past days be picked. Off by default: a NEW due date in 54 * the past is nearly always a mis-tap. Deliverables turn it on, because 55 * they're often dated after the fact â the work is already done and you're 56 * recording when it happened, not scheduling it. 57 */ 58export function openDueMenu(anchorEl, currentKey, onPick, opts) { 59 opts = opts || {}; 60 closeDueMenu({ restoreFocus: false }); 61 const now = window.TZ ? window.TZ.now() : (() => { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth(), day: d.getDate() }; })(); 62 const today = todayKey(); 63 const minDate = opts.allowPast ? null : new Date(now.year, now.month, now.day); 64 // open the calendar on the current due date's month when set 65 let calY = now.year, calM = now.month, selDate = null; 66 let selKey = currentKey || null; 67 if (currentKey) { 68 const [y, m, d] = currentKey.split("-").map(Number); 69 calY = y; calM = m - 1; selDate = { y, m: m - 1, day: d }; 70 } 71 // current time range (minutes since midnight, or null) 72 let selStart = minsOf(opts.dueTime); 73 let selEnd = minsOf(opts.dueEndTime); 74 let fmt = "12"; 75 76 // "Next week" = Monday of next week â the same rule the NL parser uses 77 const nextWeek = addDaysKey(today, ((8 - (now.weekday != null ? now.weekday : 1)) % 7) || 7); 78 // "Next month" = the 1st of next month (matches the NL parser) 79 const nmDt = new Date(now.year, now.month + 1, 1); 80 const nextMonth = `${nmDt.getFullYear()}
80-${String(nmDt.getMonth() + 1).padStart(2, "0")}-01`; 81 // â¤640px: the shared FilterSheet shell (grab handle + standard header via 82 // the .filter-sheet CSS) instead of an anchored popover 83 const mobile = window.innerWidth <= 640; 84 const menu = el(` 85 <div class="td-due-menu${mobile ? " filter-sheet" : ""}"> 86 ${mobile ? sheetHeadHTML("Due date") : ""} 87 <div class="td-due-cal"> 88 <button type="button" class="td-due-time-btn" data-due-time-toggle> 89 ${I("clock")}<span class="tdt-label"></span>${I("chevron-right")} 90 </button> 91 <div class="td-due-quick"> 92 <button type="button" data-due-set="${today}" aria-label="Today">${I("sun")}</button> 93 <button type="button" data-due-set="${addDaysKey(today, 1)}" aria-label="Tomorrow">${I("sunrise")}</button> 94 <button type="button" class="tdq-week" data-due-set="${nextWeek}" aria-label="Next week">${I("calendar")}<span class="tdq-badge">+7</span></button> 95 <button type="button" data-due-set="${nextMonth}" aria-label="Next month">${I("moon")}</button> 96 </div> 97 <div class="sp-dtp">${dateTimePickerHTML({ y: calY, m: calM, sel: selDate ? selDate.day : null, dateOnly: true, minDate })}</div> 98 <div class="td-due-foot"> 99 <button type="button" class="td-due-clear" data-due-clear>Clear</button> 100 <button type="button" class="td-due-done" data-due-done>OK</button> 101 </div> 102 </div> 103 <div class="td-due-time"> 104 <div class="td-time-head"> 105 <button type="button" class="td-time-back" data-due-time-back>${I("chevron-left")}<span>Back</span></button> 106 <div class="segmented td-time-fmt"> 107 <button type="button" class="active" data-fmt="12">12h</button> 108 <button type="button" data-fmt="24">24h</button> 109 </div> 110 </div> 111 <div class="td-time-list"></div> 112 <button type="button" class="td-time-clear" data-due-time-clear>Clear time</button> 113 </div> 114 </div>`); 115 let scrim = null; 116 if (mobile) { 117 // the sheet lives at the bottom â drop the software keyboard so it isn't 118 // covered (the opener refocuses its input after the pick) 119 if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur(); 120 scrim = el(`<div class="filter-sheet-scrim td-due-scrim"></div>`); 121 document.body.appendChild(scrim); 122 } 123 document.body.appendChild(menu); 124 if (mobile) wireSheetDrag(menu, closeDueMenu, { scrim }); 125 126 function place() { 127 if (mobile) return; 128 const r = anchorEl.getBoundingClientRect(); 129 const mw = menu.offsetWidth, mh = menu.offsetHeight, gap = 6, pad = 8; 130 let left = r.left; if (left + mw > window.innerWidth - pad) left = window.innerWidth - pad - mw; if (left < pad) left = pad; 131 let top = r.bottom + gap; 132 if (top + mh > window.innerHeight - pad) { const above = r.top - gap - mh; top = above >= pad ? above : Math.max(pad, window.innerHeight - pad - mh); } 133 menu.style.left = left + "px"; menu.style.top = top + "px"; 134 } 135 place(); 136 137 const timeListEl = menu.querySelector(".td-time-list"); 138 const timeBtnLabel = menu.querySelector(".tdt-label"); 139 140 function syncTimeBtn() { 141 timeBtnLabel.textContent = selStart != null ? timeRangeLabel(hhmmOf(selStart), selEnd != null ? hhmmOf(selEnd) : null) : "Add time"; 142 menu.querySelector(".td-due-time-btn").classList.toggle("is-set", selStart != null); 143 } 144 function timeObj() { 145 return { dueTime: selStart != null ? hhmmOf(selStart) : null, dueEndTime: selEnd != null ? hhmmOf(selEnd) : null }; 146 } 147 // Other (open) tasks already scheduled on the selected day â their [start,end) 148 // ranges so the time list can flag occupied slots (an indicator only; you can 149 // still stack a task on the same time). The current task is excluded. 150 function busyRanges() { 151 if (!selKey) return []; 152 return (tasksState.items || []) 153 .filter(t => t.id !== opts.taskId && !t.done && t.dueDate === selKey && t.dueTime) 154 .map(t => { 155 const s = minsOf(t.dueTime); 156 const e = t.dueEndTime ? minsOf(t.dueEndTime) : s + 60; 157 return { s, e: Math.max(e, s + 1), title: titlePlain(t.title) || "Task" }; 158 }); 159 } 160 function renderTimeList() { 161 const busy = busyRanges(); 162 let html = ""; 163 for (let m = SLOT_MIN; m <= SLOT_MAX; m += SLOT_STEP) { 164 const isStart = m === selStart, isEnd = m === selEnd; 165 const single = isStart && selEnd == null; 166 const inRange = selStart != null && selEnd != null && m > selStart && m < selEnd; 167 const taken = busy.find(r => m >= r.s && m < r.e); 168 const cls = `td-time-slot${isStart ? " is-start" : ""}${isEnd ? " is-end" : ""}${single ? " is-single" : ""}${inRange ? " in-range" : ""}${taken ? " busy" : ""}`; 169 const tip = taken ? ` title="Busy · ${esc(taken.title)}"` : ""; 170 html += `<button type="button" class="${cls}" data-mins="${m}"${tip}>${fmtTime(m, fmt)}</button>`; 171 } 172 timeListEl.innerHTML = html; 173 } 174 function scrollToSel() { 175 const target = timeListEl.querySelector(".is-start") || timeListEl.querySelector(`[data-mins="${9 * 60}"]`); 176 if (target) timeListEl.scrollTop = Math.max(0, target.offsetTop - timeListEl.clientHeight / 2 + target.offsetHeight); 177 } 178 179 // live commit (no close) â used while editing the time so the task updates 180 const applyTime = () => { if (selStart != null && !selKey) selKey = today;
180 onPick(selKey, timeObj()); }; 181 // calendar day pick: apply live but KEEP the menu open so a time can also be 182 // set in the same session (the grid updates its own .active highlight). 183 const setDate = (key) => { selKey = key; onPick(key, timeObj()); }; 184 // quick-action preset (Today / Tomorrow / Next week / Next month): apply live, 185 // move the calendar to that date + highlight it, and KEEP the menu open. 186 function gotoDate(key) { 187 selKey = key; 188 const [y, mo, d] = key.split("-").map(Number); 189 calY = y; calM = mo - 1; selDate = { y, m: mo - 1, day: d }; 190 const sp = menu.querySelector(".sp-dtp"); 191 if (sp) { 192 sp.innerHTML = dateTimePickerHTML({ y: calY, m: calM, sel: d, dateOnly: true, minDate }); 193 wireDateTimePicker(sp.querySelector(".cal-area"), { 194 y: calY, m: calM, sel: d, selDate, dateOnly: true, minDate, onReflow: place, 195 onPick: ({ y, m, day }) => setDate(`${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`), 196 }); 197 } 198 onPick(key, timeObj()); 199 place(); 200 } 201 // "No date": drop the date (and its time) but KEEP the menu open â the tick 202 // (Done) closes. Re-render the calendar with no day selected. 203 function clearDate() { 204 selKey = null; selDate = null; selStart = selEnd = null; 205 const sp = menu.querySelector(".sp-dtp"); 206 if (sp) { 207 sp.innerHTML = dateTimePickerHTML({ y: calY, m: calM, sel: null, dateOnly: true, minDate }); 208 wireDateTimePicker(sp.querySelector(".cal-area"), { 209 y: calY, m: calM, sel: null, selDate: null, dateOnly: true, minDate, onReflow: place, 210 onPick: ({ y, m, day }) => setDate(`${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`), 211 }); 212 } 213 syncTimeBtn(); 214 onPick(null, null); 215 place(); 216 } 217 218 function showTime(on) { 219 menu.classList.toggle("is-time", on); 220 if (on) { renderTimeList(); requestAnimationFrame(scrollToSel); } 221 place(); 222 } 223 224 syncTimeBtn(); 225 226 menu.addEventListener("click", e => { 227 if (e.target.closest("[data-sheet-close]")) { closeDueMenu(); return; } 228 const set = e.target.closest("[data-due-set]"); 229 if (set) { gotoDate(set.dataset.dueSet); return; } 230 if (e.target.closest("[data-due-clear]")) { clearDate(); return; } // "No date" â clear, stay open 231 if (e.target.closest("[data-due-done]")) { closeDueMenu(); return; } // tick â close 232 if (e.target.closest("[data-due-time-toggle]")) { showTime(true); return; } 233 if (e.target.closest("[data-due-time-back]")) { showTime(false); return; } 234 if (e.target.closest("[data-due-time-clear]")) { 235 selStart = selEnd = null; renderTimeList(); syncTimeBtn(); applyTime(); return; 236 } 237 const fmtBtn = e.target.closest(".td-time-fmt button"); 238 if (fmtBtn) { 239 menu.querySelectorAll(".td-time-fmt button").forEach(b => b.classList.remove("active")); 240 fmtBtn.classList.add("active"); fmt = fmtBtn.dataset.fmt; renderTimeList(); return; 241 } 242 const slot = e.target.closest(".td-time-slot"); 243 if (slot) { 244 const m = parseInt(slot.dataset.mins, 10); 245 if (selStart == null) { selStart = m; selEnd = null; } 246 else if (selEnd == null) { 247 if (m > selStart) selEnd = m; 248 else if (m === selStart) { selStart = selEnd = null; } 249 else selStart = m; // tapped before the start â move start 250 } else { selStart = m; selEnd = null; } // both set â begin a fresh range 251 renderTimeList(); syncTimeBtn(); applyTime(); 252 return; 253 } 254 }); 255 256 // calendar day pick â apply that date (carrying the current time), stay open 257 wireDateTimePicker(menu.querySelector(".cal-area"), { 258 y: calY, m: calM, sel: selDate ? selDate.day : null, selDate, dateOnly: true, minDate, 259 onReflow: place, 260 onPick: ({ y, m, day }) => setDate(`${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`), 261 }); 262 263 const onDoc = e => { if (!e.target.closest(".td-due-menu") && !anchorEl.contains(e.target)) closeDueMenu(); }; 264 const onKey = e => { 265 if (e.key !== "Escape") return; 266 e.stopPropagation(); 267 if (menu.classList.contains("is-time")) showTime(false); else closeDueMenu(); 268 }; 269 setTimeout(() => { document.addEventListener("click", onDoc, true); document.addEventListener("keydown", onKey, true); }, 0); 270 dueMenuState = { menu, scrim, onDoc, onKey, restoreFocusEl: opts.restoreFocusEl || null }; 271}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.