Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 160 additions & 18 deletions internal/dashboard/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,62 @@ func highlightGaps(body string) template.HTML {
return template.HTML(b.String()) //nolint:gosec // input is escaped above; only our own <mark> wrapper is added
}

// gapSummaryData aggregates every unresolved Writer gap across a document's
// sections for the review gate's top-of-page summary (issue #274) — one
// GOV.UK-style block with anchor links instead of a banner per gap.
type gapSummaryData struct {
Total int // gaps across the whole document
Headline string // e.g. "3 unresolved gaps across 2 sections"
Sections []gapSummarySection // only sections that hold gaps, in order
}

// gapSummarySection is one gap-holding section in the review-gate summary.
type gapSummarySection struct {
ID string
Heading string
Count int
}

// summarizeGaps builds the review-gate gap summary from the live section
// bodies, so it agrees with the per-section gap bars by construction.
func summarizeGaps(sections []document.Section) gapSummaryData {
var d gapSummaryData
for _, s := range sections {
n := len(finalreview.GapTexts(s.Body))
if n == 0 {
continue
}
d.Total += n
d.Sections = append(d.Sections, gapSummarySection{ID: s.ID, Heading: s.Heading, Count: n})
}
d.Headline = fmt.Sprintf("%d unresolved %s across %d %s",
d.Total, pluralize(d.Total, "gap"), len(d.Sections), pluralize(len(d.Sections), "section"))
return d
}

// pluralize returns word with an "s" appended unless n is exactly 1.
func pluralize(n int, word string) string {
if n == 1 {
return word
}
return word + "s"
}

// openNonGapFlags returns the document flags the review gate should banner:
// unresolved flags that are NOT per-gap "Unresolved gap" flags. Gaps are
// surfaced by the aggregated summary and section bars instead (issue #274) —
// bannering them too would re-introduce the one-callout-per-gap spam.
func openNonGapFlags(flags []document.Flag) []document.Flag {
var open []document.Flag
for _, f := range flags {
if f.Resolved || f.Title == gapFlagTitle {
continue
}
open = append(open, f)
}
return open
}

// handleEditor renders the full-page draft editor for a selected proposal.
func (h *Handler) handleEditor(w http.ResponseWriter, r *http.Request) {
if h.proposals == nil {
Expand Down Expand Up @@ -173,13 +229,17 @@ const editorPageTmpl = `<!DOCTYPE html>
<textarea name="body" rows="8"{{if .Gaps}} class="gap-warn"{{end}}>{{.Body}}</textarea>
<noscript><button class="kbtn kbtn--secondary kbtn--sm" style="margin-top:6px">Save section</button></noscript>
</form>
{{range .Gaps}}
<div class="ed-flag ed-gap">
<div class="ed-flag ed-gap" data-gapbar{{if not .Gaps}} hidden{{end}}>
<span class="ef-ic">` + iconWarn + `</span>
<div><b>Unresolved gap</b><p>{{.}}</p></div>
<button type="button" class="kbtn kbtn--ghost kbtn--sm gap-jump" data-gap="{{.}}">Find in text</button>
<div>
<b data-gapcount>{{len .Gaps}} unresolved gap{{if ne (len .Gaps) 1}}s{{end}}</b>
<ul class="gap-list" data-gaplist hidden>
{{range .Gaps}}<li>{{.}}</li>{{end}}
</ul>
</div>
<button type="button" class="kbtn kbtn--ghost kbtn--sm gap-toggle" data-gaptoggle>Show list</button>
<button type="button" class="kbtn kbtn--ghost kbtn--sm gap-next" data-gapnext>Next gap &rsaquo;</button>
</div>
{{end}}
{{if .Flag}}
<div class="ed-flag">
<span class="ef-ic">` + iconWarn + `</span>
Expand Down Expand Up @@ -220,22 +280,104 @@ const editorPageTmpl = `<!DOCTYPE html>
}, 900);
});
});
// Jump-to-gap: select the [GAP: ...] marker inside the section's textarea so
// the browser scrolls to it and the human sees exactly what is missing.
document.querySelectorAll(".gap-jump").forEach(function (b) {
b.addEventListener("click", function () {
var area = b.closest("section").querySelector("textarea");
` + gapScriptJS + `
</script>
</body>
</html>
`

// gapScriptJS is the client half of the aggregated gap UI (issue #274),
// shared by the full-page editor and the workspace review gate. It keeps each
// section's gap bar live while the human types — count, list, textarea tint,
// rail dot, and the gate summary all derive from the textarea value, so they
// clear the moment the last [GAP: ...] marker is filled (no reload) — and
// drives the "Next gap" cycling and the on-demand gap list.
const gapScriptJS = `
// Mirrors finalreview.GapTexts: every "[GAP:" marker counts as one gap; its
// text runs to the closing "]" or, if the model left it unclosed, to the
// end of its line.
function gapTexts(text) {
var gaps = [];
text.split("\n").forEach(function (line) {
var from = 0;
for (;;) {
var s = line.indexOf("[GAP:", from);
if (s < 0) break;
var e = line.indexOf("]", s);
gaps.push((e < 0 ? line.slice(s + 5) : line.slice(s + 5, e)).trim());
from = e < 0 ? line.length : e + 1;
}
});
return gaps;
}
// Keep the review-gate summary (if this page has one) agreeing with the
// per-section bars: recount every section, update the per-section rows, and
// hide the whole block when the draft is clean.
function updateGapSummary() {
var sum = document.querySelector("[data-gapsummary]");
if (!sum) return;
var total = 0, secs = 0;
document.querySelectorAll("[data-gapbar]").forEach(function (bar) {
var sec = bar.closest("section");
var area = sec && sec.querySelector("textarea");
if (!area) return;
var idx = area.value.indexOf(b.getAttribute("data-gap"));
var start = idx < 0 ? area.value.indexOf("[GAP:") : area.value.lastIndexOf("[GAP:", idx);
if (start < 0) return;
var end = area.value.indexOf("]", start);
var n = gapTexts(area.value).length;
var li = sec.id ? sum.querySelector('li[data-sec="' + sec.id + '"]') : null;
if (li) {
li.hidden = n === 0;
var c = li.querySelector(".gs-n");
if (c) c.textContent = n;
}
if (n > 0) { total += n; secs += 1; }
});
sum.hidden = total === 0;
var h = sum.querySelector("[data-gapheadline]");
if (h) h.textContent = total + " unresolved " + (total === 1 ? "gap" : "gaps")
+ " across " + secs + " " + (secs === 1 ? "section" : "sections");
}
document.querySelectorAll("[data-gapbar]").forEach(function (bar) {
var sec = bar.closest("section");
var area = sec && sec.querySelector("textarea");
if (!area) return;
var count = bar.querySelector("[data-gapcount]");
var list = bar.querySelector("[data-gaplist]");
var toggle = bar.querySelector("[data-gaptoggle]");
var next = bar.querySelector("[data-gapnext]");
var rail = sec.id ? document.querySelector('.ed-sec[href="#' + sec.id + '"]') : null;
// A non-gap review flag keeps the rail dot amber even once gaps are filled.
var hasOtherFlag = !!sec.querySelector(".ed-flag:not(.ed-gap)");
area.addEventListener("input", function () {
var gaps = gapTexts(area.value);
bar.hidden = gaps.length === 0;
area.classList.toggle("gap-warn", gaps.length > 0);
if (rail) rail.classList.toggle("warn", gaps.length > 0 || hasOtherFlag);
if (count) count.textContent = gaps.length + " unresolved " + (gaps.length === 1 ? "gap" : "gaps");
if (list) {
list.textContent = "";
gaps.forEach(function (g) {
var li = document.createElement("li");
li.textContent = g;
list.appendChild(li);
});
}
updateGapSummary();
});
if (toggle && list) toggle.addEventListener("click", function () {
list.hidden = !list.hidden;
toggle.textContent = list.hidden ? "Show list" : "Hide list";
});
// Cycle the selection through the [GAP: ...] markers, wrapping after the
// last, so one button replaces a find-button per gap.
if (next) next.addEventListener("click", function () {
var v = area.value;
var s = v.indexOf("[GAP:", area.selectionEnd || 0);
if (s < 0) s = v.indexOf("[GAP:");
if (s < 0) return;
var close = v.indexOf("]", s), nl = v.indexOf("\n", s);
var end = close >= 0 && (nl < 0 || close < nl) ? close + 1 : (nl < 0 ? v.length : nl);
area.focus();
area.setSelectionRange(start, end < 0 ? area.value.length : end + 1);
area.setSelectionRange(s, end);
area.scrollIntoView({ behavior: "smooth", block: "center" });
});
});
</script>
</body>
</html>
`
57 changes: 57 additions & 0 deletions internal/dashboard/editor_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package dashboard
import (
"strings"
"testing"

"github.com/Mawar2/Kaimi/internal/document"
)

// TestHighlightGaps verifies the read-only draft rendering: gap markers are
Expand Down Expand Up @@ -35,3 +37,58 @@ func TestHighlightGaps_NoMarker_PlainEscape(t *testing.T) {
t.Errorf("plain text must still be escaped: %q", got)
}
}

// TestSummarizeGaps verifies the review-gate summary aggregation (issue
// #274): totals across sections, only gap-holding sections listed, and a
// correctly pluralized headline.
func TestSummarizeGaps(t *testing.T) {
sections := []document.Section{
{ID: "exec", Heading: "Executive Summary", Body: "All grounded prose."},
{ID: "tech", Heading: "Technical Approach", Body: "Staffed by [GAP: cleared staff count] holding [GAP: clearance level]."},
{ID: "past", Heading: "Past Performance", Body: "Delivered before. [GAP: DoD contract number]"},
}
got := summarizeGaps(sections)
if got.Total != 3 {
t.Errorf("Total = %d, want 3", got.Total)
}
if len(got.Sections) != 2 {
t.Fatalf("Sections = %d entries, want 2 (only gap-holding sections)", len(got.Sections))
}
if got.Sections[0].ID != "tech" || got.Sections[0].Count != 2 {
t.Errorf("Sections[0] = %+v, want tech with 2 gaps", got.Sections[0])
}
if got.Headline != "3 unresolved gaps across 2 sections" {
t.Errorf("Headline = %q", got.Headline)
}
}

func TestSummarizeGaps_Singular(t *testing.T) {
got := summarizeGaps([]document.Section{
{ID: "tech", Heading: "Technical Approach", Body: "[GAP: staffing count]"},
})
if got.Headline != "1 unresolved gap across 1 section" {
t.Errorf("Headline = %q", got.Headline)
}
}

func TestSummarizeGaps_Clean(t *testing.T) {
got := summarizeGaps([]document.Section{{ID: "exec", Heading: "Exec", Body: "fine"}})
if got.Total != 0 || len(got.Sections) != 0 {
t.Errorf("clean draft: got %+v, want zero summary", got)
}
}

// TestOpenNonGapFlags: the gate's flag banners must exclude resolved flags and
// the per-gap "Unresolved gap" flags — gaps are surfaced by the aggregated
// summary instead (issue #274), so persisted gap flags would double-report.
func TestOpenNonGapFlags(t *testing.T) {
flags := []document.Flag{
{Title: gapFlagTitle, Detail: "missing staffing count", SectionID: "tech"},
{Title: "Tone concern", Detail: "too informal", SectionID: "exec"},
{Title: "Stale citation", Detail: "old contract", SectionID: "past", Resolved: true},
}
got := openNonGapFlags(flags)
if len(got) != 1 || got[0].Title != "Tone concern" {
t.Errorf("openNonGapFlags = %+v, want only the open non-gap flag", got)
}
}
Loading
Loading