mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: add survey-tui (#410)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
survey-tui
|
||||
comments.md
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
@@ -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 "?"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user