summary history files

internal/report/report.go
package report

import (
	"bytes"
	"context"
	"ct/internal/store"
	"database/sql"
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"

	_ "github.com/mattn/go-sqlite3" //nolint
	"github.com/olekukonko/tablewriter"
	"github.com/snabb/isoweek"
)

type reportDaily struct {
	// The metric name.
	MetricName string `json:"metric_name"`

	// The monthly value.
	MetricValue float64 `json:"metric_value"`

	// The month.
	Timestamp string `json:"month"`
}

type reportMonth struct {
	// The metric name.
	MetricName string `json:"metric_name"`

	// The monthly average.
	MetricAverage float64 `json:"metric_average"`

	// The monthly sum.
	MetricSum float64 `json:"metric_sum"`

	// The count for the month.
	Count int `json:"count"`

	// The month.
	Month string `json:"month"`
}

// Daily prints the daily report.
func Daily(ctx context.Context, db *sql.DB, metric *store.Metric) (string, error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return "", err
	}
	defer tx.Rollback()

	rows, err := tx.QueryContext(ctx, `
	SELECT
	id,
	ROUND(value, 2),
	STRFTIME("%Y-%m-%d", timestamp)
	FROM log
	WHERE metric_id = ?
	ORDER BY log.timestamp
	ASC
	`, metric.MetricID)
	if err != nil {
		return "", err
	}

	tableString := &strings.Builder{}
	table := tablewriter.NewWriter(tableString)
	table.SetHeader([]string{"Month", "Value", "Comment"})

	s := store.NewStore(db)
	for rows.Next() {
		var id int64
		var value float64
		var timestamp string
		if err := rows.Scan(&id, &value, &timestamp); err != nil {
			return "", err
		}

		comment := ""
		logComment, err := s.LogComment.SelectOne(ctx, id)
		if err != nil && err != store.ErrNotFound {
			return "", err
		}
		if err == nil {
			comment = logComment.Comment
		}

		table.Append([]string{timestamp, strconv.FormatFloat(value, 'f', -1, 64), comment})
	}

	err = rows.Err()
	if err != nil {
		return "", err
	}

	table.Render()

	return tableString.String(), tx.Commit()
}

func stringInSlice(s string, sl []string) bool {
	for _, i := range sl {
		if i == s {
			return true
		}
	}
	return false
}

// Report is the main struct for reports.
type Report struct {
	db     *sql.DB
	metric *store.Metric
}

// NewReport returns a new Report.
func NewReport(db *sql.DB, metric *store.Metric) Report {
	return Report{db: db, metric: metric}
}

// QueryText is a struct that is intended to help build SQL strings.
type QueryText struct {
	slt         string
	from        string
	where       []string
	whereMarker bool
	groupBy     string
	orderBy     string
}

// QueryOption is a type that closes over QueryText, providing a way to mutate queries.
type QueryOption func(*QueryText)

// WithStartTimestamp sets the WHERE clause of the SQL for log timestamps greater than or equal to t.
func WithStartTimestamp(t time.Time) QueryOption {
	return func(q *QueryText) {
		q.where = append(q.where, fmt.Sprintf("log.timestamp >= \"%s\"", t.Format("2006-01-02")))
	}
}

// WithEndTimestamp sets the WHERE clause of the SQL for log timestamps less than or equal to t.
func WithEndTimestamp(t time.Time) QueryOption {
	return func(q *QueryText) {
		q.where = append(q.where, fmt.Sprintf("log.timestamp <= \"%s\"", t.Format("2006-01-02")))
	}
}

// NewQueryText returns a new QueryText.
func NewQueryText(slt, from, groupBy, orderBy string, where []string) *QueryText {
	return &QueryText{slt: slt, from: from, where: where, groupBy: groupBy, orderBy: orderBy}
}

func (q *QueryText) String() string {
	buf := &bytes.Buffer{}
	buf.WriteString(q.slt + " ")
	buf.WriteString(q.from + " ")
	buf.WriteString("WHERE " + strings.Join(q.where, " AND ") + " ")
	buf.WriteString(q.groupBy + " ")
	buf.WriteString(q.orderBy)
	return buf.String()
}

// MonthlyCounter generates the monthly report for counter metrics.
func (r Report) MonthlyCounter(ctx context.Context, options ...QueryOption) (output string, err error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return "", err
	}
	defer tx.Rollback()

	q := NewQueryText(
		"SELECT ROUND(AVG(value), 2) AS metric_average, ROUND(SUM(value), 2) AS metric_sum, COUNT(1) AS metric_count, STRFTIME(\"%Y-%m\", log.timestamp) AS month",
		"FROM log",
		"GROUP BY month",
		"ORDER BY log.timestamp",
		[]string{"log.metric_id = ?"},
	)

	for _, option := range options {
		option(q)
	}

	if len(q.where) == 1 {
		q.where = append(q.where, "log.timestamp >= DATE('now', '-1 year')")
	}

	rows, err := tx.QueryContext(ctx, fmt.Sprint(q), r.metric.MetricID)
	if err != nil {
		return "", err
	}

	tableString := &strings.Builder{}
	table := tablewriter.NewWriter(tableString)
	table.SetHeader([]string{"Month", "Average", "Sum", "Count"})

	for rows.Next() {
		var avg float64
		var sum float64
		var count int
		var month string
		if err := rows.Scan(&avg, &sum, &count, &month); err != nil {
			return "", err
		}
		table.Append([]string{month, strconv.FormatFloat(avg, 'f', -1, 64), strconv.FormatFloat(sum, 'f', -1, 64), strconv.Itoa(count)})
	}

	err = rows.Err()
	if err != nil {
		return "", err
	}

	tx.Commit()

	table.Render()

	return tableString.String(), nil
}

// MonthlyGuage generates the monthly report for gauge metrics.
func (r Report) MonthlyGuage(ctx context.Context, options ...QueryOption) (output string, err error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return "", err
	}
	defer tx.Rollback()

	q := NewQueryText(
		"SELECT ROUND(AVG(value), 2) AS metric_average, COUNT(1) AS metric_count, STRFTIME(\"%Y-%m\", log.timestamp) AS month",
		"FROM log",
		"GROUP BY month",
		"ORDER BY log.timestamp",
		[]string{"log.metric_id = ?"},
	)

	for _, option := range options {
		option(q)
	}

	if len(q.where) == 1 {
		q.where = append(q.where, "log.timestamp >= DATE('now', '-1 year')")
	}

	rows, err := tx.QueryContext(ctx, fmt.Sprint(q), r.metric.MetricID)
	if err != nil {
		return "", err
	}

	tableString := &strings.Builder{}
	table := tablewriter.NewWriter(tableString)
	table.SetHeader([]string{"Month", "Average", "Count"})

	for rows.Next() {
		var avg float64
		var count int
		var month string
		if err := rows.Scan(&avg, &count, &month); err != nil {
			return "", err
		}
		table.Append([]string{month, strconv.FormatFloat(avg, 'f', -1, 64), strconv.Itoa(count)})
	}

	err = rows.Err()
	if err != nil {
		return "", err
	}

	tx.Commit()

	table.Render()

	return tableString.String(), nil
}

// WeeklyGauge generates the weekly report for gauge metrics.
func WeeklyGauge(ctx context.Context, db *sql.DB, metric *store.Metric) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	rows, err := tx.QueryContext(ctx, `
	SELECT 
	ROUND(AVG(value), 2) AS metric_average,
	COUNT(1) AS metric_count,
	STRFTIME("%Y-%W", log.timestamp) AS year_week,
	CAST(STRFTIME("%Y", log.timestamp) AS INTEGER) AS year,
	CAST(STRFTIME("%W", log.timestamp) AS INTEGER) AS week
	FROM log
	WHERE log.timestamp >= DATE('now', '-1 year')
	AND log.metric_id = ?
	GROUP BY year_week
	ORDER BY log.timestamp
`, metric.MetricID)
	if err != nil {
		return err
	}

	table := tablewriter.NewWriter(os.Stdout)
	table.SetHeader([]string{"Week", "Average", "Count"})

	for rows.Next() {
		var avg float64
		var count int
		var yearWeek string
		var week int
		var year int
		if err := rows.Scan(&avg, &count, &yearWeek, &year, &week); err != nil {
			return err
		}

		table.Append([]string{isoweek.StartTime(year, week, time.UTC).Format("2006-01-02"), strconv.FormatFloat(avg, 'f', -1, 64), strconv.Itoa(count)})
	}

	err = rows.Err()
	if err != nil {
		return err
	}

	table.Render()

	return tx.Commit()
}

// WeeklyCounter generates the weekly report for counter metrics.
func WeeklyCounter(ctx context.Context, db *sql.DB, metric *store.Metric) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	rows, err := tx.QueryContext(ctx, `
	SELECT 
	ROUND(AVG(value), 2) AS metric_average,
	ROUND(SUM(value), 2) AS metric_sum,
	COUNT(1) AS metric_count,
	STRFTIME("%Y-%W", log.timestamp) AS year_week,
	CAST(STRFTIME("%Y", log.timestamp) AS INTEGER) AS year,
	CAST(STRFTIME("%W", log.timestamp) AS INTEGER) AS week
	FROM log
	WHERE log.timestamp >= DATE('now', '-1 year')
	AND log.metric_id = ?
	GROUP BY week
	ORDER BY log.timestamp
`, metric.MetricID)
	if err != nil {
		return err
	}

	table := tablewriter.NewWriter(os.Stdout)
	table.SetHeader([]string{"Week", "Average", "Sum", "Count"})

	for rows.Next() {
		var avg float64
		var sum float64
		var count int
		var yearWeek string
		var week int
		var year int
		if err := rows.Scan(&avg, &sum, &count, &yearWeek, &year, &week); err != nil {
			return err
		}

		table.Append([]string{isoweek.StartTime(year, week, time.UTC).Format("2006-01-02"), strconv.FormatFloat(avg, 'f', -1, 64), strconv.FormatFloat(sum, 'f', -1, 64), strconv.Itoa(count)})
	}

	err = rows.Err()
	if err != nil {
		return err
	}

	table.Render()

	return tx.Commit()
}

// Streak prints the streak report.
func Streak(db *sql.DB, metricName string) error {
	sqlStmt := `
	SELECT 
	metric.id AS metric_id,
	log.value AS metric_value,
	log.id AS log_id
	FROM log
	INNER JOIN metric 
	ON metric.id = log.metric_id
	WHERE metric.name = ?
	ORDER BY log.timestamp
	DESC
	LIMIT 1
	`

	stmt, err := db.Prepare(sqlStmt)
	if err != nil {
		return err
	}
	defer stmt.Close()

	var metricID int
	var lastValue string
	var logID int
	err = stmt.QueryRow(metricName).Scan(&metricID, &lastValue, &logID)
	if err != nil {
		return err
	}

	// FIXME: This query will find all values for the metric and then walk back to tally up the streak. It will  skip a day if the metric does not exist. The code below should be updated to fill in the empty days when tallying up the streak.
	sqlStmt = `
	SELECT
	value AS metric_value
	FROM log
	WHERE metric_id = ?
	AND id != ?
	ORDER BY timestamp
	ASC
	`

	stmt, err = db.Prepare(sqlStmt)
	if err != nil {
		return err
	}
	defer stmt.Close()

	rows, err := stmt.Query(metricID, logID)
	if err != nil {
		return err
	}
	defer rows.Close()

	streakTally := 1
	for rows.Next() {
		var i string
		if err := rows.Scan(&i); err != nil {
			return err
		}
		if i == lastValue {
			streakTally++
		}
	}

	table := tablewriter.NewWriter(os.Stdout)
	table.SetHeader([]string{"Name", "Current Streak"})
	table.Append([]string{metricName, strconv.Itoa(streakTally)})
	table.Render()

	return nil
}