9 Commits

Author SHA1 Message Date
talksik 31b8071350 show loading message on start of program 2025-10-28 17:08:02 -07:00
talksik dadbca4b5f fix: los angeles time for joined at in detail pane 2025-10-28 17:07:52 -07:00
talksik b53f418607 add field for customer whether they are pro or not 2025-10-28 17:05:39 -07:00
talksik 99c0dc5a41 add total revenue in header 2025-10-28 16:50:24 -07:00
talksik 9330fdc4de add getting started script for intel mac 2025-10-28 14:01:10 -07:00
talksik 7fec49ff04 support refreshing data 2025-10-27 16:17:23 -07:00
talksik 9401d5002d add intel build command 2025-10-27 16:07:47 -07:00
talksik b5a55fe9d8 add makefile 2025-10-27 15:38:30 -07:00
talksik 3046436185 fix: scroll left panel not working 2025-10-27 15:38:23 -07:00
3 changed files with 253 additions and 19 deletions
+18
View File
@@ -0,0 +1,18 @@
.PHONY: all
all: test
go run cmd/main.go
.PHONY: generate
generate:
./genproto.sh
test:
go test ./... -v
.PHONY: build
build: test
CGO_ENABLED=0 go build cmd/main.go
build_intel:
GOARCH=amd64 CGO_ENABLED=0 go build -o main_intel cmd/main.go
+11
View File
@@ -7,3 +7,14 @@ The purpose is to have a lightweight TUI which shows the main data relevant for
1. How many signups do we have? What is each of their keypad data? 1. How many signups do we have? What is each of their keypad data?
2. [TODO] How many desks are we are on (a.k.a. weekly active users)? 2. [TODO] How many desks are we are on (a.k.a. weekly active users)?
3. [TODO] What's our revenue to date? 3. [TODO] What's our revenue to date?
## Getting started (as consumer)
1. Download the new program to your desktop.
2. In your Mac Terminal, run the following:
```sh
cd ~/Desktop
chmod +x main_intel
xattr -d com.apple.quarantine main_intel
./main_intel
```
+224 -19
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
"time" "time"
@@ -18,8 +20,170 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"net/http"
) )
type MetricResponse struct {
Metrics []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Value float64 `json:"value"`
Unit string `json:"unit"`
Period string `json:"period"`
} `json:"metrics"`
}
type SubscriptionResponse struct {
Items []struct {
Status string `json:"status"`
GivesAccess bool `json:"gives_access"`
Entitlements struct {
Items []struct {
LookupKey string `json:"lookup_key"`
} `json:"items"`
} `json:"entitlements"`
} `json:"items"`
}
func getRevenue() string {
client := &http.Client{}
req, err := http.NewRequest("GET", "https://api.revenuecat.com/v2/projects/proj5b2f7356/metrics/overview", nil)
if err != nil {
return fmt.Sprintf("Error creating request: %v", err)
}
// Add authorization header
req.Header.Add("Authorization", "Bearer sk_wxmjNCLtnDSUPmSXlbEWyuqkpTVTi")
response, err := client.Do(req)
if err != nil {
return fmt.Sprintf("Error fetching revenue: %v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Sprintf("Error reading response: %v", err)
}
var metricResp MetricResponse
err = json.Unmarshal(body, &metricResp)
if err != nil {
return fmt.Sprintf("Error parsing JSON: %v", err)
}
// Create a map for easy lookup
metrics := make(map[string]struct {
name string
value float64
unit string
})
for _, m := range metricResp.Metrics {
metrics[m.ID] = struct {
name string
value float64
unit string
}{
name: m.Name,
value: m.Value,
unit: m.Unit,
}
}
if m, ok := metrics["revenue"]; ok {
return fmt.Sprintf("%s%.0f", m.unit, m.value)
}
return ""
// Format the output
// var result strings.Builder
// result.WriteString("\n")
// result.WriteString("💰 Revenue Metrics\n")
// result.WriteString("═══════════════════════════════════════\n\n")
// Key metrics
// if m, ok := metrics["mrr"]; ok {
// result.WriteString(fmt.Sprintf("MRR: %s%.0f\n", m.unit, m.value))
// }
// if m, ok := metrics["revenue"]; ok {
// result.WriteString(fmt.Sprintf("Revenue (28d): %s%.0f\n", m.unit, m.value))
// }
// result.WriteString("\n")
//
// if m, ok := metrics["active_subscriptions"]; ok {
// result.WriteString(fmt.Sprintf("Active Subscriptions: %s%.0f\n", m.unit, m.value))
// }
// if m, ok := metrics["active_trials"]; ok {
// result.WriteString(fmt.Sprintf("Active Trials: %s%.0f\n", m.unit, m.value))
// }
// result.WriteString("\n")
//
// if m, ok := metrics["new_customers"]; ok {
// result.WriteString(fmt.Sprintf("New Customers (28d): %s%.0f\n", m.unit, m.value))
// }
// if m, ok := metrics["active_users"]; ok {
// result.WriteString(fmt.Sprintf("Active Users (28d): %s%.0f\n", m.unit, m.value))
// }
//
// result.WriteString("\n═══════════════════════════════════════\n")
//
// return result.String()
}
func hasProSubscription(customerEmail string) bool {
client := &http.Client{}
url := fmt.Sprintf("https://api.revenuecat.com/v2/projects/proj5b2f7356/customers/%s/subscriptions", customerEmail)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
logrus.Errorf("Error creating subscription request for %s: %v", customerEmail, err)
return false
}
// Add authorization header
req.Header.Add("Authorization", "Bearer sk_wxmjNCLtnDSUPmSXlbEWyuqkpTVTi")
response, err := client.Do(req)
if err != nil {
logrus.Errorf("Error fetching subscriptions for %s: %v", customerEmail, err)
return false
}
defer response.Body.Close()
// If customer not found or no subscriptions, return false
if response.StatusCode != 200 {
return false
}
body, err := io.ReadAll(response.Body)
if err != nil {
logrus.Errorf("Error reading subscription response for %s: %v", customerEmail, err)
return false
}
var subResp SubscriptionResponse
err = json.Unmarshal(body, &subResp)
if err != nil {
logrus.Errorf("Error parsing subscription JSON for %s: %v", customerEmail, err)
return false
}
// Check if any subscription is active and has Pro entitlement
for _, sub := range subResp.Items {
if sub.Status == "active" && sub.GivesAccess {
for _, ent := range sub.Entitlements.Items {
if ent.LookupKey == "Pro" {
return true
}
}
}
}
return false
}
var ( var (
titleStyle = lipgloss.NewStyle(). titleStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
@@ -93,6 +257,7 @@ type HumanData struct {
DisplayName string DisplayName string
JoinedAt time.Time JoinedAt time.Time
KeypadConfiguration string KeypadConfiguration string
HasProSubscription bool
} }
type model struct { type model struct {
@@ -102,17 +267,17 @@ type model struct {
viewport viewport.Model viewport viewport.Model
detailViewport viewport.Model detailViewport viewport.Model
ready bool ready bool
showKeypad bool
terminalWidth int terminalWidth int
terminalHeight int terminalHeight int
focusOnDetail bool // true = detail panel has focus, false = list has focus focusOnDetail bool // true = detail panel has focus, false = list has focus
revenue string
} }
func initialModel(humans []*HumanData) model { func initialModel(humans []*HumanData) model {
return model{ return model{
humans: humans, humans: humans,
cursor: 0, cursor: 0,
showKeypad: false, revenue: getRevenue(),
} }
} }
@@ -174,14 +339,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderList()) m.viewport.SetContent(m.renderList())
m.detailViewport.SetContent(m.renderDetails()) m.detailViewport.SetContent(m.renderDetails())
m.detailViewport.GotoTop() // Reset detail scroll position m.detailViewport.GotoTop() // Reset detail scroll position
// Auto-scroll left viewport to keep cursor visible
if m.cursor < m.viewport.YOffset {
m.viewport.YOffset = m.cursor
}
} }
case "r":
m.humans = getHumansData()
m.cursor = 0
m.viewport.SetContent(m.renderList())
m.detailViewport.SetContent(m.renderDetails())
m.detailViewport.GotoTop()
case "down", "j": case "down", "j":
if m.cursor < len(m.humans)-1 { if m.cursor < len(m.humans)-1 {
m.cursor++ m.cursor++
m.viewport.SetContent(m.renderList()) m.viewport.SetContent(m.renderList())
m.detailViewport.SetContent(m.renderDetails()) m.detailViewport.SetContent(m.renderDetails())
m.detailViewport.GotoTop() // Reset detail scroll position m.detailViewport.GotoTop() // Reset detail scroll position
// Auto-scroll left viewport to keep cursor visible
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
m.viewport.YOffset = m.cursor - m.viewport.Height + 1
}
} }
} }
} }
@@ -238,9 +420,28 @@ func (m model) renderDetails() string {
// Joined At // Joined At
b.WriteString(detailLabelStyle.Render("Joined: ")) b.WriteString(detailLabelStyle.Render("Joined: "))
timeAgo := timediff.TimeDiff(human.JoinedAt) timeAgo := timediff.TimeDiff(human.JoinedAt)
joinedFormatted := human.JoinedAt.Format("Jan 2, 2006 at 3:04 PM")
// Convert to Pacific timezone
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
loc = time.UTC // fallback to UTC if there's an error
}
joinedPacific := human.JoinedAt.In(loc)
joinedFormatted := joinedPacific.Format("Jan 2, 2006 at 3:04 PM MST")
b.WriteString(detailValueStyle.Render(fmt.Sprintf("%s (%s)", joinedFormatted, timeAgo)) + "\n\n") b.WriteString(detailValueStyle.Render(fmt.Sprintf("%s (%s)", joinedFormatted, timeAgo)) + "\n\n")
// Pro Subscription Status
b.WriteString(detailLabelStyle.Render("Pro Subscription: "))
if human.HasProSubscription {
proStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF00")).
Bold(true)
b.WriteString(proStyle.Render("✓ Active") + "\n\n")
} else {
b.WriteString(dimItemStyle.Render("✗ None") + "\n\n")
}
// Keypad Configuration // Keypad Configuration
if human.KeypadConfiguration != "" { if human.KeypadConfiguration != "" {
b.WriteString(detailLabelStyle.Render("Keypad Configuration:") + "\n") b.WriteString(detailLabelStyle.Render("Keypad Configuration:") + "\n")
@@ -266,13 +467,13 @@ func (m model) View() string {
pastSevenDaysSignupsCount++ pastSevenDaysSignupsCount++
} }
} }
header := titleStyle.Render(fmt.Sprintf("👥 Humans | %d total | %d in the past 7 days", len(m.humans), pastSevenDaysSignupsCount)) header := titleStyle.Render(fmt.Sprintf("👥 Humans | %d total | %d in the past 7 days | Revenue: %s", len(m.humans), pastSevenDaysSignupsCount, m.revenue))
// Status bar showing current position // Status bar showing current position
statusBar := statusStyle.Render(fmt.Sprintf(" %d/%d", m.cursor+1, len(m.humans))) statusBar := statusStyle.Render(fmt.Sprintf(" %d/%d", m.cursor+1, len(m.humans)))
// Help text // Help text
help := helpStyle.Render("↑/k up • ↓/j down • ctrl+u/d scroll detail • q quit") help := helpStyle.Render("↑/k up • ↓/j down • ctrl+u/d scroll detail • r refresh • q quit")
// Create two-column layout // Create two-column layout
leftPanel := m.viewport.View() leftPanel := m.viewport.View()
@@ -296,8 +497,17 @@ func unaryInterceptor(ctx context.Context, method string, req, reply interface{}
} }
func main() { func main() {
logrus.Infof("hello world") logrus.Infof("Loading customer data...wait a moment")
humansData := getHumansData()
p := tea.NewProgram(initialModel(humansData))
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
func getHumansData() []*HumanData {
heliosAddr := "helios.flowy.live:443" heliosAddr := "helios.flowy.live:443"
creds := credentials.NewTLS(&tls.Config{ creds := credentials.NewTLS(&tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
@@ -322,12 +532,6 @@ func main() {
os.Exit(1) os.Exit(1)
} }
keypadsResponse.GetHumanKeypads()[0].GetHumanId()
keypadsResponse.GetHumanKeypads()[0].GetYamlConfig()
logrus.Info("connected to helios")
logrus.Infof("fetched %d humans", len(humansResponse.GetHumans()))
humansData := []*HumanData{} humansData := []*HumanData{}
for _, human := range humansResponse.GetHumans() { for _, human := range humansResponse.GetHumans() {
keypadConfiguration := "" keypadConfiguration := ""
@@ -336,17 +540,18 @@ func main() {
keypadConfiguration = keypad.GetYamlConfig() keypadConfiguration = keypad.GetYamlConfig()
} }
} }
// Check if customer has pro subscription
hasPro := hasProSubscription(human.GetEmail())
humansData = append(humansData, &HumanData{ humansData = append(humansData, &HumanData{
Email: human.GetEmail(), Email: human.GetEmail(),
DisplayName: human.GetDisplayName(), DisplayName: human.GetDisplayName(),
JoinedAt: human.GetJoinedAt().AsTime(), JoinedAt: human.GetJoinedAt().AsTime(),
KeypadConfiguration: keypadConfiguration, KeypadConfiguration: keypadConfiguration,
HasProSubscription: hasPro,
}) })
} }
p := tea.NewProgram(initialModel(humansData)) return humansData
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
} }