package docs // HTML rendering for the writing passport. Self-contained (no external assets) // and styled for print, so the browser's "Save as PDF" turns it into the file a // writer actually hands over. import ( "fmt" "math" "strings" "time" ) // Chart geometry. The plot is wide and short on purpose: the report's question // is "what shape did this document grow in", and a wide aspect makes a steady // climb read as steady rather than dramatic. const ( chartW, chartH = 760, 260 padL, padR, padT, padB = 52, 20, 18, 34 plotW, plotH = chartW - padL - padR, chartH - padT - padB minBandW = 2.0 // so a single-snapshot session still shows // gutterSlots is the space between sessions, in snapshot-slot widths. Wide // enough to read as a break and to seat its duration label. gutterSlots = 2.5 ) // Palette — the export stylesheet's tokens, reused so a passport looks like it // came from the same application as the document it describes. const ( rose = "#b04a6a" roseLight = "#f6d6e0" roseWash = "#fdeef3" surface = "#fffafb" ) func renderPassport(d passportData) []byte { var b strings.Builder fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title)) fmt.Fprintf(&b, `

Writing passport

%s

Generated %s

`, htmlEscape(d.Doc.Title), htmlEscape(formatWhen(d.GeneratedAt))) if len(d.Versions) == 0 { b.WriteString(`

This document has no saved history yet, so there is nothing to report. History builds up automatically as you write.

`) return []byte(b.String()) } b.WriteString(renderStats(d)) b.WriteString(renderChart(d)) b.WriteString(renderSessions(d)) b.WriteString(renderIntegrity(d)) b.WriteString(passportLimits) b.WriteString("\n") return []byte(b.String()) } // renderStats is the headline row — the numbers a reader wants before deciding // whether to study the chart. func renderStats(d passportData) string { final := d.Versions[len(d.Versions)-1].WordCount tiles := []struct{ value, label string }{ {fmt.Sprintf("%d", len(d.Versions)), "snapshots saved"}, {humanDuration(d.Span), "from first to last edit"}, {fmt.Sprintf("%d", len(d.Sessions)), pluralize(len(d.Sessions), "writing session", "writing sessions")}, {humanDuration(d.ActiveTime), "spent actively editing"}, {fmt.Sprintf("%d", final), "words in the final draft"}, } var b strings.Builder b.WriteString(`
`) for _, t := range tiles { fmt.Fprintf(&b, `
%s%s
`, htmlEscape(t.value), htmlEscape(t.label)) } b.WriteString("
\n") return b.String() } // renderChart draws word count as a step line — the count is only known at // snapshot moments, and a step says that honestly where a smooth curve would // invent values in between. Shaded bands mark writing sessions. // // The x axis is snapshot order, not wall-clock time, and this is deliberate. On // a linear time axis an essay written in three half-hour sittings across three // days renders as three vertical cliffs separated by empty space: all the actual // writing is crushed into one percent of the width, and the result looks exactly // like text pasted in three chunks — the opposite of what happened. Because // auto-snapshots are throttled to roughly one per few minutes of *active* // editing, snapshot order is already close to proportional to time spent // writing. So the plot gives its width to the writing and compresses the breaks, // which are drawn as explicit labelled gaps rather than silently removed. // // One series, so no legend: the heading names it. func renderChart(d passportData) string { maxW := 0 for _, v := range d.Versions { if v.WordCount > maxW { maxW = v.WordCount } } yTop := niceCeil(maxW) y := func(words int) float64 { if yTop <= 0 { return padT + plotH } return padT + plotH - float64(words)/float64(yTop)*plotH } // Lay snapshots out in slots: one per snapshot, plus a gutter between // sessions for the break marker. slots := float64(len(d.Versions)) + gutterSlots*float64(len(d.Sessions)-1) sw := plotW / slots xs := make([]float64, len(d.Versions)) bandStart := make([]float64, len(d.Sessions)) bandEnd := make([]float64, len(d.Sessions)) cursor, vi := 0.0, 0 for si, s := range d.Sessions { if si > 0 { cursor += gutterSlots } bandStart[si] = padL + cursor*sw for k := 0; k < s.Snapshots; k++ { xs[vi] = padL + (cursor+0.5)*sw cursor++ vi++ } bandEnd[si] = padL + cursor*sw } var b strings.Builder fmt.Fprintf(&b, `

How the draft grew

`, chartW, chartH) // Session bands sit behind everything; each carries a native tooltip. for i, s := range d.Sessions { w := bandEnd[i] - bandStart[i] if w < minBandW { w = minBandW } fmt.Fprintf(&b, `Session %d: %s, %s, %d snapshots `, bandStart[i], padT, w, plotH, roseWash, i+1, htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())), s.Snapshots) } // Recessive gridlines with y labels at 0 / half / top. for _, gv := range []int{0, yTop / 2, yTop} { gy := y(gv) fmt.Fprintf(&b, ` %d `, padL, gy, chartW-padR, gy, roseLight, padL-8, gy+4, gv) } // Break markers in the gutters, so compressed time is stated, not hidden. for i := 1; i < len(d.Sessions); i++ { mid := (bandEnd[i-1] + bandStart[i]) / 2 gap := d.Sessions[i].Start.Sub(d.Sessions[i-1].End) fmt.Fprintf(&b, ` %s `, mid, padT, mid, padT+plotH, roseLight, mid, padT+plotH+13, htmlEscape(humanDuration(gap)+" away")) } // Step path: hold the previous value until the next snapshot lands. var path strings.Builder fmt.Fprintf(&path, "M %.1f %.1f", xs[0], y(d.Versions[0].WordCount)) for i := 1; i < len(d.Versions); i++ { fmt.Fprintf(&path, " L %.1f %.1f L %.1f %.1f", xs[i], y(d.Versions[i-1].WordCount), xs[i], y(d.Versions[i].WordCount)) } fmt.Fprintf(&b, ` `, path.String(), rose) // Pre-empt the obvious question: label the largest single jump when it is a // big share of the finished draft, rather than letting a reader find it. if d.NoteJump && d.LargestJumpIdx < len(xs) { jx, jy := xs[d.LargestJumpIdx], y(d.Versions[d.LargestJumpIdx].WordCount) // Flip the label inboard near the right edge so it can't overflow, and // push it below the point when the point sits near the top. anchor, dx := "start", 9.0 if jx > float64(chartW)*0.6 { anchor, dx = "end", -9.0 } ly := jy - 10 if ly < padT+12 { ly = jy + 18 } fmt.Fprintf(&b, ` largest single addition: +%d words `, jx, jy, rose, surface, jx+dx, ly, anchor, d.LargestJump) } fmt.Fprintf(&b, `

Each shaded band is one writing session, %s to %s. Width follows snapshots saved, so time spent writing gets the space and breaks are compressed to the labelled gaps.

`, htmlEscape(formatDay(d.FirstAt)), htmlEscape(formatDay(d.LastAt))) return b.String() } func renderSessions(d passportData) string { var b strings.Builder b.WriteString(`

Writing sessions

`) for i, s := range d.Sessions { fmt.Fprintf(&b, ` `, i+1, htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())), s.Snapshots, s.WordsAdded) } b.WriteString("
#StartedLengthSnapshotsNet words
%d%s%s%d%+d
\n
\n") return b.String() } // renderIntegrity explains the hash chain in plain language, including when it // cannot vouch for something. func renderIntegrity(d passportData) string { var headline, detail string switch d.ChainStatus { case chainVerified: headline = "History intact" detail = "Every snapshot matches its own contents and links correctly to the one before it. Nothing in this history has been altered or removed since it was recorded." case chainGaps: headline = "History intact, with gaps" detail = "Every snapshot matches its own contents, but some older automatic snapshots have been cleared to save space, so the record is not continuous. Turn on “keep full history” for this document to stop that happening." case chainPartial: headline = "Partly verifiable" detail = fmt.Sprintf("%d snapshot(s) were recorded before this document started tracking integrity, so they cannot be checked. Everything recorded since then matches.", d.UnhashedCount) case chainUnverifiable: headline = "Not verifiable" detail = "These snapshots were recorded before integrity tracking existed. The timeline above is still the record that was saved as you wrote; it simply cannot be checked for later alteration." case chainBroken: headline = "Integrity check failed" detail = "At least one snapshot does not match what was recorded for it. This can mean the history was edited after the fact, or that the database was restored from a backup or copied between machines." } return fmt.Sprintf(`

%s

%s

`, htmlEscape(d.ChainStatus), htmlEscape(headline), htmlEscape(detail)) } // passportLimits states plainly what the report does and does not establish. // Overclaiming would be worse than useless: a reader who catches the report // overstating its case discounts the whole thing. const passportLimits = `

How to read this

A document written over time leaves a trail: many snapshots, uneven growth, words added and cut and added again across separate sittings. A document that was pasted in from elsewhere tends to arrive nearly whole, in one or two snapshots, with little revision after.

What this report shows is the record Petal saved automatically while the document was open, roughly every few minutes of active editing.

What it does not show. It cannot prove who was at the keyboard, and it cannot tell whether text typed into the editor was composed there or copied from another window. It is evidence of a writing process, not a certificate of authorship. It is most useful read alongside the drafts themselves.

` // --- formatting helpers ----------------------------------------------------- func formatWhen(t time.Time) string { return t.Local().Format("2 Jan 2006, 3:04 PM") } func formatDay(t time.Time) string { return t.Local().Format("2 Jan 2006") } // humanDuration renders a span at the coarsest useful precision — a reader cares // that a session ran "2h 40m", never that it ran 2h40m12s. func humanDuration(d time.Duration) string { if d < time.Minute { return "under a minute" } days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 mins := int(d.Minutes()) % 60 switch { case days > 0 && hours > 0: return fmt.Sprintf("%dd %dh", days, hours) case days > 0: return fmt.Sprintf("%dd", days) case hours > 0 && mins > 0: return fmt.Sprintf("%dh %dm", hours, mins) case hours > 0: return fmt.Sprintf("%dh", hours) default: return fmt.Sprintf("%dm", mins) } } func pluralize(n int, one, many string) string { if n == 1 { return one } return many } // niceCeil rounds a maximum up to a round number so gridlines land on values a // reader can hold in their head. func niceCeil(n int) int { if n <= 0 { return 0 } mag := math.Pow(10, math.Floor(math.Log10(float64(n)))) return int(math.Ceil(float64(n)/(mag/2)) * (mag / 2)) } // passportHead is the page shell: one %s for the title. Print rules keep the // chart and the caveats on the page rather than letting them break across sheets. const passportHead = ` Writing passport — %s `