bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "encoding/csv"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/lipgloss"
13 lgtable "github.com/charmbracelet/lipgloss/table"
14 "github.com/charmbracelet/log"
15 "github.com/stormlightlabs/skypanel/cli/internal/registry"
16 "github.com/stormlightlabs/skypanel/cli/internal/setup"
17 "github.com/stormlightlabs/skypanel/cli/internal/store"
18 "github.com/stormlightlabs/skypanel/cli/internal/ui"
19 "github.com/urfave/cli/v3"
20)
21
22// followerInfo holds enriched follower data for display and export
23type followerInfo struct {
24 Profile *store.ActorProfile
25 LastPostDate time.Time
26 DaysSincePost int
27 IsInactive bool
28 PostsPerDay float64
29 IsQuiet bool
30}
31
32type diffOutput struct {
33 NewFollowers []string `json:"newFollowers"`
34 Unfollows []string `json:"unfollows"`
35 Summary struct {
36 BaselineCount int `json:"baselineCount"`
37 ComparisonCount int `json:"comparisonCount"`
38 NetChange int `json:"netChange"`
39 NewCount int `json:"newCount"`
40 UnfollowCount int `json:"unfollowCount"`
41 } `json:"summary"`
42}
43
44// FollowersCommand returns the followers command with all subcommands
45func FollowersCommand() *cli.Command {
46 return &cli.Command{
47 Name: "followers",
48 Usage: "Manage and analyze followers",
49 Commands: []*cli.Command{
50 {
51 Name: "list",
52 Usage: "List followers for a user",
53 UsageText: "Fetch all followers with optional filters for inactivity, date range, and output format.",
54 ArgsUsage: " ",
55 Flags: []cli.Flag{
56 &cli.StringFlag{
57 Name: "user",
58 Aliases: []string{"u"},
59 Usage: "User handle or DID (defaults to authenticated user)",
60 },
61 &cli.IntFlag{
62 Name: "limit",
63 Aliases: []string{"l"},
64 Usage: "Maximum number of followers to fetch (0 = all)",
65 Value: 0,
66 },
67 &cli.StringFlag{
68 Name: "since",
69 Usage: "Filter followers created after date (YYYY-MM-DD)",
70 },
71 &cli.IntFlag{
72 Name: "inactive",
73 Usage: "Show only followers with no posts in N days",
74 Value: 0,
75 },
76 &cli.BoolFlag{
77 Name: "quiet",
78 Usage: "Show only quiet posters (low posting frequency)",
79 },
80 &cli.FloatFlag{
81 Name: "threshold",
82 Usage: "Posts per day threshold for quiet posters (used with --quiet)",
83 Value: 1.0,
84 },
85 &cli.StringFlag{
86 Name: "output",
87 Aliases: []string{"o"},
88 Usage: "Output format: table, json, csv",
89 Value: "table",
90 },
91 &cli.BoolFlag{
92 Name: "refresh",
93 Usage: "Force refresh cached data (bypasses 24-hour cache)",
94 },
95 },
96 Action: ListFollowersAction,
97 },
98 {
99 Name: "stats",
100 Usage: "Show aggregate follower statistics",
101 UsageText: "Calculate aggregate statistics including active/inactive counts, growth metrics, and optional ASCII chart.",
102 ArgsUsage: " ",
103 Flags: []cli.Flag{
104 &cli.StringFlag{
105 Name: "user",
106 Aliases: []string{"u"},
107 Usage: "User handle or DID (defaults to authenticated user)",
108 },
109 &cli.StringFlag{
110 Name: "since",
111 Usage: "Calculate growth since date (YYYY-MM-DD)",
112 },
113 &cli.IntFlag{
114 Name: "inactive",
115 Usage: "Threshold for inactive status (days)",
116 Value: 60,
117 },
118 &cli.BoolFlag{
119 Name: "chart",
120 Usage: "Display ASCII bar chart",
121 },
122 },
123 Action: FollowersStatsAction,
124 },
125 {
126 Name: "diff",
127 Usage: "Compare follower lists between two dates",
128 UsageText: "Compare follower lists to identify new followers and unfollows. Without --until, compares snapshot to current live data.",
129 ArgsUsage: " ",
130 Flags: []cli.Flag{
131 &cli.StringFlag{
132 Name: "user",
133 Aliases: []string{"u"},
134 Usage: "User handle or DID (defaults to authenticated user)",
135 },
136 &cli.StringFlag{
137 Name: "since",
138 Usage: "Start date (YYYY-MM-DD) or snapshot ID",
139 Required: true,
140 },
141 &cli.StringFlag{
142 Name: "until",
143 Usage: "End date (YYYY-MM-DD) or snapshot ID (omit to compare with live data)",
144 },
145 &cli.StringFlag{
146 Name: "output",
147 Aliases: []string{"o"},
148 Usage: "Output format: table, json, csv",
149 Value: "table",
150 },
151 },
152 Action: FollowersDiffAction,
153 },
154 {
155 Name: "export",
156 Usage: "Export followers to CSV or JSON",
157 UsageText: "Export follower list to CSV or JSON for external analysis, archival, or backup purposes.",
158 ArgsUsage: " ",
159 Flags: []cli.Flag{
160 &cli.StringFlag{
161 Name: "user",
162 Aliases: []string{"u"},
163 Usage: "User handle or DID (defaults to authenticated user)",
164 },
165 &cli.IntFlag{
166 Name: "inactive",
167 Usage: "Export only followers with no posts in N days",
168 Value: 0,
169 },
170 &cli.BoolFlag{
171 Name: "quiet",
172 Usage: "Export only quiet posters (low posting frequency)",
173 },
174 &cli.FloatFlag{
175 Name: "threshold",
176 Usage: "Posts per day threshold for quiet posters (used with --quiet)",
177 Value: 1.0,
178 },
179 &cli.StringFlag{
180 Name: "output",
181 Aliases: []string{"o"},
182 Usage: "Output format: json, csv",
183 Value: "csv",
184 Required: true,
185 },
186 &cli.BoolFlag{
187 Name: "refresh",
188 Usage: "Force refresh cached data (bypasses 24-hour cache)",
189 },
190 },
191 Action: FollowersExportAction,
192 },
193 },
194 }
195}
196
197// ListFollowersAction fetches and displays followers for a user with optional filtering
198func ListFollowersAction(ctx context.Context, cmd *cli.Command) error {
199 if err := setup.EnsurePersistenceReady(ctx); err != nil {
200 return fmt.Errorf("persistence layer not ready: %w", err)
201 }
202
203 reg := registry.Get()
204
205 service, err := reg.GetService()
206 if err != nil {
207 return fmt.Errorf("failed to get service: %w", err)
208 }
209
210 if !service.Authenticated() {
211 return fmt.Errorf("not authenticated: run 'skycli login' first")
212 }
213
214 cacheRepo, err := reg.GetCacheRepo()
215 if err != nil {
216 return fmt.Errorf("failed to get cache repository: %w", err)
217 }
218
219 actor := cmd.String("user")
220 if actor == "" {
221 actor = service.GetDid()
222 }
223 limit := cmd.Int("limit")
224 sinceStr := cmd.String("since")
225 inactiveDays := cmd.Int("inactive")
226 quietPosters := cmd.Bool("quiet")
227 quietThreshold := cmd.Float("threshold")
228 outputFormat := cmd.String("output")
229 refresh := cmd.Bool("refresh")
230
231 if limit == 0 {
232 logger.Debugf("Fetching all followers for %v", actor)
233 } else {
234 logger.Debugf("Fetching %v followers for %v", actor, limit)
235 }
236
237 var allFollowers []store.ActorProfile
238 cursor := ""
239 page := 0
240 for {
241 page++
242 response, err := service.GetFollowers(ctx, actor, 100, cursor)
243 if err != nil {
244 return fmt.Errorf("failed to fetch followers: %w", err)
245 }
246
247 allFollowers = append(allFollowers, response.Followers...)
248
249 if response.Cursor != "" {
250 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers))
251 }
252
253 if response.Cursor == "" || (limit > 0 && len(allFollowers) >= limit) {
254 break
255 }
256 cursor = response.Cursor
257 }
258
259 logger.Infof("Fetched %d total followers", len(allFollowers))
260
261 if limit > 0 && len(allFollowers) > limit {
262 allFollowers = allFollowers[:limit]
263 }
264
265 if sinceStr != "" {
266 since, err := time.Parse("2006-01-02", sinceStr)
267 if err != nil {
268 return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
269 }
270
271 var filtered []store.ActorProfile
272 for _, follower := range allFollowers {
273 if follower.IndexedAt == "" {
274 continue
275 }
276 indexedAt, err := time.Parse(time.RFC3339, follower.IndexedAt)
277 if err != nil {
278 logger.Warn("Failed to parse indexedAt", "error", err)
279 continue
280 }
281 if indexedAt.After(since) {
282 filtered = append(filtered, follower)
283 }
284 }
285 allFollowers = filtered
286 }
287
288 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowers, logger)
289
290 if inactiveDays > 0 {
291 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger)
292 }
293
294 if quietPosters {
295 followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger)
296 }
297
298 switch outputFormat {
299 case "json":
300 return outputFollowersJSON(followerInfos)
301 case "csv":
302 return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters)
303 default:
304 displayFollowersTable(followerInfos, inactiveDays > 0 || quietPosters)
305 }
306
307 return nil
308}
309
310// FollowersStatsAction displays aggregate statistics about followers
311func FollowersStatsAction(ctx context.Context, cmd *cli.Command) error {
312 if err := setup.EnsurePersistenceReady(ctx); err != nil {
313 return fmt.Errorf("persistence layer not ready: %w", err)
314 }
315
316 reg := registry.Get()
317
318 service, err := reg.GetService()
319 if err != nil {
320 return fmt.Errorf("failed to get service: %w", err)
321 }
322
323 if !service.Authenticated() {
324 return fmt.Errorf("not authenticated: run 'skycli login' first")
325 }
326
327 actor := cmd.String("user")
328 if actor == "" {
329 actor = service.GetDid()
330 }
331 sinceStr := cmd.String("since")
332 inactiveDays := cmd.Int("inactive")
333 showChart := cmd.Bool("chart")
334
335 logger.Debugf("Fetching followers stats for actor %v", actor)
336
337 var allFollowers []store.ActorProfile
338 cursor := ""
339 page := 0
340 for {
341 page++
342 response, err := service.GetFollowers(ctx, actor, 100, cursor)
343 if err != nil {
344 return fmt.Errorf("failed to fetch followers: %w", err)
345 }
346
347 allFollowers = append(allFollowers, response.Followers...)
348
349 if response.Cursor != "" {
350 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers))
351 }
352
353 if response.Cursor == "" {
354 break
355 }
356 cursor = response.Cursor
357 }
358
359 logger.Infof("Fetched %d total followers", len(allFollowers))
360
361 totalFollowers := len(allFollowers)
362
363 // Fetch full profiles for stats (required for accurate counts)
364 logger.Infof("Fetching detailed profiles for %d followers...", len(allFollowers))
365 actors := make([]string, len(allFollowers))
366 for i, follower := range allFollowers {
367 actors[i] = follower.Did
368 }
369
370 fullProfiles := service.BatchGetProfiles(ctx, actors, 10)
371 logger.Infof("Fetched %d detailed profiles", len(fullProfiles))
372
373 var growth int
374 var sinceDate time.Time
375 if sinceStr != "" {
376 since, err := time.Parse("2006-01-02", sinceStr)
377 if err != nil {
378 return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
379 }
380 sinceDate = since
381
382 for _, follower := range allFollowers {
383 if follower.IndexedAt == "" {
384 continue
385 }
386 indexedAt, err := time.Parse(time.RFC3339, follower.IndexedAt)
387 if err != nil {
388 continue
389 }
390 if indexedAt.After(since) {
391 growth++
392 }
393 }
394 }
395
396 var activeCount, inactiveCount int
397 if inactiveDays > 0 {
398 logger.Infof("Checking activity status (threshold: %d days)...", inactiveDays)
399
400 actors := make([]string, len(allFollowers))
401 for i, follower := range allFollowers {
402 actors[i] = follower.Did
403 }
404
405 lastPostDates := service.BatchGetLastPostDates(ctx, actors, 10)
406
407 for _, actor := range actors {
408 lastPost, ok := lastPostDates[actor]
409 if !ok || lastPost.IsZero() {
410 inactiveCount++
411 } else {
412 daysSince := int(time.Since(lastPost).Hours() / 24)
413 if daysSince > inactiveDays {
414 inactiveCount++
415 } else {
416 activeCount++
417 }
418 }
419 }
420 }
421
422 ui.Titleln("Follower Statistics")
423 fmt.Printf("Total followers: %d\n", totalFollowers)
424
425 if inactiveDays > 0 {
426 fmt.Printf("Active: %d\n", activeCount)
427 fmt.Printf("Inactive: %d (no post > %d days)\n", inactiveCount, inactiveDays)
428 }
429
430 if sinceStr != "" {
431 fmt.Printf("\nGrowth since %s: +%d\n", sinceDate.Format("2006-01-02"), growth)
432 }
433
434 if showChart && inactiveDays > 0 {
435 displayActivityChart(activeCount, inactiveCount)
436 }
437
438 return nil
439}
440
441// FollowersDiffAction compares follower lists between two dates
442func FollowersDiffAction(ctx context.Context, cmd *cli.Command) error {
443 if err := setup.EnsurePersistenceReady(ctx); err != nil {
444 return fmt.Errorf("persistence layer not ready: %w", err)
445 }
446
447 reg := registry.Get()
448
449 service, err := reg.GetService()
450 if err != nil {
451 return fmt.Errorf("failed to get service: %w", err)
452 }
453
454 if !service.Authenticated() {
455 return fmt.Errorf("not authenticated: run 'skycli login' first")
456 }
457
458 snapshotRepo, err := reg.GetSnapshotRepo()
459 if err != nil {
460 return fmt.Errorf("failed to get snapshot repository: %w", err)
461 }
462
463 actor := cmd.String("user")
464 if actor == "" {
465 actor = service.GetDid()
466 }
467 sinceStr := cmd.String("since")
468 untilStr := cmd.String("until")
469 outputFormat := cmd.String("output")
470
471 // Parse since parameter (date or snapshot ID)
472 sinceDate, err := time.Parse("2006-01-02", sinceStr)
473 var baselineSnapshot *store.SnapshotModel
474 if err != nil {
475 // Not a date, try as snapshot ID
476 model, err := snapshotRepo.Get(ctx, sinceStr)
477 if err != nil {
478 return fmt.Errorf("invalid --since parameter (not a date or snapshot ID): %w", err)
479 }
480 if model == nil {
481 return fmt.Errorf("snapshot not found: %s", sinceStr)
482 }
483 baselineSnapshot = model.(*store.SnapshotModel)
484 } else {
485 // Find snapshot by date
486 baselineSnapshot, err = snapshotRepo.FindByUserTypeAndDate(ctx, actor, "followers", sinceDate)
487 if err != nil {
488 return fmt.Errorf("failed to find snapshot: %w", err)
489 }
490 if baselineSnapshot == nil {
491 return fmt.Errorf("no snapshot found for %s on or before %s", actor, sinceStr)
492 }
493 }
494
495 logger.Infof("Using baseline snapshot from %s (%d followers)", baselineSnapshot.CreatedAt().Format("2006-01-02 15:04"), baselineSnapshot.TotalCount)
496
497 // Get baseline follower DIDs
498 baselineDids, err := snapshotRepo.GetActorDids(ctx, baselineSnapshot.ID())
499 if err != nil {
500 return fmt.Errorf("failed to get baseline followers: %w", err)
501 }
502
503 var comparisonDids []string
504 var comparisonLabel string
505
506 if untilStr != "" {
507 // Snapshot-to-snapshot comparison
508 untilDate, err := time.Parse("2006-01-02", untilStr)
509 var comparisonSnapshot *store.SnapshotModel
510 if err != nil {
511 // Not a date, try as snapshot ID
512 model, err := snapshotRepo.Get(ctx, untilStr)
513 if err != nil {
514 return fmt.Errorf("invalid --until parameter (not a date or snapshot ID): %w", err)
515 }
516 if model == nil {
517 return fmt.Errorf("snapshot not found: %s", untilStr)
518 }
519 comparisonSnapshot = model.(*store.SnapshotModel)
520 } else {
521 // Find snapshot by date
522 comparisonSnapshot, err = snapshotRepo.FindByUserTypeAndDate(ctx, actor, "followers", untilDate)
523 if err != nil {
524 return fmt.Errorf("failed to find snapshot: %w", err)
525 }
526 if comparisonSnapshot == nil {
527 return fmt.Errorf("no snapshot found for %s on or before %s", actor, untilStr)
528 }
529 }
530
531 logger.Infof("Comparing with snapshot from %s (%d followers)", comparisonSnapshot.CreatedAt().Format("2006-01-02 15:04"), comparisonSnapshot.TotalCount)
532 comparisonLabel = comparisonSnapshot.CreatedAt().Format("2006-01-02 15:04")
533
534 comparisonDids, err = snapshotRepo.GetActorDids(ctx, comparisonSnapshot.ID())
535 if err != nil {
536 return fmt.Errorf("failed to get comparison followers: %w", err)
537 }
538 } else {
539 // Snapshot-to-live comparison
540 logger.Infof("Fetching current followers for comparison...")
541 comparisonLabel = "now"
542
543 var allFollowers []store.ActorProfile
544 cursor := ""
545 page := 0
546 for {
547 page++
548 response, err := service.GetFollowers(ctx, actor, 100, cursor)
549 if err != nil {
550 return fmt.Errorf("failed to fetch followers: %w", err)
551 }
552
553 allFollowers = append(allFollowers, response.Followers...)
554
555 if response.Cursor != "" {
556 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers))
557 }
558
559 if response.Cursor == "" {
560 break
561 }
562 cursor = response.Cursor
563 }
564
565 logger.Infof("Fetched %d current followers", len(allFollowers))
566
567 for _, follower := range allFollowers {
568 comparisonDids = append(comparisonDids, follower.Did)
569 }
570 }
571
572 // Calculate diff
573 baselineSet := make(map[string]bool)
574 for _, did := range baselineDids {
575 baselineSet[did] = true
576 }
577
578 comparisonSet := make(map[string]bool)
579 for _, did := range comparisonDids {
580 comparisonSet[did] = true
581 }
582
583 // New followers: in comparison but not in baseline
584 var newFollowers []string
585 for _, did := range comparisonDids {
586 if !baselineSet[did] {
587 newFollowers = append(newFollowers, did)
588 }
589 }
590
591 // Unfollows: in baseline but not in comparison
592 var unfollows []string
593 for _, did := range baselineDids {
594 if !comparisonSet[did] {
595 unfollows = append(unfollows, did)
596 }
597 }
598
599 // Output results
600 switch outputFormat {
601 case "json":
602 return outputDiffJSON(newFollowers, unfollows)
603 case "csv":
604 return outputDiffCSV(newFollowers, unfollows)
605 default:
606 displayDiffTable(baselineSnapshot.CreatedAt().Format("2006-01-02 15:04"), comparisonLabel, len(baselineDids), len(comparisonDids), newFollowers, unfollows)
607 }
608
609 return nil
610}
611
612// FollowersExportAction exports followers to CSV or JSON
613func FollowersExportAction(ctx context.Context, cmd *cli.Command) error {
614 if err := setup.EnsurePersistenceReady(ctx); err != nil {
615 return fmt.Errorf("persistence layer not ready: %w", err)
616 }
617
618 reg := registry.Get()
619
620 service, err := reg.GetService()
621 if err != nil {
622 return fmt.Errorf("failed to get service: %w", err)
623 }
624
625 if !service.Authenticated() {
626 return fmt.Errorf("not authenticated: run 'skycli login' first")
627 }
628
629 cacheRepo, err := reg.GetCacheRepo()
630 if err != nil {
631 return fmt.Errorf("failed to get cache repository: %w", err)
632 }
633
634 actor := cmd.String("user")
635 if actor == "" {
636 actor = service.GetDid()
637 }
638 inactiveDays := cmd.Int("inactive")
639 quietPosters := cmd.Bool("quiet")
640 quietThreshold := cmd.Float("threshold")
641 outputFormat := cmd.String("output")
642 refresh := cmd.Bool("refresh")
643
644 logger.Debugf("Exporting followers for actor %v with fmt %v", actor, outputFormat)
645
646 var allFollowers []store.ActorProfile
647 cursor := ""
648 page := 0
649 for {
650 page++
651 response, err := service.GetFollowers(ctx, actor, 100, cursor)
652 if err != nil {
653 return fmt.Errorf("failed to fetch followers: %w", err)
654 }
655
656 allFollowers = append(allFollowers, response.Followers...)
657
658 if response.Cursor != "" {
659 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers))
660 }
661
662 if response.Cursor == "" {
663 break
664 }
665 cursor = response.Cursor
666 }
667
668 logger.Infof("Fetched %d total followers", len(allFollowers))
669
670 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowers, logger)
671
672 if inactiveDays > 0 {
673 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger)
674 }
675
676 if quietPosters {
677 followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger)
678 }
679
680 switch outputFormat {
681 case "json":
682 return outputFollowersJSON(followerInfos)
683 case "csv":
684 return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters)
685 default:
686 return fmt.Errorf("output format must be 'json' or 'csv'")
687 }
688}
689
690// enrichFollowerProfiles fetches full profiles and merges them with lightweight profiles
691func enrichFollowerProfiles(ctx context.Context, service *store.BlueskyService, profiles []store.ActorProfile, logger *log.Logger) ([]followerInfo, []string) {
692 logger.Infof("Fetching detailed profiles for %d accounts...", len(profiles))
693 actors := make([]string, len(profiles))
694 for i, profile := range profiles {
695 actors[i] = profile.Did
696 }
697
698 fullProfiles := service.BatchGetProfiles(ctx, actors, 10)
699 logger.Infof("Fetched %d detailed profiles", len(fullProfiles))
700
701 followerInfos := make([]followerInfo, len(profiles))
702 for i, profile := range profiles {
703 if fullProfile, ok := fullProfiles[profile.Did]; ok {
704 followerInfos[i] = followerInfo{Profile: fullProfile}
705 } else {
706 followerInfos[i] = followerInfo{Profile: &profile}
707 }
708 }
709
710 return followerInfos, actors
711}
712
713// filterInactive filters follower infos to only include accounts inactive for N days
714func filterInactive(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, inactiveDays int, refresh bool, logger *log.Logger) []followerInfo {
715 logger.Infof("Checking activity status (threshold: %d days)...", inactiveDays)
716
717 lastPostDates := service.BatchGetLastPostDatesCached(ctx, cacheRepo, actors, 10, refresh)
718
719 var filtered []followerInfo
720 for i, info := range followerInfos {
721 lastPost, ok := lastPostDates[actors[i]]
722 info.LastPostDate = lastPost
723
724 if !ok || lastPost.IsZero() {
725 info.IsInactive = true
726 info.DaysSincePost = -1
727 } else {
728 daysSince := int(time.Since(lastPost).Hours() / 24)
729 info.DaysSincePost = daysSince
730 info.IsInactive = daysSince > inactiveDays
731 }
732
733 if info.IsInactive {
734 filtered = append(filtered, info)
735 }
736 followerInfos[i] = info
737 }
738
739 return filtered
740}
741
742// filterQuiet filters follower infos to only include quiet posters
743func filterQuiet(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, threshold float64, refresh bool, logger *log.Logger) []followerInfo {
744 logger.Infof("Computing post rates (threshold: %.2f posts/day)...", threshold)
745 if refresh {
746 logger.Infof("Refreshing cache (this may take a while)...")
747 }
748
749 postRates := service.BatchGetPostRatesCached(ctx, cacheRepo, actors, 30, 30, 10, refresh, func(current, total int) {
750 if current%10 == 0 || current == total {
751 logger.Infof("Progress: %d/%d accounts analyzed", current, total)
752 }
753 })
754
755 var filtered []followerInfo
756 for i, info := range followerInfos {
757 if rate, ok := postRates[actors[i]]; ok {
758 info.PostsPerDay = rate.PostsPerDay
759 info.LastPostDate = rate.LastPostDate
760 info.IsQuiet = rate.PostsPerDay <= threshold
761 }
762
763 if info.IsQuiet {
764 filtered = append(filtered, info)
765 }
766 followerInfos[i] = info
767 }
768
769 logger.Infof("Found %d quiet posters (posting <= %.2f times/day)", len(filtered), threshold)
770 return filtered
771}
772
773func displayDiffTable(baselineLabel, comparisonLabel string, baselineCount, comparisonCount int, newFollowers, unfollows []string) {
774 ui.Titleln("Follower Diff: %s → %s", baselineLabel, comparisonLabel)
775 fmt.Println()
776
777 fmt.Printf("Baseline: %d followers\n", baselineCount)
778 fmt.Printf("Comparison: %d followers\n", comparisonCount)
779 fmt.Printf("Net change: %+d\n", comparisonCount-baselineCount)
780 fmt.Println()
781
782 if len(newFollowers) > 0 {
783 ui.Titleln("New Followers (%d)", len(newFollowers))
784 for _, did := range newFollowers {
785 fmt.Printf(" + %s\n", did)
786 }
787 fmt.Println()
788 }
789
790 if len(unfollows) > 0 {
791 ui.Titleln("Unfollows (%d)", len(unfollows))
792 for _, did := range unfollows {
793 fmt.Printf(" - %s\n", did)
794 }
795 fmt.Println()
796 }
797
798 if len(newFollowers) == 0 && len(unfollows) == 0 {
799 ui.Infoln("No changes detected")
800 }
801}
802
803func outputDiffJSON(newFollowers, unfollows []string) error {
804 output := diffOutput{
805 NewFollowers: newFollowers,
806 Unfollows: unfollows,
807 }
808 if output.NewFollowers == nil {
809 output.NewFollowers = []string{}
810 }
811 if output.Unfollows == nil {
812 output.Unfollows = []string{}
813 }
814 output.Summary.NewCount = len(newFollowers)
815 output.Summary.UnfollowCount = len(unfollows)
816
817 encoder := json.NewEncoder(os.Stdout)
818 encoder.SetIndent("", " ")
819 return encoder.Encode(output)
820}
821
822func outputDiffCSV(newFollowers, unfollows []string) error {
823 writer := csv.NewWriter(os.Stdout)
824 defer writer.Flush()
825
826 if err := writer.Write([]string{"type", "did"}); err != nil {
827 return err
828 }
829
830 for _, did := range newFollowers {
831 if err := writer.Write([]string{"new_follower", did}); err != nil {
832 return err
833 }
834 }
835
836 for _, did := range unfollows {
837 if err := writer.Write([]string{"unfollow", did}); err != nil {
838 return err
839 }
840 }
841
842 return nil
843}
844
845// formatTimeSince formats a time duration into a human-readable string.
846//
847// Returns
848// - "< 1 hour ago" for durations under 1 hour
849// - "X hours ago" for under 24 hours
850// - "X days ago" for longer durations.
851func formatTimeSince(since time.Time) string {
852 if since.IsZero() {
853 return "never"
854 }
855
856 duration := time.Since(since)
857 hours := duration.Hours()
858
859 if hours < 1 {
860 return "< 1 hour ago"
861 } else if hours < 24 {
862 return fmt.Sprintf("%d hours ago", int(hours))
863 } else {
864 days := int(hours / 24)
865 if days == 1 {
866 return "1 day ago"
867 }
868 return fmt.Sprintf("%d days ago", days)
869 }
870}
871
872func displayFollowersTable(followers []followerInfo, showInactive bool) {
873 if len(followers) == 0 {
874 ui.Infoln("No followers found")
875 return
876 }
877
878 ui.Titleln("Followers (%d)", len(followers))
879 fmt.Println()
880
881 headers := []string{"Handle", "Display Name", "Followers", "Posts"}
882
883 if showInactive && len(followers) > 0 && followers[0].IsQuiet {
884 headers = append(headers, "Posts/Day", "Last Post")
885 } else if len(followers) > 0 && followers[0].IsQuiet {
886 headers = append(headers, "Posts/Day")
887 } else if showInactive {
888 headers = append(headers, "Last Post")
889 }
890 headers = append(headers, "Profile URL")
891
892 data := make([][]string, len(followers))
893 for i, info := range followers {
894 displayName := info.Profile.DisplayName
895 if displayName == "" {
896 displayName = info.Profile.Handle
897 }
898
899 profileURL := fmt.Sprintf("https://bsky.app/profile/%s", info.Profile.Handle)
900
901 row := []string{
902 "@" + info.Profile.Handle,
903 displayName,
904 fmt.Sprintf("%d", info.Profile.FollowersCount),
905 fmt.Sprintf("%d", info.Profile.PostsCount),
906 }
907
908 if showInactive && info.IsQuiet {
909 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay))
910 row = append(row, formatTimeSince(info.LastPostDate))
911 } else if info.IsQuiet {
912 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay))
913 } else if showInactive {
914 row = append(row, formatTimeSince(info.LastPostDate))
915 }
916
917 row = append(row, profileURL)
918 data[i] = row
919 }
920
921 lastColIdx := len(headers) - 1
922
923 re := lipgloss.NewRenderer(os.Stdout)
924 t := lgtable.New().Border(lipgloss.NormalBorder()).BorderStyle(ui.TableBorderStyle).Headers(headers...).Rows(data...)
925 t = t.StyleFunc(func(row, col int) lipgloss.Style {
926 if row == lgtable.HeaderRow {
927 return ui.TableHeaderStyle
928 }
929
930 if col == 0 {
931 even := row%2 == 0
932 if even {
933 return ui.TableRowEvenStyle.Foreground(lipgloss.Color("#f6c177"))
934 }
935 return ui.TableRowOddStyle.Foreground(lipgloss.Color("#f6c177"))
936 }
937
938 if col == lastColIdx {
939 even := row%2 == 0
940 baseStyle := ui.TableRowEvenStyle
941 if !even {
942 baseStyle = ui.TableRowOddStyle
943 }
944 return baseStyle.Foreground(lipgloss.Color("#e0def4"))
945 }
946
947 if row%2 == 0 {
948 return ui.TableRowEvenStyle
949 }
950 return ui.TableRowOddStyle
951 })
952
953 fmt.Println(re.NewStyle().Render(t.String()))
954 fmt.Println()
955}
956
957func outputFollowersJSON(followers []followerInfo) error {
958 encoder := json.NewEncoder(os.Stdout)
959 encoder.SetIndent("", " ")
960 return encoder.Encode(followers)
961}
962
963func outputFollowersCSV(followers []followerInfo, includeInactive bool) error {
964 writer := csv.NewWriter(os.Stdout)
965 defer writer.Flush()
966
967 hasQuiet := len(followers) > 0 && followers[0].IsQuiet
968
969 header := []string{"handle", "displayName", "did", "followersCount", "postsCount", "profileURL"}
970 if hasQuiet {
971 header = append(header, "postsPerDay")
972 }
973 if includeInactive {
974 header = append(header, "daysSincePost", "lastPostDate")
975 }
976 if err := writer.Write(header); err != nil {
977 return err
978 }
979
980 for _, info := range followers {
981 // profileURL := fmt.Sprintf("https://bsky.app/profile/%s", info.Profile.Handle)
982 row := []string{
983 info.Profile.Handle,
984 info.Profile.DisplayName,
985 info.Profile.Did,
986 fmt.Sprintf("%d", info.Profile.FollowersCount),
987 fmt.Sprintf("%d", info.Profile.PostsCount),
988 // profileURL,
989 }
990
991 if hasQuiet {
992 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay))
993 }
994
995 if includeInactive {
996 daysSince := "N/A"
997 if info.DaysSincePost >= 0 {
998 daysSince = fmt.Sprintf("%d", info.DaysSincePost)
999 }
1000 lastPost := ""
1001 if !info.LastPostDate.IsZero() {
1002 lastPost = info.LastPostDate.Format(time.RFC3339)
1003 }
1004 row = append(row, daysSince, lastPost)
1005 }
1006
1007 if err := writer.Write(row); err != nil {
1008 return err
1009 }
1010 }
1011
1012 return nil
1013}
1014
1015func displayActivityChart(active, inactive int) {
1016 total := active + inactive
1017 if total == 0 {
1018 return
1019 }
1020
1021 fmt.Println()
1022
1023 activePercent := float64(active) / float64(total)
1024 inactivePercent := float64(inactive) / float64(total)
1025
1026 chartWidth := 30
1027 activeBars := int(activePercent * float64(chartWidth))
1028 inactiveBars := int(inactivePercent * float64(chartWidth))
1029
1030 if activeBars+inactiveBars < chartWidth {
1031 activeBars = chartWidth - inactiveBars
1032 }
1033
1034 activeBar := strings.Repeat("█", activeBars)
1035 inactiveBar := strings.Repeat("▒", inactiveBars)
1036
1037 fmt.Printf("%s%s\n", activeBar, inactiveBar)
1038 fmt.Printf("█ Active ▒ Inactive\n")
1039}