9 Commits
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?
2. [TODO] How many desks are we are on (a.k.a. weekly active users)?
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 (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -18,8 +20,170 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"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 (
titleStyle = lipgloss.NewStyle().
Bold(true).
@@ -93,6 +257,7 @@ type HumanData struct {
DisplayName string
JoinedAt time.Time
KeypadConfiguration string
HasProSubscription bool
}
type model struct {
@@ -102,17 +267,17 @@ type model struct {
viewport viewport.Model
detailViewport viewport.Model
ready bool
showKeypad bool
terminalWidth int
terminalHeight int
focusOnDetail bool // true = detail panel has focus, false = list has focus
revenue string
}
func initialModel(humans []*HumanData) model {
return model{
humans: humans,
cursor: 0,
showKeypad: false,
humans: humans,
cursor: 0,
revenue: getRevenue(),
}
}
@@ -174,14 +339,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderList())
m.detailViewport.SetContent(m.renderDetails())
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":
if m.cursor < len(m.humans)-1 {
m.cursor++
m.viewport.SetContent(m.renderList())
m.detailViewport.SetContent(m.renderDetails())
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
b.WriteString(detailLabelStyle.Render("Joined: "))
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")
// 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
if human.KeypadConfiguration != "" {
b.WriteString(detailLabelStyle.Render("Keypad Configuration:") + "\n")
@@ -266,13 +467,13 @@ func (m model) View() string {
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
statusBar := statusStyle.Render(fmt.Sprintf(" %d/%d", m.cursor+1, len(m.humans)))
// 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
leftPanel := m.viewport.View()
@@ -296,8 +497,17 @@ func unaryInterceptor(ctx context.Context, method string, req, reply interface{}
}
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"
creds := credentials.NewTLS(&tls.Config{
InsecureSkipVerify: true,
@@ -322,12 +532,6 @@ func main() {
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{}
for _, human := range humansResponse.GetHumans() {
keypadConfiguration := ""
@@ -336,17 +540,18 @@ func main() {
keypadConfiguration = keypad.GetYamlConfig()
}
}
// Check if customer has pro subscription
hasPro := hasProSubscription(human.GetEmail())
humansData = append(humansData, &HumanData{
Email: human.GetEmail(),
DisplayName: human.GetDisplayName(),
JoinedAt: human.GetJoinedAt().AsTime(),
KeypadConfiguration: keypadConfiguration,
HasProSubscription: hasPro,
})
}
p := tea.NewProgram(initialModel(humansData))
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
return humansData
}