oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1// quickadd.js — the live detail pane.
2//
3// This file adds ONE thing: it asks the server what the sentence means
4// while you type, and swaps the answer into the pane. It does not parse
5// anything. There is no date logic here and there must never be: the
6// server's parser is the one that decides what gets saved, so a second
7// opinion in this file could only ever disagree with the thing that
8// actually happens.
9//
10// Everything else already works without this file. The disclosure is a
11// <details>, the form is a form, and submitting it creates the event.
12// Turn JavaScript off and the feature is slower to check, not gone.
13(function () {
14 "use strict";
15
16 var DEBOUNCE_MS = 220;
17
18 function setup(root) {
19 var text = root.querySelector("[cal-quick-text]");
20 var pane = root.querySelector("[cal-quick-pane]");
21 if (!text || !pane) return;
22
23 var timer = null;
24 var inflight = null;
25 var lastSent = null;
26
27 function render(html) {
28 pane.innerHTML = html;
29 }
30
31 function ask() {
32 var value = text.value.trim();
33 if (value === lastSent) return;
34 lastSent = value;
35
36 // An empty box goes back to the resting state without a round
37 // trip: the server would answer the same thing.
38 if (value === "") {
39 render('<p cal-quick-blank>Start typing and this will show what gets saved.</p>');
40 return;
41 }
42
43 // One request at a time. Without this, two keystrokes in flight
44 // can land out of order and the pane settles on the OLDER parse —
45 // a stale answer that looks authoritative.
46 if (inflight) inflight.abort();
47 var ctrl = new AbortController();
48 inflight = ctrl;
49
50 var body = new URLSearchParams();
51 body.set("text", value);
52
53 fetch("/events/quick/parse", {
54 method: "POST",
55 credentials: "same-origin",
56 headers: { "Content-Type": "application/x-www-form-urlencoded" },
57 body: body.toString(),
58 signal: ctrl.signal
59 })
60 .then(function (res) {
61 if (!res.ok) throw new Error("parse " + res.status);
62 return res.text();
63 })
64 .then(function (html) {
65 if (ctrl !== inflight) return;
66 render(html);
67 })
68 .catch(function (err) {
69 if (err && err.name === "AbortError") return;
70 // The pane is an enhancement; if it cannot be refreshed, say
71 // so rather than leaving a stale parse on screen looking
72 // current. Submitting still works — the server parses again.
73 render('<p cal-quick-blank>Could not check that just now. Adding it will still work.</p>');
74 });
75 }
76
77 text.addEventListener("input", function () {
78 if (timer) clearTimeout(timer);
79 timer = setTimeout(ask, DEBOUNCE_MS);
80 });
81
82 // Opening the disclosure should put the cursor where the person is
83 // about to type. <details> fires "toggle" for every route in —
84 // pointer, keyboard, and the browser opening it to find text — so
85 // this covers them all where a click handler would cover one.
86 root.addEventListener("toggle", function () {
87 if (root.open) {
88 text.focus();
89 ask();
90 }
91 });
92
93 // Enter adds it. Shift+Enter is a newline, and Cmd/Ctrl+Enter adds
94 // it too — the chord people arrive from GitHub and Linear expecting.
95 //
96 // Enter can fire before the debounced parse has landed, so the pane
97 // may never have shown this sentence. That is ACCEPTABLE, and the
98 // reason is worth stating: the pane is a preview, never a gate. The
99 // server parses again on submit, so racing it can only mean "you did
100 // not get to look" — never "the wrong event was created". Anyone who
101 // wants to look pauses, which is the same pause the debounce needs.
102 text.addEventListener("keydown", function (e) {
103 if (e.key !== "Enter") return;
104 if (e.shiftKey) return; // a deliberate newline
105 // isComposing guards an IME: mid-composition Enter commits the
106 // candidate word and must not submit the form under it.
107 if (e.isComposing || e.keyCode === 229) return;
108 if (text.value.trim() === "") return;
109 e.preventDefault();
110 submit();
111 });
112
113 function submit() {
114 var form = root.querySelector("[cal-quick-form]");
115 if (!form) return;
116 // requestSubmit fires submit handlers and honours validation the
117 // way a real button press does; form.submit() skips both. The
118 // button goes with it, so the busy rule shows its spinner on Add.
119 var button = form.querySelector("button[type=submit]");
120 if (typeof form.requestSubmit === "function") {
121 form.requestSubmit(button || undefined);
122 return;
123 }
124 if (button) button.click();
125 }
126
127 // Escape closes, which is what every other popover on the web does.
128 root.addEventListener("keydown", function (e) {
129 if (e.key !== "Escape" || !root.open) return;
130 root.open = false;
131 var summary = root.querySelector("summary");
132 if (summary) summary.focus();
133 });
134
135 // The keyboard hint is revealed HERE rather than rendered in the
136 // template, because without this file Enter does nothing and a
137 // promise the page cannot keep is worse than no hint at all.
138 var hint = root.querySelector("[cal-quick-key]");
139 if (hint) hint.hidden = false;
140 }
141
142 function init() {
143 var boxes = document.querySelectorAll("[cal-quick]");
144 for (var i = 0; i < boxes.length; i++) setup(boxes[i]);
145 }
146
147 if (document.readyState === "loading") {
148 document.addEventListener("DOMContentLoaded", init);
149 } else {
150 init();
151 }
152})();
153