feat: new homepage statistic API's (#167)

* add date format and orDefault helpers

* introduce new statistics calculations queries

* rework statistics endpoints

* code generation

* fix styles on photo card

* label and location aggregation endpoints

* code-gen

* cleanup parser and defaults

* remove debug point

* setup E2E Testing

* linters

* formatting

* fmt plus name support on time series data

* code gen
This commit is contained in:
Hayden 2022-12-05 12:36:32 -09:00 committed by GitHub
parent de419dc37d
commit d6da63187b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 925 additions and 149 deletions

View file

@ -14,7 +14,6 @@ var Files embed.FS
// should be called when the migrations are no longer needed.
func Write(temp string) error {
err := os.MkdirAll(temp, 0755)
if err != nil {
return err
}

View file

@ -0,0 +1,18 @@
package repo
import "time"
func sqliteDateFormat(t time.Time) string {
return t.Format("2006-01-02 15:04:05")
}
// orDefault returns the value of the pointer if it is not nil, otherwise it returns the default value
//
// This is used for nullable or potentially nullable fields (or aggregates) in the database when running
// queries. If the field is null, the pointer will be nil, so we return the default value instead.
func orDefault[T any](v *T, def T) T {
if v == nil {
return def
}
return *v
}

View file

@ -5,10 +5,14 @@ import (
"strings"
"time"
"entgo.io/ent/dialect/sql"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/ent/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/groupinvitationtoken"
"github.com/hay-kot/homebox/backend/internal/data/ent/item"
"github.com/hay-kot/homebox/backend/internal/data/ent/label"
"github.com/hay-kot/homebox/backend/internal/data/ent/location"
)
type GroupRepository struct {
@ -41,11 +45,34 @@ type (
Uses int `json:"uses"`
Group Group `json:"group"`
}
GroupStatistics struct {
TotalUsers int `json:"totalUsers"`
TotalItems int `json:"totalItems"`
TotalLocations int `json:"totalLocations"`
TotalLabels int `json:"totalLabels"`
TotalUsers int `json:"totalUsers"`
TotalItems int `json:"totalItems"`
TotalLocations int `json:"totalLocations"`
TotalLabels int `json:"totalLabels"`
TotalItemPrice float64 `json:"totalItemPrice"`
TotalWithWarranty int `json:"totalWithWarranty"`
}
ValueOverTimeEntry struct {
Date time.Time `json:"date"`
Value float64 `json:"value"`
Name string `json:"name"`
}
ValueOverTime struct {
PriceAtStart float64 `json:"valueAtStart"`
PriceAtEnd float64 `json:"valueAtEnd"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Entries []ValueOverTimeEntry `json:"entries"`
}
TotalsByOrganizer struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Total float64 `json:"total"`
}
)
@ -76,18 +103,144 @@ func mapToGroupInvitation(g *ent.GroupInvitationToken) GroupInvitation {
}
}
func (r *GroupRepository) GroupStatistics(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
func (r *GroupRepository) StatsLocationsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) {
var v []TotalsByOrganizer
err := r.db.Location.Query().
Where(
location.HasGroupWith(group.ID(GID)),
).
GroupBy(location.FieldID, location.FieldName).
Aggregate(func(sq *sql.Selector) string {
t := sql.Table(item.Table)
sq.Join(t).On(sq.C(location.FieldID), t.C(item.LocationColumn))
return sql.As(sql.Sum(t.C(item.FieldPurchasePrice)), "total")
}).
Scan(ctx, &v)
if err != nil {
return nil, err
}
return v, err
}
func (r *GroupRepository) StatsLabelsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) {
var v []TotalsByOrganizer
err := r.db.Label.Query().
Where(
label.HasGroupWith(group.ID(GID)),
).
GroupBy(label.FieldID, label.FieldName).
Aggregate(func(sq *sql.Selector) string {
itemTable := sql.Table(item.Table)
jt := sql.Table(label.ItemsTable)
sq.Join(jt).On(sq.C(label.FieldID), jt.C(label.ItemsPrimaryKey[0]))
sq.Join(itemTable).On(jt.C(label.ItemsPrimaryKey[1]), itemTable.C(item.FieldID))
return sql.As(sql.Sum(itemTable.C(item.FieldPurchasePrice)), "total")
}).
Scan(ctx, &v)
if err != nil {
return nil, err
}
return v, err
}
func (r *GroupRepository) StatsPurchasePrice(ctx context.Context, GID uuid.UUID, start, end time.Time) (*ValueOverTime, error) {
// Get the Totals for the Start and End of the Given Time Period
q := `
SELECT
(SELECT Sum(purchase_price)
FROM items
WHERE group_items = ?
AND items.archived = false
AND items.created_at < ?) AS price_at_start,
(SELECT Sum(purchase_price)
FROM items
WHERE group_items = ?
AND items.archived = false
AND items.created_at < ?) AS price_at_end
`
stats := ValueOverTime{
Start: start,
End: end,
}
var maybeStart *float64
var maybeEnd *float64
row := r.db.Sql().QueryRowContext(ctx, q, GID, sqliteDateFormat(start), GID, sqliteDateFormat(end))
err := row.Scan(&maybeStart, &maybeEnd)
if err != nil {
return nil, err
}
stats.PriceAtStart = orDefault(maybeStart, 0)
stats.PriceAtEnd = orDefault(maybeEnd, 0)
var v []struct {
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
PurchasePrice float64 `json:"purchase_price"`
}
// Get Created Date and Price of all items between start and end
err = r.db.Item.Query().
Where(
item.HasGroupWith(group.ID(GID)),
item.CreatedAtGTE(start),
item.CreatedAtLTE(end),
item.Archived(false),
).
Select(
item.FieldName,
item.FieldCreatedAt,
item.FieldPurchasePrice,
).
Scan(ctx, &v)
if err != nil {
return nil, err
}
stats.Entries = make([]ValueOverTimeEntry, len(v))
for i, vv := range v {
stats.Entries[i] = ValueOverTimeEntry{
Date: vv.CreatedAt,
Value: vv.PurchasePrice,
}
}
return &stats, nil
}
func (r *GroupRepository) StatsGroup(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
q := `
SELECT
(SELECT COUNT(*) FROM users WHERE group_users = ?) AS total_users,
(SELECT COUNT(*) FROM items WHERE group_items = ? AND items.archived = false) AS total_items,
(SELECT COUNT(*) FROM locations WHERE group_locations = ?) AS total_locations,
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels,
(SELECT SUM(purchase_price) FROM items WHERE group_items = ? AND items.archived = false) AS total_item_price,
(SELECT COUNT(*)
FROM items
WHERE group_items = ?
AND items.archived = false
AND (items.lifetime_warranty = true OR items.warranty_expires > date())
) AS total_with_warranty
`
var stats GroupStatistics
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID)
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID, GID, GID)
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels)
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels, &stats.TotalItemPrice, &stats.TotalWithWarranty)
if err != nil {
return GroupStatistics{}, err
}

View file

@ -36,7 +36,7 @@ func Test_Group_GroupStatistics(t *testing.T) {
useItems(t, 20)
useLabels(t, 20)
stats, err := tRepos.Groups.GroupStatistics(context.Background(), tGroup.ID)
stats, err := tRepos.Groups.StatsGroup(context.Background(), tGroup.ID)
assert.NoError(t, err)
assert.Equal(t, 20, stats.TotalItems)