feat: add survey-tui (#410)

This commit is contained in:
Antoine Lecompte
2026-03-27 08:51:52 -04:00
committed by GitHub
parent 87126a69cd
commit 049e7575bb
10 changed files with 1733 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
survey-tui
comments.md
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// D1 API types
type d1Request struct {
SQL string `json:"sql"`
}
type d1Response struct {
Result []d1Result `json:"result"`
Errors []d1Error `json:"errors"`
}
type d1Result struct {
Results json.RawMessage `json:"results"`
}
type d1Error struct {
Message string `json:"message"`
}
func queryD1(accountID, apiToken, dbID, sql string) (json.RawMessage, error) {
body, err := json.Marshal(d1Request{SQL: sql})
if err != nil {
return nil, err
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database/%s/query", accountID, dbID)
req, err := http.NewRequest("POST", url, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("D1 API returned %d: %s", resp.StatusCode, string(respBody))
}
var d1Resp d1Response
if err := json.Unmarshal(respBody, &d1Resp); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
if len(d1Resp.Errors) > 0 {
return nil, fmt.Errorf("D1 error: %s", d1Resp.Errors[0].Message)
}
if len(d1Resp.Result) == 0 {
return nil, fmt.Errorf("no results")
}
return d1Resp.Result[0].Results, nil
}
func extractCount(raw json.RawMessage) int {
var rows []map[string]any
if err := json.Unmarshal(raw, &rows); err != nil || len(rows) == 0 {
return 0
}
if v, ok := rows[0]["cnt"]; ok {
if n, ok := v.(float64); ok {
return int(n)
}
}
return 0
}
func requireEnv() (accountID, apiToken, databaseID string) {
accountID = os.Getenv("CLOUDFLARE_ACCOUNT_ID")
apiToken = os.Getenv("CLOUDFLARE_API_TOKEN")
databaseID = os.Getenv("D1_DATABASE_ID")
if accountID == "" || apiToken == "" || databaseID == "" {
fmt.Fprintln(os.Stderr, "Required env vars: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, D1_DATABASE_ID")
os.Exit(1)
}
return
}
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"unicode"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// State for the comments tab.
type commentsState int
const (
commentsIdle commentsState = iota
commentsFetching
commentsLoaded
)
type comment struct {
RespondentID string
Answer string
AnsweredAt string
}
// Messages.
type commentsFetchedMsg struct {
Comments []comment
Err error
}
// fetchComments fetches all q19 comments from D1.
func (m model) fetchComments() tea.Msg {
rows, err := queryD1(m.accountID, m.apiToken, m.databaseID, `
SELECT respondent_id, answer, answered_at
FROM answers
WHERE question_id = 'q19' AND answer != ''
ORDER BY answered_at DESC`)
if err != nil {
return commentsFetchedMsg{Err: fmt.Errorf("fetch: %w", err)}
}
var raw []struct {
RespondentID string `json:"respondent_id"`
Answer string `json:"answer"`
AnsweredAt string `json:"answered_at"`
}
if err := json.Unmarshal(rows, &raw); err != nil {
return commentsFetchedMsg{Err: fmt.Errorf("parse: %w", err)}
}
comments := make([]comment, len(raw))
for i, r := range raw {
comments[i] = comment{
RespondentID: r.RespondentID,
Answer: r.Answer,
AnsweredAt: r.AnsweredAt,
}
}
return commentsFetchedMsg{Comments: comments}
}
// filteredComments returns comments matching the current search query.
func (m model) filteredComments() []comment {
if m.commentsSearch == "" {
return m.comments
}
q := strings.ToLower(m.commentsSearch)
var out []comment
for _, c := range m.comments {
if strings.Contains(strings.ToLower(c.Answer), q) {
out = append(out, c)
}
}
return out
}
// wordFrequency returns top N words (3+ chars, lowercased) across comments.
func wordFrequency(comments []comment, topN int) []wordCount {
freq := map[string]int{}
for _, c := range comments {
seen := map[string]bool{} // count each word once per comment
for _, word := range strings.FieldsFunc(c.Answer, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
w := strings.ToLower(word)
if len(w) < 3 || stopWords[w] {
continue
}
if !seen[w] {
seen[w] = true
freq[w]++
}
}
}
var wcs []wordCount
for w, c := range freq {
wcs = append(wcs, wordCount{Word: w, Count: c})
}
sort.Slice(wcs, func(i, j int) bool { return wcs[i].Count > wcs[j].Count })
if len(wcs) > topN {
wcs = wcs[:topN]
}
return wcs
}
type wordCount struct {
Word string
Count int
}
// avgCommentLength returns average word count across comments.
func avgCommentLength(comments []comment) float64 {
if len(comments) == 0 {
return 0
}
total := 0
for _, c := range comments {
total += len(strings.Fields(c.Answer))
}
return float64(total) / float64(len(comments))
}
var commentBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444444"))
var commentIDStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4"))
var commentDateStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
var searchStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFD700")).
Bold(true)
var matchHighlightStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFD700")).
Bold(true)
func (m model) renderCommentsTab(lines *[]string) {
add := func(s string) { *lines = append(*lines, s) }
add(""); add(sectionTitleStyle.Render("Comments (q19)"))
add("")
switch m.commentsState {
case commentsIdle:
add(dimStyle.Render(" Press 'f' to fetch all comments"))
case commentsFetching:
add(dimStyle.Render(" Fetching comments from D1..."))
case commentsLoaded:
if m.commentsErr != nil {
add(errorStyle.Render(fmt.Sprintf(" Error: %v", m.commentsErr)))
add("")
}
filtered := m.filteredComments()
// Stats bar.
statsLine := fmt.Sprintf(" %s comments",
statValueStyle.Render(fmt.Sprintf("%d", len(m.comments))))
if m.commentsSearch != "" {
statsLine += fmt.Sprintf(" %s matching %s",
emailValueStyle.Render(fmt.Sprintf("%d", len(filtered))),
searchStyle.Render(fmt.Sprintf("\"%s\"", m.commentsSearch)))
}
avgLen := avgCommentLength(m.comments)
statsLine += fmt.Sprintf(" avg %s words",
dimStyle.Render(fmt.Sprintf("%.0f", avgLen)))
add(statsLine)
add("")
// Top words.
topWords := wordFrequency(filtered, 12)
if len(topWords) > 0 {
var wordParts []string
for _, wc := range topWords {
wordParts = append(wordParts,
fmt.Sprintf("%s%s",
distCountStyle.Render(fmt.Sprintf("%d", wc.Count)),
dimStyle.Render("×"+wc.Word)))
}
add(" " + strings.Join(wordParts, " "))
add("")
}
// Search prompt.
if m.searchMode {
add(searchStyle.Render(fmt.Sprintf(" / %s▍", m.commentsSearch)))
} else if m.commentsSearch != "" {
add(dimStyle.Render(fmt.Sprintf(" filter: \"%s\" (/ to edit, Esc to clear)", m.commentsSearch)))
} else {
add(dimStyle.Render(" / to search · f to refresh"))
}
add("")
// Separator.
contentWidth := m.width - sidebarWidth - 2
if contentWidth < 40 {
contentWidth = 40
}
add(commentBorderStyle.Render(" " + strings.Repeat("─", contentWidth-4)))
add("")
// Comments list.
if len(filtered) == 0 {
add(dimStyle.Render(" No comments match your search."))
}
for i, c := range filtered {
id := c.RespondentID
if len(id) > 8 {
id = id[:8]
}
// Header line: #N respondent:xxxxxxxx 2025-03-20T...
header := fmt.Sprintf(" %s %s %s",
commentIDStyle.Render(fmt.Sprintf("#%d", i+1)),
dimStyle.Render(id),
commentDateStyle.Render(c.AnsweredAt))
add(header)
// Comment body, wrapped and indented.
wrapWidth := contentWidth - 4 // 2 indent + 2 margin
if wrapWidth < 20 {
wrapWidth = 20
}
for _, paragraph := range strings.Split(c.Answer, "\n") {
for _, wrapped := range wrapLine(paragraph, wrapWidth) {
if m.commentsSearch != "" {
wrapped = highlightMatches(wrapped, m.commentsSearch)
}
add(" " + wrapped)
}
}
add("")
}
}
}
// wrapLine breaks a line into multiple lines at word boundaries.
func wrapLine(s string, width int) []string {
if width <= 0 || len(s) <= width {
return []string{s}
}
words := strings.Fields(s)
if len(words) == 0 {
return []string{""}
}
var lines []string
cur := words[0]
for _, w := range words[1:] {
if len(cur)+1+len(w) > width {
lines = append(lines, cur)
cur = w
} else {
cur += " " + w
}
}
lines = append(lines, cur)
return lines
}
// highlightMatches wraps matching substrings with ANSI bold+yellow.
func highlightMatches(text, query string) string {
if query == "" {
return text
}
lower := strings.ToLower(text)
q := strings.ToLower(query)
var result strings.Builder
pos := 0
for {
idx := strings.Index(lower[pos:], q)
if idx < 0 {
result.WriteString(text[pos:])
break
}
result.WriteString(text[pos : pos+idx])
matchEnd := pos + idx + len(query)
result.WriteString(matchHighlightStyle.Render(text[pos+idx : matchEnd]))
pos = matchEnd
}
return result.String()
}
var stopWords = map[string]bool{
"the": true, "and": true, "for": true, "that": true, "this": true,
"with": true, "you": true, "are": true, "but": true, "not": true,
"have": true, "from": true, "would": true, "will": true, "can": true,
"been": true, "has": true, "was": true, "were": true, "they": true,
"their": true, "there": true, "what": true, "when": true, "which": true,
"who": true, "how": true, "all": true, "each": true, "your": true,
"than": true, "also": true, "into": true, "could": true, "about": true,
"just": true, "like": true, "very": true, "some": true, "any": true,
"only": true, "other": true, "more": true, "much": true, "most": true,
"own": true, "too": true, "don": true, "does": true, "did": true,
"should": true, "its": true, "it's": true, "i'm": true, "i'd": true,
}
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"fmt"
"strings"
)
// Midpoint estimates for range-based questions.
var librarySizeMidpoints = map[string]float64{
"Less than 100GB": 50,
"100GB - 250GB": 175,
"250GB - 500GB": 375,
"500GB - 1TB": 750,
"1 - 5TB": 3000,
"5 - 10TB": 7500,
"10 - 50TB": 30000,
"50TB+": 75000,
}
var monthlyDataMidpoints = map[string]float64{
"Less than 1GB": 0.5,
"1 - 10GB": 5,
"10 - 50GB": 30,
"50 - 100GB": 75,
"100 - 500GB": 300,
"Over 500GB": 750,
}
var noBackupAnswers = map[string]bool{
"I don't currently have a backup": true,
"RAID (but no offsite/separate copy)": true,
}
func (m model) renderExecSummary(lines *[]string) {
add := func(s string) { *lines = append(*lines, s) }
s := m.stats
add(""); add(sectionTitleStyle.Render("Executive Summary — FUTO Backups Survey"))
add("")
// --- Respondent overview ---
add(""); add(sectionTitleStyle.Render("Respondents"))
completionPct := float64(0)
if s.TotalRespondents > 0 {
completionPct = float64(s.CompletedRespondents) / float64(s.TotalRespondents) * 100
}
add(fmt.Sprintf(" %s total respondents, %s completed (%.0f%%)",
statValueStyle.Render(fmt.Sprintf("%d", s.TotalRespondents)),
completedValueStyle.Render(fmt.Sprintf("%d", s.CompletedRespondents)),
completionPct))
add(""); add(sectionTitleStyle.Render("Email Signups (q17)"))
add(fmt.Sprintf(" %s unique emails collected", emailValueStyle.Render(fmt.Sprintf("%d", s.UniqueEmails))))
totalBetaInterest := s.BetaSignups + s.BothSignups
totalUpdatesInterest := s.ProductUpdateSignups + s.BothSignups
add(fmt.Sprintf(" %s want to join the closed beta (%d beta-only + %d both)",
emailValueStyle.Render(fmt.Sprintf("%d", totalBetaInterest)),
s.BetaSignups, s.BothSignups))
add(fmt.Sprintf(" %s want product updates (%d updates-only + %d both)",
emailValueStyle.Render(fmt.Sprintf("%d", totalUpdatesInterest)),
s.ProductUpdateSignups, s.BothSignups))
if s.CompletedRespondents > 0 {
signupRate := float64(s.UniqueEmails) / float64(s.CompletedRespondents) * 100
add(fmt.Sprintf(" Signup rate: %s of completed respondents left an email",
statValueStyle.Render(fmt.Sprintf("%.1f%%", signupRate))))
}
// --- Library size ---
add(""); add(sectionTitleStyle.Render("Library Size (q4)"))
if vs, ok := s.Distributions["q4"]; ok && len(vs) > 0 {
total := 0
weightedSum := float64(0)
totalStorageTB := float64(0)
for _, v := range vs {
total += v.Count
if mid, ok := librarySizeMidpoints[v.Value]; ok {
weightedSum += mid * float64(v.Count)
totalStorageTB += (mid / 1000) * float64(v.Count)
}
}
if total > 0 {
mean := weightedSum / float64(total)
if mean >= 1000 {
add(fmt.Sprintf(" Estimated mean library: %s",
statValueStyle.Render(fmt.Sprintf("%.1f TB", mean/1000))))
} else {
add(fmt.Sprintf(" Estimated mean library: %s",
statValueStyle.Render(fmt.Sprintf("%.0f GB", mean))))
}
add(fmt.Sprintf(" Estimated total storage across all respondents: %s",
statValueStyle.Render(fmt.Sprintf("%.0f TB", totalStorageTB))))
// Median bucket
cumulative := 0
for _, v := range vs {
cumulative += v.Count
if cumulative >= total/2 {
add(fmt.Sprintf(" Median bucket: %s", statValueStyle.Render(v.Value)))
break
}
}
}
// Size distribution inline
add("")
for _, v := range vs {
pct := float64(v.Count) / float64(total) * 100
add(fmt.Sprintf(" %s %s %s",
distCountStyle.Render(fmt.Sprintf("%5d", v.Count)),
pctStyle.Render(fmt.Sprintf("%5.1f%%", pct)),
distLabelStyle.Render(v.Value)))
}
}
// --- Backup status (RAID = no backup) ---
add(""); add(sectionTitleStyle.Render("Backup Status (q7) — RAID counted as no backup"))
if vs, ok := s.Distributions["q7"]; ok && len(vs) > 0 {
total := 0
noBackup := 0
for _, v := range vs {
total += v.Count
if noBackupAnswers[v.Value] {
noBackup += v.Count
}
}
if total > 0 {
noBackupPct := float64(noBackup) / float64(total) * 100
hasBackupPct := 100 - noBackupPct
add(fmt.Sprintf(" %s of respondents have %s",
funnelDropStyle.Render(fmt.Sprintf("%.0f%%", noBackupPct)),
funnelDropStyle.Render(fmt.Sprintf("NO real backup (%d)", noBackup))))
add(fmt.Sprintf(" %s of respondents have %s",
statValueStyle.Render(fmt.Sprintf("%.0f%%", hasBackupPct)),
statValueStyle.Render(fmt.Sprintf("a real backup (%d)", total-noBackup))))
}
add("")
for _, v := range vs {
pct := float64(v.Count) / float64(total) * 100
label := v.Value
marker := " "
if noBackupAnswers[v.Value] {
marker = funnelDropStyle.Render("⚠ ")
}
add(fmt.Sprintf(" %s%s %s %s",
marker,
distCountStyle.Render(fmt.Sprintf("%5d", v.Count)),
pctStyle.Render(fmt.Sprintf("%5.1f%%", pct)),
distLabelStyle.Render(label)))
}
}
// --- Monthly new data ---
add(""); add(sectionTitleStyle.Render("Monthly New Data (q9)"))
if vs, ok := s.Distributions["q9"]; ok && len(vs) > 0 {
total := 0
weightedSum := float64(0)
for _, v := range vs {
total += v.Count
if mid, ok := monthlyDataMidpoints[v.Value]; ok {
weightedSum += mid * float64(v.Count)
}
}
if total > 0 {
mean := weightedSum / float64(total)
add(fmt.Sprintf(" Estimated mean monthly ingestion: %s",
statValueStyle.Render(fmt.Sprintf("%.1f GB", mean))))
}
add("")
for _, v := range vs {
pct := float64(v.Count) / float64(total) * 100
add(fmt.Sprintf(" %s %s %s",
distCountStyle.Render(fmt.Sprintf("%5d", v.Count)),
pctStyle.Render(fmt.Sprintf("%5.1f%%", pct)),
distLabelStyle.Render(v.Value)))
}
}
// --- Interest level ---
add(""); add(sectionTitleStyle.Render("Interest Level (q10)"))
if vs, ok := s.Distributions["q10"]; ok && len(vs) > 0 {
total := 0
interested := 0
for _, v := range vs {
total += v.Count
if v.Value == "Very interested" || v.Value == "Interested" {
interested += v.Count
}
}
if total > 0 {
pct := float64(interested) / float64(total) * 100
add(fmt.Sprintf(" %s interested or very interested",
statValueStyle.Render(fmt.Sprintf("%.0f%% (%d/%d)", pct, interested, total))))
}
add("")
for _, v := range vs {
pct := float64(v.Count) / float64(total) * 100
add(fmt.Sprintf(" %s %s %s",
distCountStyle.Render(fmt.Sprintf("%5d", v.Count)),
pctStyle.Render(fmt.Sprintf("%5.1f%%", pct)),
distLabelStyle.Render(v.Value)))
}
}
// --- Willingness to pay ---
add(""); add(sectionTitleStyle.Render("Willingness to Pay (q11)"))
if vs, ok := s.Distributions["q11"]; ok && len(vs) > 0 {
total := 0
wouldPay := 0
for _, v := range vs {
total += v.Count
if !strings.Contains(v.Value, "wouldn't pay") {
wouldPay += v.Count
}
}
if total > 0 {
payPct := float64(wouldPay) / float64(total) * 100
add(fmt.Sprintf(" %s willing to pay something",
statValueStyle.Render(fmt.Sprintf("%.0f%% (%d/%d)", payPct, wouldPay, total))))
}
add("")
for _, v := range vs {
pct := float64(v.Count) / float64(total) * 100
add(fmt.Sprintf(" %s %s %s",
distCountStyle.Render(fmt.Sprintf("%5d", v.Count)),
pctStyle.Render(fmt.Sprintf("%5.1f%%", pct)),
distLabelStyle.Render(v.Value)))
}
}
// --- TAM estimate ---
add(""); add(sectionTitleStyle.Render("Addressable Market Estimate"))
q10vs, haveQ10 := s.Distributions["q10"]
q11vs, haveQ11 := s.Distributions["q11"]
q4vs, haveQ4 := s.Distributions["q4"]
if haveQ10 && haveQ11 && haveQ4 {
q10total := 0
interested := 0
for _, v := range q10vs {
q10total += v.Count
if v.Value == "Very interested" || v.Value == "Interested" {
interested += v.Count
}
}
q11total := 0
wouldPay := 0
for _, v := range q11vs {
q11total += v.Count
if !strings.Contains(v.Value, "wouldn't pay") {
wouldPay += v.Count
}
}
q4total := 0
weightedStorage := float64(0)
for _, v := range q4vs {
q4total += v.Count
if mid, ok := librarySizeMidpoints[v.Value]; ok {
weightedStorage += mid * float64(v.Count)
}
}
if q10total > 0 && q11total > 0 && q4total > 0 {
interestedRate := float64(interested) / float64(q10total)
wouldPayRate := float64(wouldPay) / float64(q11total)
conversionEst := interestedRate * wouldPayRate
meanLibGB := weightedStorage / float64(q4total)
meanLibTB := meanLibGB / 1000
potentialCustomers := conversionEst * float64(s.CompletedRespondents)
potentialTB := potentialCustomers * meanLibTB
add(fmt.Sprintf(" Interest rate × willingness to pay: %s",
statValueStyle.Render(fmt.Sprintf("%.0f%% × %.0f%% = %.0f%% potential conversion",
interestedRate*100, wouldPayRate*100, conversionEst*100))))
add(fmt.Sprintf(" Among %d completed respondents: ~%s potential customers",
s.CompletedRespondents,
statValueStyle.Render(fmt.Sprintf("%.0f", potentialCustomers))))
add(fmt.Sprintf(" At mean library %.1f TB: ~%s potential TB to store",
meanLibTB,
statValueStyle.Render(fmt.Sprintf("%.0f TB", potentialTB))))
add(fmt.Sprintf(" At $4/TB/mo: ~%s/mo potential revenue (survey respondents only)",
completedValueStyle.Render(fmt.Sprintf("$%.0f", potentialTB*4))))
add("")
add(dimStyle.Render(" ⚠ Survey respondents are a self-selected sample; real conversion will differ"))
}
}
// --- Key qualitative findings ---
add(""); add(sectionTitleStyle.Render("Key Findings from Free-Text Comments (q19)"))
add(fmt.Sprintf(" %s respondents left a comment", statValueStyle.Render(fmt.Sprintf("%d", s.UniqueCommenters))))
add("")
add(distLabelStyle.Render(" Top demand: Self-hostable backup / BYOS (S3, SFTP, friend's NAS)"))
add(distLabelStyle.Render(" #2 demand: E2E encryption with user-held keys (BYOK)"))
add(distLabelStyle.Render(" #3 demand: EU / non-US datacenter (hard dealbreaker for many)"))
add(distLabelStyle.Render(" Organic idea: Buddy backup (mutual backup between friends' instances)"))
add("")
add(distLabelStyle.Render(" Pricing ref: Backblaze B2 (~$6/TB) is the universal benchmark"))
add(distLabelStyle.Render(" Concern: RAID-only users don't realize they have no backup"))
add(distLabelStyle.Render(" Concern: Conflict-of-interest / enshittification fear"))
add(distLabelStyle.Render(" Concern: Immich-only scope too narrow (users back up whole homelab)"))
add(distLabelStyle.Render(" Sentiment: Overwhelmingly positive about Immich, cautious about managed cloud"))
}
+28
View File
@@ -0,0 +1,28 @@
module github.com/immich-app/static-pages/tools/survey-tui
go 1.24.2
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+43
View File
@@ -0,0 +1,43 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+520
View File
@@ -0,0 +1,520 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Stats
type surveyStats struct {
TotalRespondents int
CompletedRespondents int
InProgress int
TotalAnswers int
UniqueEmails int
BetaSignups int
ProductUpdateSignups int
BothSignups int
UniqueCommenters int
AvgAnswers float64
RecentRespondents1h int
RecentRespondents24h int
PerQuestion map[string]int // question_id -> count
Distributions map[string][]valueStat // question_id -> value distribution
FetchedAt time.Time
Err error
}
type valueStat struct {
Value string
Count int
}
// Model
type model struct {
stats surveyStats
accountID string
apiToken string
databaseID string
page int
scroll int
width int
height int
quitting bool
// Comments tab state.
commentsState commentsState
comments []comment
commentsErr error
commentsSearch string
searchMode bool
}
type statsMsg surveyStats
func (m model) fetchStats() tea.Msg {
stats := surveyStats{
FetchedAt: time.Now(),
PerQuestion: make(map[string]int),
Distributions: make(map[string][]valueStat),
}
type qj struct {
name string
sql string
}
queries := []qj{
{"respondents", `
SELECT
COUNT(*) as total,
SUM(CASE WHEN is_complete = 1 THEN 1 ELSE 0 END) as completed
FROM respondents`},
{"recent_1h", `
SELECT COUNT(*) as cnt FROM respondents
WHERE created_at > datetime('now', '-1 hour')`},
{"recent_24h", `
SELECT COUNT(*) as cnt FROM respondents
WHERE created_at > datetime('now', '-1 day')`},
{"total_answers", `SELECT COUNT(*) as cnt FROM answers`},
{"unique_emails", `
SELECT COUNT(DISTINCT answer) as cnt FROM answers
WHERE question_id = 'q17' AND answer != ''`},
{"email_signups", `
SELECT other_text, COUNT(*) as cnt FROM answers
WHERE question_id = 'q17' AND answer != '' AND other_text IS NOT NULL
GROUP BY other_text`},
{"unique_commenters", `
SELECT COUNT(DISTINCT respondent_id) as cnt FROM answers
WHERE question_id = 'q19' AND answer != ''`},
{"avg_answers", `
SELECT AVG(cnt) as avg_cnt FROM (
SELECT COUNT(*) as cnt FROM answers GROUP BY respondent_id
)`},
{"per_question", `
SELECT question_id, COUNT(*) as cnt
FROM answers GROUP BY question_id`},
}
// Distributions for every radio question.
for _, s := range sections {
for _, q := range s.Questions {
if q.Type != "radio" {
continue
}
queries = append(queries, qj{
name: "dist_" + q.ID,
sql: fmt.Sprintf(`
SELECT answer, COUNT(*) as cnt
FROM answers WHERE question_id = '%s'
GROUP BY answer ORDER BY cnt DESC`, q.ID),
})
}
}
for _, q := range queries {
rows, err := queryD1(m.accountID, m.apiToken, m.databaseID, q.sql)
if err != nil {
stats.Err = fmt.Errorf("%s: %w", q.name, err)
return statsMsg(stats)
}
switch q.name {
case "respondents":
var r []struct {
Total float64 `json:"total"`
Completed float64 `json:"completed"`
}
json.Unmarshal(rows, &r)
if len(r) > 0 {
stats.TotalRespondents = int(r[0].Total)
stats.CompletedRespondents = int(r[0].Completed)
stats.InProgress = stats.TotalRespondents - stats.CompletedRespondents
}
case "recent_1h":
stats.RecentRespondents1h = extractCount(rows)
case "recent_24h":
stats.RecentRespondents24h = extractCount(rows)
case "total_answers":
stats.TotalAnswers = extractCount(rows)
case "unique_emails":
stats.UniqueEmails = extractCount(rows)
case "email_signups":
var r []struct {
OtherText string `json:"other_text"`
Count float64 `json:"cnt"`
}
json.Unmarshal(rows, &r)
for _, row := range r {
c := int(row.Count)
switch row.OtherText {
case "Join the closed beta":
stats.BetaSignups += c
case "Receive product updates":
stats.ProductUpdateSignups += c
case "Join the closed beta,Receive product updates",
"Receive product updates,Join the closed beta":
stats.BothSignups += c
}
}
case "unique_commenters":
stats.UniqueCommenters = extractCount(rows)
case "avg_answers":
var r []struct {
AvgCnt *float64 `json:"avg_cnt"`
}
json.Unmarshal(rows, &r)
if len(r) > 0 && r[0].AvgCnt != nil {
stats.AvgAnswers = *r[0].AvgCnt
}
case "per_question":
var r []struct {
QuestionID string `json:"question_id"`
Count float64 `json:"cnt"`
}
json.Unmarshal(rows, &r)
for _, row := range r {
stats.PerQuestion[row.QuestionID] = int(row.Count)
}
default:
if qid, ok := strings.CutPrefix(q.name, "dist_"); ok {
var r []struct {
Answer string `json:"answer"`
Count float64 `json:"cnt"`
}
json.Unmarshal(rows, &r)
var vs []valueStat
for _, row := range r {
vs = append(vs, valueStat{Value: row.Answer, Count: int(row.Count)})
}
stats.Distributions[qid] = vs
}
}
}
return statsMsg(stats)
}
func (m model) Init() tea.Cmd {
return m.fetchStats
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Search mode intercepts all keys except ctrl+c.
if m.searchMode {
switch msg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc":
m.searchMode = false
m.commentsSearch = ""
m.scroll = 0
case "enter":
m.searchMode = false
m.scroll = 0
case "backspace":
if len(m.commentsSearch) > 0 {
m.commentsSearch = m.commentsSearch[:len(m.commentsSearch)-1]
m.scroll = 0
}
default:
if len(msg.String()) == 1 {
m.commentsSearch += msg.String()
m.scroll = 0
}
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c", "esc":
m.quitting = true
return m, tea.Quit
case "r":
return m, m.fetchStats
case "tab", "l", "right":
m.page = (m.page + 1) % totalPages()
m.scroll = 0
case "shift+tab", "h", "left":
m.page = (m.page - 1 + totalPages()) % totalPages()
m.scroll = 0
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
case "down", "j":
m.scroll++
case "1":
m.page = 0
m.scroll = 0
case "2":
m.page = 1
m.scroll = 0
case "3":
m.page = 2
m.scroll = 0
case "4":
m.page = 3
m.scroll = 0
case "5":
m.page = 4
m.scroll = 0
case "6":
m.page = 5
m.scroll = 0
case "7":
if totalPages() > 6 {
m.page = 6
m.scroll = 0
}
case "8":
if totalPages() > 7 {
m.page = 7
m.scroll = 0
}
case "9":
if totalPages() > 8 {
m.page = 8
m.scroll = 0
}
case "f":
if m.page == pageCommentsAI() && m.commentsState != commentsFetching {
m.commentsState = commentsFetching
m.commentsErr = nil
m.comments = nil
return m, m.fetchComments
}
case "/":
if m.page == pageCommentsAI() && m.commentsState == commentsLoaded {
m.searchMode = true
return m, nil
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case statsMsg:
m.stats = surveyStats(msg)
case commentsFetchedMsg:
if msg.Err != nil {
m.commentsState = commentsLoaded
m.commentsErr = msg.Err
return m, nil
}
m.commentsState = commentsLoaded
m.comments = msg.Comments
}
return m, nil
}
// padRight pads s with spaces to width, accounting for ANSI escape codes.
func padRight(s string, width int) string {
visible := lipgloss.Width(s)
if visible >= width {
return s
}
return s + strings.Repeat(" ", width-visible)
}
func (m model) View() string {
if m.quitting {
return ""
}
viewHeight := m.height
if viewHeight <= 0 {
viewHeight = 50
}
// --- Build sidebar lines ---
var sidebarLines []string
sidebarLines = append(sidebarLines, titleStyle.Render("FUTO Survey"))
sidebarLines = append(sidebarLines, "")
for i := 0; i < totalPages(); i++ {
label := fmt.Sprintf(" %d %s", i+1, pageName(i))
if len(label) > sidebarWidth-1 {
label = label[:sidebarWidth-2] + "…"
}
if i == m.page {
sidebarLines = append(sidebarLines, sidebarActiveStyle.Render(padRight(label, sidebarWidth-1)))
} else {
sidebarLines = append(sidebarLines, sidebarItemStyle.Render(label))
}
}
// Pad sidebar to fill height.
for len(sidebarLines) < viewHeight {
sidebarLines = append(sidebarLines, "")
}
// --- Build content lines ---
var contentLines []string
if m.stats.Err != nil {
contentLines = append(contentLines, errorStyle.Render(fmt.Sprintf("Error: %v", m.stats.Err)))
contentLines = append(contentLines, "")
}
switch m.page {
case pageOverview:
m.renderOverview(&contentLines)
case pageFunnel:
m.renderFunnel(&contentLines)
default:
if m.page == pageExecSummary() {
m.renderExecSummary(&contentLines)
} else if m.page == pageCommentsAI() {
m.renderCommentsTab(&contentLines)
} else {
sectionIdx := m.page - 2
if sectionIdx >= 0 && sectionIdx < len(sections) {
m.renderSection(&contentLines, sections[sectionIdx])
}
}
}
// Apply scroll to content.
maxScroll := len(contentLines) - viewHeight
if maxScroll < 0 {
maxScroll = 0
}
if m.scroll > maxScroll {
m.scroll = maxScroll
}
if m.scroll < 0 {
m.scroll = 0
}
end := m.scroll + viewHeight
if end > len(contentLines) {
end = len(contentLines)
}
visible := contentLines[m.scroll:end]
// Pad content to fill height, with footer on last line.
for len(visible) < viewHeight-1 {
visible = append(visible, "")
}
footer := ""
if !m.stats.FetchedAt.IsZero() {
footer = dimStyle.Render(fmt.Sprintf("Updated %s · r=refresh ←/→=page ↑/↓=scroll 1-9=jump q=quit", m.stats.FetchedAt.Format("15:04:05")))
} else {
footer = dimStyle.Render("Loading... · r=refresh ←/→=page ↑/↓=scroll 1-9=jump q=quit")
}
visible = append(visible, footer)
// --- Compose: join sidebar and content line-by-line ---
border := sidebarBorder.Render("│")
var output strings.Builder
for i := 0; i < viewHeight; i++ {
sl := ""
if i < len(sidebarLines) {
sl = sidebarLines[i]
}
cl := ""
if i < len(visible) {
cl = visible[i]
}
output.WriteString(padRight(sl, sidebarWidth))
output.WriteString(border)
output.WriteString(" ")
output.WriteString(cl)
if i < viewHeight-1 {
output.WriteString("\n")
}
}
return output.String()
}
func runComments() {
accountID, apiToken, databaseID := requireEnv()
// Fetch q19 answers and all "other" free-text from any question.
commentRows, err := queryD1(accountID, apiToken, databaseID, `
SELECT respondent_id, answer, answered_at
FROM answers
WHERE question_id = 'q19' AND answer != ''
ORDER BY answered_at DESC`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching comments: %v\n", err)
os.Exit(1)
}
otherRows, err := queryD1(accountID, apiToken, databaseID, `
SELECT respondent_id, question_id, other_text, answered_at
FROM answers
WHERE other_text IS NOT NULL AND other_text != ''
ORDER BY question_id, answered_at DESC`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching other-text: %v\n", err)
os.Exit(1)
}
var comments []struct {
RespondentID string `json:"respondent_id"`
Answer string `json:"answer"`
AnsweredAt string `json:"answered_at"`
}
json.Unmarshal(commentRows, &comments)
var others []struct {
RespondentID string `json:"respondent_id"`
QuestionID string `json:"question_id"`
OtherText string `json:"other_text"`
AnsweredAt string `json:"answered_at"`
}
json.Unmarshal(otherRows, &others)
fmt.Printf("=== Free-text comments (q19): %d responses ===\n\n", len(comments))
for i, c := range comments {
fmt.Printf("--- #%d [%s] respondent:%s ---\n%s\n\n", i+1, c.AnsweredAt, c.RespondentID[:8], c.Answer)
}
fmt.Printf("=== \"Other\" free-text responses: %d entries ===\n\n", len(others))
curQ := ""
for _, o := range others {
if o.QuestionID != curQ {
curQ = o.QuestionID
label := questionLabelMap[curQ]
if label == "" {
label = curQ
}
fmt.Printf("--- %s: %s ---\n", curQ, label)
}
fmt.Printf(" [%s] %s\n", o.AnsweredAt, o.OtherText)
}
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "comments" {
runComments()
return
}
accountID, apiToken, databaseID := requireEnv()
m := model{
accountID: accountID,
apiToken: apiToken,
databaseID: databaseID,
}
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"fmt"
"strings"
)
func (m model) renderOverview(lines *[]string) {
add := func(s string) { *lines = append(*lines, s) }
s := m.stats
add(""); add(sectionTitleStyle.Render("Respondents"))
add(statLabelStyle.Render("Total respondents") + statValueStyle.Render(fmt.Sprintf("%d", s.TotalRespondents)))
add(statLabelStyle.Render("Completed surveys") + completedValueStyle.Render(fmt.Sprintf("%d", s.CompletedRespondents)))
add(statLabelStyle.Render("In progress") + statValueStyle.Render(fmt.Sprintf("%d", s.InProgress)))
if s.TotalRespondents > 0 {
pct := float64(s.CompletedRespondents) / float64(s.TotalRespondents) * 100
add(statLabelStyle.Render("Completion rate") + completedValueStyle.Render(fmt.Sprintf("%.1f%%", pct)))
}
add(""); add(sectionTitleStyle.Render("Engagement"))
add(statLabelStyle.Render("Total answers recorded") + statValueStyle.Render(fmt.Sprintf("%d", s.TotalAnswers)))
if s.AvgAnswers > 0 {
add(statLabelStyle.Render("Avg answers/respondent") + statValueStyle.Render(fmt.Sprintf("%.1f / 18", s.AvgAnswers)))
}
add(statLabelStyle.Render("Unique email signups") + emailValueStyle.Render(fmt.Sprintf("%d", s.UniqueEmails)))
add(statLabelStyle.Render(" Beta only") + emailValueStyle.Render(fmt.Sprintf("%d", s.BetaSignups)))
add(statLabelStyle.Render(" Updates only") + emailValueStyle.Render(fmt.Sprintf("%d", s.ProductUpdateSignups)))
add(statLabelStyle.Render(" Both") + emailValueStyle.Render(fmt.Sprintf("%d", s.BothSignups)))
add(statLabelStyle.Render("Left a comment (q19)") + statValueStyle.Render(fmt.Sprintf("%d", s.UniqueCommenters)))
add(""); add(sectionTitleStyle.Render("Recent Activity"))
add(statLabelStyle.Render("New respondents (1h)") + statValueStyle.Render(fmt.Sprintf("%d", s.RecentRespondents1h)))
add(statLabelStyle.Render("New respondents (24h)") + statValueStyle.Render(fmt.Sprintf("%d", s.RecentRespondents24h)))
// Quick highlights from key distributions
add(""); add(sectionTitleStyle.Render("Quick Highlights"))
if vs, ok := s.Distributions["q10"]; ok && len(vs) > 0 {
interested := 0
total := 0
for _, v := range vs {
total += v.Count
if v.Value == "Very interested" || v.Value == "Interested" {
interested += v.Count
}
}
if total > 0 {
pct := float64(interested) / float64(total) * 100
add(statLabelStyle.Render("Interest (q10)") + statValueStyle.Render(fmt.Sprintf("%.0f%% interested or very interested", pct)))
}
}
if vs, ok := s.Distributions["q7"]; ok && len(vs) > 0 {
for _, v := range vs {
if strings.Contains(v.Value, "don't currently") {
total := 0
for _, vv := range vs {
total += vv.Count
}
if total > 0 {
pct := float64(v.Count) / float64(total) * 100
add(statLabelStyle.Render("No backup (q7)") + funnelDropStyle.Render(fmt.Sprintf("%.0f%% have no backup", pct)))
}
break
}
}
}
if vs, ok := s.Distributions["q11"]; ok && len(vs) > 0 {
wouldntPay := 0
total := 0
for _, v := range vs {
total += v.Count
if strings.Contains(v.Value, "wouldn't pay") {
wouldntPay += v.Count
}
}
if total > 0 {
pct := float64(total-wouldntPay) / float64(total) * 100
add(statLabelStyle.Render("Would pay (q11)") + statValueStyle.Render(fmt.Sprintf("%.0f%% willing to pay something", pct)))
}
}
}
func (m model) renderFunnel(lines *[]string) {
add := func(s string) { *lines = append(*lines, s) }
s := m.stats
add(""); add(sectionTitleStyle.Render("Response Funnel"))
add(dimStyle.Render("Shows drop-off from q1 → completion"))
add("")
if len(s.PerQuestion) == 0 {
add(dimStyle.Render("No data yet"))
return
}
// Get max for bar scaling.
maxCount := 0
for _, qid := range allQuestionIDs {
if c, ok := s.PerQuestion[qid]; ok && c > maxCount {
maxCount = c
}
}
barMax := 40
if m.width > 0 && m.width-46 < barMax {
barMax = m.width - 46
}
if barMax < 5 {
barMax = 5
}
prevCount := 0
for i, qid := range allQuestionIDs {
count := s.PerQuestion[qid]
label := questionLabelMap[qid]
barLen := 0
if maxCount > 0 {
barLen = count * barMax / maxCount
}
if barLen == 0 && count > 0 {
barLen = 1
}
bar := barStyle.Render(strings.Repeat("█", barLen))
dropStr := ""
if i > 0 && prevCount > 0 {
drop := prevCount - count
if drop > 0 {
dropPct := float64(drop) / float64(prevCount) * 100
dropStr = funnelDropStyle.Render(fmt.Sprintf(" ↓%d (%.0f%%)", drop, dropPct))
}
}
id := funnelIDStyle.Render(qid)
lbl := funnelLabelStyle.Render(label)
add(fmt.Sprintf("%s%s %s %d%s", id, lbl, bar, count, dropStr))
prevCount = count
}
add("")
add(""); add(sectionTitleStyle.Render("Completion"))
if maxCount > 0 {
completedBarLen := s.CompletedRespondents * barMax / maxCount
if completedBarLen == 0 && s.CompletedRespondents > 0 {
completedBarLen = 1
}
completedBar := completedValueStyle.Render(strings.Repeat("█", completedBarLen))
lbl := funnelLabelStyle.Render("Marked complete")
add(fmt.Sprintf(" %s %s %d", lbl, completedBar, s.CompletedRespondents))
if s.PerQuestion["q1"] > 0 {
overallPct := float64(s.CompletedRespondents) / float64(s.PerQuestion["q1"]) * 100
add(dimStyle.Render(fmt.Sprintf(" %.0f%% of those who started q1 completed the survey", overallPct)))
}
}
}
func (m model) renderSection(lines *[]string, sec sectionMeta) {
add := func(s string) { *lines = append(*lines, s) }
st := m.stats
add(""); add(sectionTitleStyle.Render(fmt.Sprintf("Section %d: %s", sec.Number, sec.Title)))
add("")
for _, q := range sec.Questions {
count := st.PerQuestion[q.ID]
header := fmt.Sprintf("%s — %s (%d responses)", q.ID, q.Label, count)
add(""); add(questionTitleStyle.Render(header))
switch q.Type {
case "radio":
m.renderDistribution(lines, q.ID, count)
case "email":
add(fmt.Sprintf(" %s unique email addresses collected",
emailValueStyle.Render(fmt.Sprintf("%d", st.UniqueEmails))))
totalBeta := st.BetaSignups + st.BothSignups
totalUpdates := st.ProductUpdateSignups + st.BothSignups
add(fmt.Sprintf(" %s want the closed beta, %s want product updates, %s want both",
emailValueStyle.Render(fmt.Sprintf("%d", totalBeta)),
emailValueStyle.Render(fmt.Sprintf("%d", totalUpdates)),
emailValueStyle.Render(fmt.Sprintf("%d", st.BothSignups))))
if st.CompletedRespondents > 0 {
pct := float64(st.UniqueEmails) / float64(st.CompletedRespondents) * 100
add(fmt.Sprintf(" %s",
dimStyle.Render(fmt.Sprintf("%.1f%% of completed respondents signed up", pct))))
}
case "textarea":
add(fmt.Sprintf(" %s respondents left a comment",
statValueStyle.Render(fmt.Sprintf("%d", st.UniqueCommenters))))
if st.CompletedRespondents > 0 {
pct := float64(st.UniqueCommenters) / float64(st.CompletedRespondents) * 100
add(fmt.Sprintf(" %s",
dimStyle.Render(fmt.Sprintf("%.1f%% of completed respondents commented", pct))))
}
}
add("")
}
}
func (m model) renderDistribution(lines *[]string, qid string, total int) {
add := func(s string) { *lines = append(*lines, s) }
vs, ok := m.stats.Distributions[qid]
if !ok || len(vs) == 0 {
add(dimStyle.Render(" No responses yet"))
return
}
maxVal := vs[0].Count
barMax := 30
if m.width > 0 && m.width-56 < barMax {
barMax = m.width - 56
}
if barMax < 5 {
barMax = 5
}
maxLabelWidth := 44
if m.width > 0 && m.width-36 < maxLabelWidth {
maxLabelWidth = m.width - 36
}
if maxLabelWidth < 20 {
maxLabelWidth = 20
}
for _, v := range vs {
barLen := 0
if maxVal > 0 {
barLen = v.Count * barMax / maxVal
}
if barLen == 0 && v.Count > 0 {
barLen = 1
}
pct := float64(0)
if total > 0 {
pct = float64(v.Count) / float64(total) * 100
}
truncated := v.Value
if len(truncated) > maxLabelWidth {
truncated = truncated[:maxLabelWidth-1] + "…"
}
bar := distBarStyle.Render(strings.Repeat("█", barLen))
count := distCountStyle.Render(fmt.Sprintf("%5d", v.Count))
pctStr := pctStyle.Render(fmt.Sprintf("%5.1f%%", pct))
add(fmt.Sprintf(" %s %s %s %s", count, pctStr, bar, distLabelStyle.Render(truncated)))
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import "github.com/charmbracelet/lipgloss"
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
statLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Width(26)
statValueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#04B575"))
completedValueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFD700"))
emailValueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FF79C6"))
barStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
distBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#BD93F9"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
dimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#555555"))
sectionTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
questionTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF79C6")).
Bold(true)
distLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#CCCCCC"))
distCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#BD93F9")).
Bold(true)
pctStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
funnelIDStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Width(5)
funnelLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Width(26)
funnelDropStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555"))
)
const sidebarWidth = 26
var (
sidebarBorder = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444444"))
sidebarItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
sidebarActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4"))
)
+92
View File
@@ -0,0 +1,92 @@
package main
import "fmt"
// Survey metadata
type questionMeta struct {
ID string
Label string
Type string // "radio", "email", "textarea"
}
type sectionMeta struct {
Number int
Title string
Questions []questionMeta
}
var sections = []sectionMeta{
{1, "Server, Library & Internet", []questionMeta{
{"q1", "Server location", "radio"},
{"q2", "Download speed", "radio"},
{"q3", "Upload speed", "radio"},
{"q4", "Library size", "radio"},
{"q5", "Video percentage", "radio"},
{"q6", "Self-hosting experience", "radio"},
}},
{2, "Current Backup Solution", []questionMeta{
{"q7", "Current backup method", "radio"},
{"q8", "Backup frequency", "radio"},
{"q9", "Monthly new data", "radio"},
}},
{3, "Interest & Pricing", []questionMeta{
{"q10", "Interest level", "radio"},
{"q11", "Willingness to pay", "radio"},
{"q12", "Backup copies wanted", "radio"},
}},
{4, "Infrastructure", []questionMeta{
{"q13", "Storage type", "radio"},
{"q14", "Drive type", "radio"},
{"q15", "OS / Hypervisor", "radio"},
{"q16", "How running Immich", "radio"},
}},
{5, "Beta & Feedback", []questionMeta{
{"q17", "Email signup", "email"},
{"q19", "Additional comments", "textarea"},
}},
}
var allQuestionIDs []string
var questionLabelMap = map[string]string{}
func init() {
for _, s := range sections {
for _, q := range s.Questions {
allQuestionIDs = append(allQuestionIDs, q.ID)
questionLabelMap[q.ID] = q.Label
}
}
}
// Pages: overview, funnel, section 1..5, executive summary, AI analysis.
const (
pageOverview = 0
pageFunnel = 1
// pages 2..6 = section 1..5
)
func pageExecSummary() int { return 2 + len(sections) }
func pageCommentsAI() int { return 3 + len(sections) }
func totalPages() int { return 4 + len(sections) }
func pageName(p int) string {
switch p {
case pageOverview:
return "Overview"
case pageFunnel:
return "Funnel"
default:
if p == pageExecSummary() {
return "Exec Summary"
}
if p == pageCommentsAI() {
return "Comments"
}
idx := p - 2
if idx >= 0 && idx < len(sections) {
return fmt.Sprintf("S%d: %s", sections[idx].Number, sections[idx].Title)
}
return "?"
}
}