PageSourceSearch

https://govably.ai/app/document/build/build-run.js?v=govably-wizard13

js govably.ai collected 2026-10-05 14:16:20 UTC 15,715 bytes, 278 lines download raw bytes

1/* Document tab — Running the build: first build, rebuild, backfilling requesters, starting over.
2   Loaded by app.html after app.js; see ../README.md. */
3
4// Backfills the requester (submitterName) into existing minute item title blocks for a meeting.
5// Safe for in-progress minutes: only rewrites title blocks whose content still matches the
6// original generated format ("Title" or "Title \u2014 Department"). Blocks that have been
7// hand-edited are skipped.
8async function backfillRequesterInMinutes(meetingId) {
9    meetingId = meetingId || getActiveMeetingId();
10    if (!meetingId) { showToast('No active meeting'); return { updated: 0, skipped: 0 }; }
11
12    const items = (_cachedData.agendaItems || []).filter(function(i) { return i.meetingId === meetingId; });
13    const itemById = {};
14    items.forEach(function(i) { itemById[i.id] = i; });
15
16    const snap = await minutesBlocksRef().where('meetingId', '==', meetingId).where('sourceType', '==', 'item').get();
17
18    const batch = db.batch();
19    let updated = 0, skipped = 0;
20    snap.forEach(function(doc) {
21        const b = doc.data();
22        const item = itemById[b.sourceItemId];
23        if (!item) { skipped++; return; }
24        if (!item.submitterName) { skipped++; return; }
25
26        const title = item.title || '';
27        const dept = item.department || '';
28        const origNoMeta = title;
29        const origWithDept = dept ? (title + ' \u2014 ' + dept) : title;
30        // Also accept a prior backfilled state that used the old ' \u00b7 ' separator so
31        // re-running migrates it to the new comma-separated format.
32        const priorBullet = title + ' \u2014 ' + [item.submitterName, dept].filter(Boolean).join(' \u00b7 ');
33        const current = b.content || '';
34
35        if (current !== origNoMeta && current !== origWithDept && current !== priorBullet) { skipped++; return; }
36
37        const newContent = title + ' \u2014 ' + [item.submitterName, dept].filter(Boolean).join(', ');
38        if (newContent === current) { skipped++; return; }
39
40        // The rich copy described the old text — it can't survive a rewrite of it.
41        batch.update(doc.ref, { content: newContent, contentHtml: '', updatedAt: firebase.firestore.FieldValue.serverTimestamp() });
42        updated++;
43    });
44
45    if (updated > 0) await batch.commit();
46    await loadMeetingData(meetingId);
47    showToast('Requester backfill: ' + updated + ' updated, ' + skipped + ' skipped');
48    return { updated: updated, skipped: skipped };
49}
50
51window.backfillRequesterInMinutes = backfillRequesterInMinutes;
52
53async function initAndRenderMinutesBlocks(meetingId, event) {
54    const btn = event ? event.target.closest('button') : null;
55    const btnHTML = btn ? btn.innerHTML : null;
56
57    if (btn) {
58        btn.disabled = true;
59        btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Generating…';
60    }
61
62    // Counted before the build so a rebuild is distinguishable from a first build. The
63    // empty-state button only appears when there are none, but this is also reachable
64    // after Uncreate minutes, and "built the template again over 30 existing blocks" is
65    // a different event from "built it for the first time".
66    var _blocksBefore = 0;
67    try { _blocksBefore = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {}
68
69    try {
70        await initializeMinutesBlocks(meetingId);
71        // Surface each recorded vote as an "Action" line immediately — the actions come
72        // through on the first (template) build, without needing the AI pass.
73        try { await mbBuildPerItemVoteSentences(meetingId); } catch (e) { console.warn('vote sentences failed', e); }
74        // One event for the whole build. initializeMinutesBlocks writes a batch of blocks;
75        // that is one act, not thirty (same reasoning as minutes.generated).
76        if (typeof trackAudit === 'function') {
77            var _blocksAfter = 0;
78            try { _blocksAfter = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {}
79            trackAudit('minutes.template.built', meetingId, {
80                meta: { blocks: _blocksAfter, replacedBlocks: _blocksBefore }
81            });
82        }
83        const container = mbGetActiveContainer();
84        if (container) {
85            await renderMinutesBuilder(meetingId, container);
86        }
87    } catch (err) {
88        console.error('initAndRenderMinutesBlocks error:', err);
89        showToast('Error building minutes: ' + err.message);
90        if (btn) { btn.disabled = false; btn.innerHTML = btnHTML; }
91    }
92}
93
94/* Build the minutes — the skeleton and the AI narrative, in one press (#481).
95 *
96 * These were two buttons: Generate Template built the sections, the item titles and the
97 * vote action lines, and then the bar swapped to Write minutes, whose menu asked for a
98 * level of detail and ran the AI over every section. A clerk building minutes for the
99 * first time wants both essentially every time, so the first press landed them on a
100 * screen whose only remaining job was to press the second button — and the level of
101 * detail, the one real decision in the flow, was hidden behind that second press rather
102 * than asked when they decided to build.
103 *
104 * So the level is asked up front (mbDocBuildMenu) and comes in here. A falsy detailLevel
105 * is the menu's "No minutes" row: the template and nothing else, which is exactly what
106 * Generate Template did on its own and is still what a clerk who does not want the AI
107 * near their minutes should get.
108 *
109 * _mbBuildRunning guards THIS function's own re-entry, and it is belt-and-braces rather
110 * than a fix for an observed race: the only way in is a row of mbDocBuildMenu, and GV.menu
111 * removes itself on the activating click, so a double-click cannot fire the row twice. It
112 * earns its place because this is a global an inline handler can reach, the run is now
113 * long enough to click into, and the cost of being wrong here is #323.
114 *
115 * It is NOT what stops two AI sweeps colliding. That door is the tab switch: the progress
116 * card outlives the screen by design, and coming back re-renders the bar with a freshly
117 * enabled Write minutes. The guard for it is on mbGenerateWholeDocument, where the flag
118 * that tracks a live sweep already lives; see the comment there.
119 *
120 * (#323 still owns the rest: what a deliberate REBUILD should do to blocks that are
121 * already there is a question this does not answer.) */
122var _mbBuildRunning = false;
123
124async function mbBuildMinutes(meetingId, detailLevel) {
125    if (readonlyBlock()) return;
126    if (_mbBuildRunning) { showToast('Already building these minutes — one moment'); return; }
127    _mbBuildRunning = true;
128    /* The bar button wears the run, found by id rather than off the event: the click that
129       got here came from a MENU ROW on <body>, so event.target is that row, not the button
130       the clerk is looking at. It is only worn for the template half — renderMinutesBuilder
131       swaps the whole bar for the built-state one part-way through, taking this button with
132       it — and that is the half with no other feedback. From there mbGenerateWholeDocument's
133       progress card and its own button take over. */
134    var _bbtn = document.getElementById('mb-gen-template-btn');
135    var _bwas = mbAiBusy(_bbtn, 'Building…');
136    try {
137        await initAndRenderMinutesBlocks(meetingId, null);
138        // initAndRenderMinutesBlocks catches its own failures and toasts them, so a thrown
139        // error is not how we learn the template did not get built. An empty document is.
140        // Going on to the AI pass here would toast a second, less true message on top of
141        // the first ("No sections to generate yet" when the real fault was the write).
142        var _built = 0;
143        try { _built = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {}
144        if (!_built) return;
145        if (!detailLevel) return;
146        await mbGenerateWholeDocument(meetingId, detailLevel, { skipConfirm: true });
147    } finally {
148        _mbBuildRunning = false;
149        // Only if it survived — the usual path is that the re-render replaced it, and
150        // putting a label back on a detached node is writing to nothing.
151        if (_bbtn && _bbtn.isConnected) mbAiIdle(_bbtn, _bwas);
152    }
153}
154
155/* The Document tab before anything has been built.
156 *
157 * THE EMPTY STATE IS THE PAGE (#665, Dom 2026-09-22), the way every step asks to be run
158 * (.gv-start, #671): the nib, a line saying what the AI does, one button, and the steps
159 * under it. Before that it
160 * was a step bar with Build minutes as its one action and the explanation beside it — a
161 * control row across the top of a column with nothing in it, which underplays the only
162 * thing there is to do on the screen.
163 *
164 * The rest of the stage is real: the recording and the Agenda/Transcript panel are beside
165 * it, because they are what the minutes will be written from. Pressing Build changes what
166 * is in the document column and nothing else moves.
167 *
168 * NOTHING ON THE TAB ROW. There is no draft to be a draft of, so a Draft pill over an
169 * empty column would be a status report about nothing — renderMinutesBuilder paints the
170 * pill and Approve only once blocks exist, and switchMeetingWorksp
170ace has already cleared
171 * whatever the last step left there. */
172function renderMBInitBanner(meetingId, hasLegacy, meeting) {
173    var mid = String(meetingId).replace(/'/g, "\\'");
174    var ro = (typeof canEdit === 'function') && !canEdit();
175
176    /* A statement, not a warning: nothing is at risk, and building does not delete the old
177       minutes. One of the panel's own notes, inside the card (#671) — drawn above it, the
178       note became the column's first child and .mw-dock pinned it to the tab row instead. */
179    var legacy = hasLegacy
180        ? '<p class="gv-start__note"><i class="fas fa-circle-info" aria-hidden="true"></i>'
181          + 'This meeting has legacy minutes. Building structured minutes will not delete them.</p>'
182        : '';
183
184    /* The shared start panel (#671) — the same classes as Review Agenda, Recording,
185       Speakers and Motions, so the five empty steps change together. */
186    var start = '<div class="gv-start-card"><div class="gv-start">'
187        + GV.startNib()
188        + '<h3 class="gv-start__title">Build the minutes</h3>'
189        + (ro
190            ? '<p class="gv-start__note"><i class="fas fa-lock" aria-hidden="true"></i>'
191              + 'Ask an admin to make changes.</p>'
192            : GV.stepBarAction({
193                  /* "Build minutes", not "Generate Template" (#481). The press now builds
194                     the skeleton AND writes the narrative, so the old name describes half
195                     of what happens — which is the confusion #246 reported from the other
196                     side, when the two buttons gave no clue which of them ran the AI.
197                     There is one button, and it opens the menu that asks. */
198                  start: true, icon: 'fa-file-lines',
199                  label: 'Build minutes', ai: true, id: 'mb-gen-template-btn',
200                  busyLabel: 'Building…',
201                  // Not "and write them": the menu's own No minutes row deliberately does not.
202                  title: 'Build the minutes from this meeting’s data, and choose how they’re written',
203                  onclick: "mbDocBuildMenu(event,'" + mid + "')"
204              })
205              + '<ol class="gv-start__steps">'
206              + '<li><span>1</span> Choose the detail</li>'
207              + '<li><span>2</span> Govably AI writes it</li>'
208              + '<li><span>3</span> Edit before approving</li>'
209              + '</ol>')
210        + legacy
211        + '</div></div>';
212
213    var h = '<div class="mbdoc" id="mbdoc-root"><div class="gv-stage mw-dock">';
214    h += '<div class="gv-stage__main">';
215    // No #mbdoc-fmt slot: the format bar floats over the box it is editing and reserves no
216    // space. There is also nothing to edit on this screen yet.
217    // --start: no grey desk behind the start panel (#671) — it sits in the same white card
218    // as the other steps' panels. The desk comes back with the first page.
219    h += '<div class="mbdoc-doc mbdoc-doc--start gv-scroll mw-dock" id="mbdoc-doc">' + start + '</div>';
220    h += '</div>';
221    h += GV.stage.railHtml();
222    h += mbDocSideHtml(meetingId, meeting);
223    return h + '</div></div>';
224}
225
226async function mbUncreateMinutes(meetingId) {
227    if (readonlyBlock()) return;
228    if (!confirm('Uncreate minutes for this meeting?\n\nAll generated minutes blocks (sections, votes, signatures, edits) will be deleted. The meeting will return to the unbuilt state — you can re-build later with the "Build minutes" button.\n\nThis cannot be undone. Are you sure?')) return;
229    try {
230        var data = getData();
231        var existing = (data.minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; });
232        var batchSize = 499;
233        for (var i = 0; i < existing.length; i += batchSize) {
234            var batch = db.batch();
235            existing.slice(i, i + batchSize).forEach(function(b) { batch.delete(minutesBlocksRef().doc(b.id)); });
236            await batch.commit();
237        }
238        data.minutesBlocks = (data.minutesBlocks || []).filter(function(b) { return b.meetingId !== meetingId; });
239        await fbLogActivity('uncreated minutes', meetingId);
240        var container = mbGetActiveContainer();
241        if (container) await renderMinutesBuilder(meetingId, container);
242        showToast('Minutes deleted — click Build minutes when you’re ready to start over');
243    } catch (err) { showToast('Error: ' + err.message); }
244}
245
246async function mbResetBlocks(meetingId) {
247    if (mbBlockIfApproved(meetingId)) return;
248    if (readonlyBlock()) return;
249    if (!confirm('Erase & regenerate minutes?\n\nThis will delete all your current edits, custom notes, and formatting and regenerate everything from the meeting data.\n\nThis cannot be undone. Are you sure?')) return;
250    // Show progress on the button so the operation never looks like it did nothing.
251    var btn = (typeof event !== 'undefined' && event && event.target) ? event.target.closest('button') : null;
252    var origHtml = btn ? btn.innerHTML : null;
253    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs mr-1.5"></i><span>Regenerating…</span>'; }
254    try {
255        var data = getData();
256        var existing = (data.minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; });
257        var batchSize = 499;
258        for (var i = 0; i < existing.length; i += batchSize) {
259            var batch = db.batch();
260            existing.slice(i, i + batchSize).forEach(function(b) { batch.delete(minutesBlocksRef().doc(b.id)); });
261            await batch.commit();
262        }
263        var d2 = getData();
264        d2.minutesBlocks = (d2.minutesBlocks || []).filter(function(b) { return b.meetingId !== meetingId; });
265        await initializeMinutesBlocks(meetingId);
266        var container = mbGetActiveContainer();
267        if (container) {
268            await renderMinutesBuilder(meetingId, container);
269        }
270        var rebuilt = (getData().minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; }).length;
271        showToast('Minutes regenerated — ' + rebuilt + ' block' + (rebuilt === 1 ? '' : 's') + (container ? '' : ' (no view to refresh)'), 4000);
272    } catch (err) {
273        console.error('mbResetBlocks error:', err);
274        showToast('Could not regenerate minutes: ' + ((err && err.message) ? err.message : 
274String(err)), 6000);
275    } finally {
276        if (btn && origHtml !== null) { btn.disabled = false; btn.innerHTML = origHtml; }
277    }
278}

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.