PageSourceSearch

https://pipeline-cms.onrender.com/src/features/tasks/dueMenu.js

js pipeline-cms.onrender.com collected 2026-10-03 11:44:16 UTC 14,187 bytes, 271 lines download raw bytes

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.