feat(analytics): add quiet channel report
Adds analytics quiet for reporting silent message-bearing channels.
This commit is contained in:
parent
468ad1680c
commit
71b602c5f6
16
README.md
16
README.md
@ -490,6 +490,22 @@ Notes:
|
||||
- `--channel` accepts a channel id or exact channel name
|
||||
- `--top-n` controls how many top posters and mention targets are shown per channel
|
||||
|
||||
### `analytics`
|
||||
|
||||
Groups activity-style queries under one namespace.
|
||||
|
||||
```bash
|
||||
discrawl analytics
|
||||
discrawl analytics quiet --since 30d
|
||||
discrawl analytics quiet --guild 123456789012345678
|
||||
discrawl --json analytics quiet --since 60d
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `analytics quiet` shows channels with no messages in the lookback window, including never-active channels
|
||||
- `analytics quiet --guild` scopes the report to one guild; when omitted, `default_guild_id` is used if configured
|
||||
|
||||
### `doctor`
|
||||
|
||||
Checks config, auth, DB, and FTS wiring.
|
||||
|
||||
19
SPEC.md
19
SPEC.md
@ -812,3 +812,22 @@ Behavior:
|
||||
- top mentions are ranked from `mention_events` and include all target types (`user` and `role`)
|
||||
- channels are sorted by message count descending, then channel name ascending
|
||||
- JSON output returns a `Digest` object with channel rows and totals; plain output emits one tab-separated row per channel
|
||||
|
||||
## Analytics
|
||||
|
||||
`discrawl analytics` is a subcommand group for activity-style queries.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
discrawl analytics
|
||||
discrawl analytics quiet --since 30d
|
||||
discrawl analytics quiet --guild 123456789012345678
|
||||
discrawl --json analytics quiet --since 60d
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `analytics quiet` defaults to `30d` lookback and supports `--guild`
|
||||
- `analytics quiet` includes channels with no messages at all
|
||||
- quiet rows are sorted with never-active channels first, then by longest silence
|
||||
|
||||
67
internal/cli/analytics.go
Normal file
67
internal/cli/analytics.go
Normal file
@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/discrawl/internal/report"
|
||||
)
|
||||
|
||||
func (r *runtime) runAnalytics(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printAnalyticsUsage(r.stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
subcommand := strings.TrimSpace(args[0])
|
||||
subArgs := args[1:]
|
||||
switch subcommand {
|
||||
case "quiet":
|
||||
return r.withLocalStoreDefaultLocked(true, true, func() error {
|
||||
return r.runAnalyticsQuiet(subArgs)
|
||||
})
|
||||
default:
|
||||
return usageErr(fmt.Errorf("unknown analytics subcommand %q", subcommand))
|
||||
}
|
||||
}
|
||||
|
||||
func printAnalyticsUsage(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, "Usage: discrawl analytics <subcommand> [flags]")
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, "Subcommands:")
|
||||
_, _ = fmt.Fprintln(w, " quiet Channels with no activity in the lookback window.")
|
||||
}
|
||||
|
||||
func (r *runtime) runAnalyticsQuiet(args []string) error {
|
||||
fs := flag.NewFlagSet("analytics quiet", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
since := fs.String("since", "30d", "")
|
||||
guild := fs.String("guild", "", "")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return usageErr(err)
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return usageErr(errors.New("analytics quiet takes no positional arguments"))
|
||||
}
|
||||
|
||||
lookback, err := parseLookback(*since)
|
||||
if err != nil {
|
||||
return usageErr(fmt.Errorf("parse --since: %w", err))
|
||||
}
|
||||
guildID := strings.TrimSpace(*guild)
|
||||
if guildID == "" {
|
||||
guildID = r.cfg.EffectiveDefaultGuildID()
|
||||
}
|
||||
|
||||
quiet, err := report.BuildQuiet(r.ctx, r.store, report.QuietOptions{
|
||||
Since: lookback,
|
||||
GuildID: guildID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(quiet)
|
||||
}
|
||||
169
internal/cli/analytics_test.go
Normal file
169
internal/cli/analytics_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/steipete/discrawl/internal/config"
|
||||
"github.com/steipete/discrawl/internal/store"
|
||||
)
|
||||
|
||||
func TestAnalyticsCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.toml")
|
||||
dbPath := filepath.Join(dir, "discrawl.db")
|
||||
|
||||
require.NoError(t, seedAnalyticsCLIStore(ctx, dbPath))
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DBPath = dbPath
|
||||
cfg.DefaultGuildID = "g1"
|
||||
require.NoError(t, config.Write(cfgPath, cfg))
|
||||
|
||||
t.Run("analytics with no subcommand prints usage", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "analytics"}, &out, &bytes.Buffer{}))
|
||||
require.Contains(t, out.String(), "Usage: discrawl analytics <subcommand> [flags]")
|
||||
require.Contains(t, out.String(), "quiet")
|
||||
require.NotContains(t, out.String(), "trends")
|
||||
})
|
||||
|
||||
t.Run("analytics quiet json schema", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "analytics", "quiet", "--since", "30d"}, &out, &bytes.Buffer{}))
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
|
||||
require.Contains(t, payload, "generated_at")
|
||||
require.Contains(t, payload, "since")
|
||||
require.Contains(t, payload, "until")
|
||||
require.Contains(t, payload, "channels")
|
||||
|
||||
channels, ok := payload["channels"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, channels)
|
||||
|
||||
first, ok := channels[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, first, "channel_id")
|
||||
require.Contains(t, first, "channel_name")
|
||||
require.Contains(t, first, "guild_id")
|
||||
require.Contains(t, first, "days_silent")
|
||||
|
||||
totals, ok := payload["totals"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, totals, "channels")
|
||||
})
|
||||
|
||||
t.Run("analytics quiet human output", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "analytics", "quiet", "--since", "30d"}, &out, &bytes.Buffer{}))
|
||||
|
||||
text := out.String()
|
||||
require.Contains(t, text, "CHANNEL")
|
||||
require.Contains(t, text, "stale")
|
||||
require.Contains(t, text, "Window:")
|
||||
require.Contains(t, text, "Totals: channels=")
|
||||
})
|
||||
|
||||
t.Run("analytics quiet plain output", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--plain", "analytics", "quiet", "--since", "30d"}, &out, &bytes.Buffer{}))
|
||||
|
||||
require.Contains(t, out.String(), "c3\tstale\ttext\tg1\t")
|
||||
})
|
||||
|
||||
t.Run("unknown analytics subcommand returns usage error", func(t *testing.T) {
|
||||
err := Run(ctx, []string{"--config", cfgPath, "analytics", "unknown-sub"}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 2, ExitCode(err))
|
||||
})
|
||||
|
||||
t.Run("quiet validates its own flags", func(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"--config", cfgPath, "analytics", "quiet", "--bogus"},
|
||||
{"--config", cfgPath, "analytics", "quiet", "extra"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err := Run(ctx, args, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 2, ExitCode(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedAnalyticsCLIStore(ctx context.Context, path string) error {
|
||||
s, err := store.Open(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "incidents", RawJSON: `{}`}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "text", Name: "stale", RawJSON: `{}`}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c4", GuildID: "g1", Kind: "forum", Name: "never", RawJSON: `{}`}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.UpsertMessages(ctx, []store.MessageMutation{
|
||||
{
|
||||
Record: store.MessageRecord{
|
||||
ID: "m1",
|
||||
GuildID: "g1",
|
||||
ChannelID: "c1",
|
||||
ChannelName: "general",
|
||||
AuthorID: "u1",
|
||||
AuthorName: "Alice",
|
||||
CreatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
||||
Content: "hello",
|
||||
NormalizedContent: "hello",
|
||||
RawJSON: `{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Record: store.MessageRecord{
|
||||
ID: "m2",
|
||||
GuildID: "g1",
|
||||
ChannelID: "c2",
|
||||
ChannelName: "incidents",
|
||||
AuthorID: "u2",
|
||||
AuthorName: "Bob",
|
||||
CreatedAt: now.Add(-9 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
Content: "incident",
|
||||
NormalizedContent: "incident",
|
||||
RawJSON: `{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Record: store.MessageRecord{
|
||||
ID: "m3",
|
||||
GuildID: "g1",
|
||||
ChannelID: "c3",
|
||||
ChannelName: "stale",
|
||||
AuthorID: "u1",
|
||||
AuthorName: "Alice",
|
||||
CreatedAt: now.Add(-45 * 24 * time.Hour).Format(time.RFC3339Nano),
|
||||
Content: "old",
|
||||
NormalizedContent: "old",
|
||||
RawJSON: `{}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -140,6 +140,8 @@ func (r *runtime) dispatch(rest []string) error {
|
||||
return r.withLocalStoreDefaultLocked(autoShareUpdate, autoShareUpdate, func() error { return r.runMessages(rest[1:]) })
|
||||
case "digest":
|
||||
return r.withLocalStoreDefaultLocked(true, true, func() error { return r.runDigest(rest[1:]) })
|
||||
case "analytics":
|
||||
return r.runAnalytics(rest[1:])
|
||||
case "dms":
|
||||
return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) })
|
||||
case "mentions":
|
||||
|
||||
@ -52,8 +52,8 @@ func parseLookback(value string) (time.Duration, error) {
|
||||
if value == "" {
|
||||
return 0, errors.New("empty duration")
|
||||
}
|
||||
if strings.HasSuffix(value, "d") {
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(value, "d"))
|
||||
if daysValue, ok := strings.CutSuffix(value, "d"); ok {
|
||||
days, err := strconv.Atoi(daysValue)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid day count: %w", err)
|
||||
}
|
||||
|
||||
@ -67,10 +67,10 @@ func TestDigestCommand(t *testing.T) {
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
|
||||
require.Equal(t, "7d", payload["window_label"])
|
||||
require.Equal(t, float64(3), payload["top_n"])
|
||||
require.InEpsilon(t, 3, payload["top_n"], 0.001)
|
||||
totals, ok := payload["totals"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(2), totals["messages"])
|
||||
require.InEpsilon(t, 2, totals["messages"], 0.001)
|
||||
})
|
||||
|
||||
t.Run("channel name filter", func(t *testing.T) {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@ -75,6 +76,11 @@ func printPlain(w io.Writer, value any) error {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.Messages, row.Threads, row.ActiveAuthors)
|
||||
}
|
||||
return nil
|
||||
case report.Quiet:
|
||||
for _, row := range v.Channels {
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.LastMessage, row.DaysSilent)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("no plain printer")
|
||||
}
|
||||
@ -94,6 +100,7 @@ Commands:
|
||||
search
|
||||
messages
|
||||
digest
|
||||
analytics
|
||||
dms
|
||||
mentions
|
||||
embed
|
||||
@ -301,6 +308,25 @@ func printHuman(w io.Writer, value any) error {
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "Totals: messages=%d threads=%d channels=%d authors=%d\n", v.Totals.Messages, v.Totals.Threads, v.Totals.Channels, v.Totals.ActiveAuthors)
|
||||
return err
|
||||
case report.Quiet:
|
||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(tw, "CHANNEL\tKIND\tLAST MESSAGE\tDAYS SILENT")
|
||||
for _, row := range v.Channels {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
row.ChannelName,
|
||||
firstNonEmpty(row.Kind, "unknown"),
|
||||
firstNonEmpty(row.LastMessage, "never"),
|
||||
formatDaysSilent(row.DaysSilent),
|
||||
)
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), formatWindowDuration(v.Until.Sub(v.Since))); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "Totals: channels=%d\n", v.Totals.Channels)
|
||||
return err
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(v))
|
||||
for key := range v {
|
||||
@ -360,3 +386,23 @@ func formatRankedCounts(rows []report.RankedCount) string {
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatDaysSilent(days int) string {
|
||||
if days < 0 {
|
||||
return "-"
|
||||
}
|
||||
return strconv.Itoa(days)
|
||||
}
|
||||
|
||||
func formatWindowDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "0"
|
||||
}
|
||||
if d%(24*time.Hour) == 0 {
|
||||
return fmt.Sprintf("%dd", int(d/(24*time.Hour)))
|
||||
}
|
||||
if d%time.Hour == 0 {
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
159
internal/report/quiet.go
Normal file
159
internal/report/quiet.go
Normal file
@ -0,0 +1,159 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/discrawl/internal/store"
|
||||
)
|
||||
|
||||
// QuietOptions controls how a Quiet report is built.
|
||||
type QuietOptions struct {
|
||||
Now time.Time
|
||||
Since time.Duration
|
||||
GuildID string
|
||||
}
|
||||
|
||||
// Quiet summarizes channels with no activity in a window.
|
||||
type Quiet struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Since time.Time `json:"since"`
|
||||
Until time.Time `json:"until"`
|
||||
Guild string `json:"guild,omitempty"`
|
||||
Channels []QuietChannel `json:"channels"`
|
||||
Totals QuietTotals `json:"totals"`
|
||||
}
|
||||
|
||||
// QuietChannel is one channel with no recent activity.
|
||||
type QuietChannel struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
GuildID string `json:"guild_id"`
|
||||
LastMessage string `json:"last_message,omitempty"`
|
||||
DaysSilent int `json:"days_silent"`
|
||||
}
|
||||
|
||||
// QuietTotals summarizes quiet-channel counts.
|
||||
type QuietTotals struct {
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
// BuildQuiet computes channels with no messages newer than the lookback window.
|
||||
func BuildQuiet(ctx context.Context, s *store.Store, opts QuietOptions) (Quiet, error) {
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
|
||||
sinceWindow := opts.Since
|
||||
if sinceWindow < 0 {
|
||||
sinceWindow = -sinceWindow
|
||||
}
|
||||
if sinceWindow == 0 {
|
||||
sinceWindow = 30 * 24 * time.Hour
|
||||
}
|
||||
since := now.Add(-sinceWindow)
|
||||
|
||||
out := Quiet{
|
||||
GeneratedAt: now,
|
||||
Since: since,
|
||||
Until: now,
|
||||
Guild: strings.TrimSpace(opts.GuildID),
|
||||
}
|
||||
|
||||
channels, err := quietChannels(ctx, s.DB(), since, now, out.Guild)
|
||||
if err != nil {
|
||||
return Quiet{}, err
|
||||
}
|
||||
out.Channels = channels
|
||||
out.Totals = QuietTotals{Channels: len(channels)}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func quietChannels(ctx context.Context, db *sql.DB, since, now time.Time, guildID string) ([]QuietChannel, error) {
|
||||
query := &strings.Builder{}
|
||||
query.WriteString(`
|
||||
with latest_messages as (
|
||||
select
|
||||
m.guild_id,
|
||||
m.channel_id,
|
||||
max(m.created_at) as last_message
|
||||
from messages m
|
||||
group by m.guild_id, m.channel_id
|
||||
)
|
||||
select
|
||||
c.guild_id,
|
||||
c.id as channel_id,
|
||||
coalesce(nullif(c.name, ''), c.id) as channel_name,
|
||||
coalesce(c.kind, '') as kind,
|
||||
coalesce(lm.last_message, '') as last_message
|
||||
from channels c
|
||||
left join latest_messages lm on lm.guild_id = c.guild_id and lm.channel_id = c.id
|
||||
where 1 = 1
|
||||
and c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement')
|
||||
`)
|
||||
args := make([]any, 0, 2)
|
||||
if guildID != "" {
|
||||
query.WriteString(" and c.guild_id = ?\n")
|
||||
args = append(args, guildID)
|
||||
}
|
||||
query.WriteString(" and (lm.last_message is null or lm.last_message < ?)\n")
|
||||
args = append(args, since.UTC().Format(time.RFC3339Nano))
|
||||
query.WriteString("order by channel_name asc\n")
|
||||
|
||||
rows, err := db.QueryContext(ctx, query.String(), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiet channels query: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]QuietChannel, 0)
|
||||
for rows.Next() {
|
||||
var row QuietChannel
|
||||
if err := rows.Scan(&row.GuildID, &row.ChannelID, &row.ChannelName, &row.Kind, &row.LastMessage); err != nil {
|
||||
return nil, fmt.Errorf("quiet channels scan: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(row.LastMessage) == "" {
|
||||
row.LastMessage = ""
|
||||
row.DaysSilent = -1
|
||||
} else {
|
||||
last, err := time.Parse(time.RFC3339Nano, row.LastMessage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("quiet last message parse: %w", err)
|
||||
}
|
||||
last = last.UTC()
|
||||
row.LastMessage = last.Format(time.RFC3339)
|
||||
row.DaysSilent = int(now.Sub(last).Hours() / 24)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
iNever := out[i].DaysSilent < 0
|
||||
jNever := out[j].DaysSilent < 0
|
||||
if iNever != jNever {
|
||||
return iNever
|
||||
}
|
||||
if out[i].DaysSilent != out[j].DaysSilent {
|
||||
return out[i].DaysSilent > out[j].DaysSilent
|
||||
}
|
||||
if out[i].ChannelName != out[j].ChannelName {
|
||||
return out[i].ChannelName < out[j].ChannelName
|
||||
}
|
||||
if out[i].GuildID != out[j].GuildID {
|
||||
return out[i].GuildID < out[j].GuildID
|
||||
}
|
||||
return out[i].ChannelID < out[j].ChannelID
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
109
internal/report/quiet_test.go
Normal file
109
internal/report/quiet_test.go
Normal file
@ -0,0 +1,109 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/steipete/discrawl/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildQuiet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, now := seedQuietStore(t, ctx)
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
t.Run("happy path mixed active and silent channels", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, now, quiet.GeneratedAt)
|
||||
require.Equal(t, now.Add(-30*24*time.Hour), quiet.Since)
|
||||
require.Equal(t, now, quiet.Until)
|
||||
require.Equal(t, 4, quiet.Totals.Channels)
|
||||
|
||||
ids := []string{quiet.Channels[0].ChannelID, quiet.Channels[1].ChannelID, quiet.Channels[2].ChannelID, quiet.Channels[3].ChannelID}
|
||||
require.Equal(t, []string{"c0", "c2", "c9", "c3"}, ids)
|
||||
require.Equal(t, -1, quiet.Channels[0].DaysSilent)
|
||||
require.Equal(t, 45, quiet.Channels[1].DaysSilent)
|
||||
require.Equal(t, 40, quiet.Channels[2].DaysSilent)
|
||||
require.Equal(t, 35, quiet.Channels[3].DaysSilent)
|
||||
})
|
||||
|
||||
t.Run("zero activity channel inclusion", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour})
|
||||
require.NoError(t, err)
|
||||
|
||||
var never *QuietChannel
|
||||
for i := range quiet.Channels {
|
||||
if quiet.Channels[i].ChannelID == "c0" {
|
||||
never = &quiet.Channels[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, never)
|
||||
require.Empty(t, never.LastMessage)
|
||||
require.Equal(t, -1, never.DaysSilent)
|
||||
})
|
||||
|
||||
t.Run("guild filter", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour, GuildID: "g1"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, quiet.Channels, 3)
|
||||
for _, row := range quiet.Channels {
|
||||
require.Equal(t, "g1", row.GuildID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative since is normalized", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: -30 * 24 * time.Hour})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, now.Add(-30*24*time.Hour), quiet.Since)
|
||||
})
|
||||
|
||||
t.Run("default now and since defaults", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{})
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, quiet.GeneratedAt.Add(-30*24*time.Hour), quiet.Since, 2*time.Second)
|
||||
require.WithinDuration(t, quiet.GeneratedAt, quiet.Until, 2*time.Second)
|
||||
})
|
||||
|
||||
t.Run("sort order most silent first with never-active first", func(t *testing.T) {
|
||||
quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, quiet.Channels, 4)
|
||||
require.Equal(t, "c0", quiet.Channels[0].ChannelID)
|
||||
require.Greater(t, quiet.Channels[1].DaysSilent, quiet.Channels[2].DaysSilent)
|
||||
require.Greater(t, quiet.Channels[2].DaysSilent, quiet.Channels[3].DaysSilent)
|
||||
})
|
||||
}
|
||||
|
||||
func seedQuietStore(t *testing.T, ctx context.Context) (*store.Store, time.Time) {
|
||||
t.Helper()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild 1", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g2", Name: "Guild 2", RawJSON: `{}`}))
|
||||
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c0", GuildID: "g1", Kind: "text", Name: "never", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "recent", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "stale-45", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "thread_public", Name: "stale-35", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c5", GuildID: "g1", Kind: "category", Name: "structural", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c6", GuildID: "g1", Kind: "voice", Name: "lobby", RawJSON: `{}`}))
|
||||
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c9", GuildID: "g2", Kind: "text", Name: "other-guild-stale", RawJSON: `{}`}))
|
||||
|
||||
require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{
|
||||
{Record: store.MessageRecord{ID: "m1", GuildID: "g1", ChannelID: "c1", ChannelName: "recent", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-2 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "recent", NormalizedContent: "recent", RawJSON: `{}`}},
|
||||
{Record: store.MessageRecord{ID: "m2", GuildID: "g1", ChannelID: "c2", ChannelName: "stale-45", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-45 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}},
|
||||
{Record: store.MessageRecord{ID: "m3", GuildID: "g1", ChannelID: "c3", ChannelName: "stale-35", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-35 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}},
|
||||
{Record: store.MessageRecord{ID: "m4", GuildID: "g2", ChannelID: "c9", ChannelName: "other-guild-stale", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-40 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}},
|
||||
}))
|
||||
|
||||
return s, now
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user