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

15
.vscode/settings.json vendored
View file

@ -1,7 +1,4 @@
{ {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"yaml.schemas": { "yaml.schemas": {
"https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml"
}, },
@ -13,5 +10,15 @@
}, },
"cSpell.words": [ "cSpell.words": [
"debughandlers" "debughandlers"
] ],
// use ESLint to format code on save
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
} }

View file

@ -24,26 +24,6 @@ type (
} }
) )
// HandleGroupGet godoc
// @Summary Get the current user's group
// @Tags Group
// @Produce json
// @Success 200 {object} repo.GroupStatistics
// @Router /v1/groups/statistics [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
stats, err := ctrl.repo.Groups.GroupStatistics(ctx, ctx.GID)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}
// HandleGroupGet godoc // HandleGroupGet godoc
// @Summary Get the current user's group // @Summary Get the current user's group
// @Tags Group // @Tags Group

View file

@ -0,0 +1,109 @@
package v1
import (
"net/http"
"time"
"github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/sys/validate"
"github.com/hay-kot/homebox/backend/pkgs/server"
)
// HandleGroupGet godoc
// @Summary Get the current user's group statistics
// @Tags Statistics
// @Produce json
// @Success 200 {object} []repo.TotalsByOrganizer
// @Router /v1/groups/statistics/locations [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatisticsLocations() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
stats, err := ctrl.repo.Groups.StatsLocationsByPurchasePrice(ctx, ctx.GID)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}
// HandleGroupGet godoc
// @Summary Get the current user's group statistics
// @Tags Statistics
// @Produce json
// @Success 200 {object} []repo.TotalsByOrganizer
// @Router /v1/groups/statistics/labels [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatisticsLabels() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
stats, err := ctrl.repo.Groups.StatsLabelsByPurchasePrice(ctx, ctx.GID)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}
// HandleGroupGet godoc
// @Summary Get the current user's group statistics
// @Tags Statistics
// @Produce json
// @Success 200 {object} repo.GroupStatistics
// @Router /v1/groups/statistics [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
stats, err := ctrl.repo.Groups.StatsGroup(ctx, ctx.GID)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}
// HandleGroupGet godoc
// @Summary Queries the changes overtime of the purchase price over time
// @Tags Statistics
// @Produce json
// @Success 200 {object} repo.ValueOverTime
// @Param start query string false "start date"
// @Param end query string false "end date"
// @Router /v1/groups/statistics/purchase-price [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() server.HandlerFunc {
parseDate := func(datestr string, defaultDate time.Time) (time.Time, error) {
if datestr == "" {
return defaultDate, nil
}
return time.Parse("2006-01-02", datestr)
}
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
startDate, err := parseDate(r.URL.Query().Get("start"), time.Now().AddDate(0, -1, 0))
if err != nil {
return validate.NewRequestError(err, http.StatusBadRequest)
}
endDate, err := parseDate(r.URL.Query().Get("end"), time.Now())
if err != nil {
return validate.NewRequestError(err, http.StatusBadRequest)
}
stats, err := ctrl.repo.Groups.StatsPurchasePrice(ctx, ctx.GID, startDate, endDate)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}

View file

@ -79,6 +79,9 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), userMW...) a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), userMW...)
a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), userMW...) a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), userMW...)
a.server.Get(v1Base("/groups/statistics/purchase-price"), v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...)
a.server.Get(v1Base("/groups/statistics/locations"), v1Ctrl.HandleGroupStatisticsLocations(), userMW...)
a.server.Get(v1Base("/groups/statistics/labels"), v1Ctrl.HandleGroupStatisticsLabels(), userMW...)
// TODO: I don't like /groups being the URL for users // TODO: I don't like /groups being the URL for users
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), userMW...) a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), userMW...)

View file

@ -148,9 +148,9 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Group" "Statistics"
], ],
"summary": "Get the current user's group", "summary": "Get the current user's group statistics",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -161,6 +161,98 @@ const docTemplate = `{
} }
} }
}, },
"/v1/groups/statistics/labels": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Get the current user's group statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.TotalsByOrganizer"
}
}
}
}
}
},
"/v1/groups/statistics/locations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Get the current user's group statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.TotalsByOrganizer"
}
}
}
}
}
},
"/v1/groups/statistics/purchase-price": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Queries the changes overtime of the purchase price over time",
"parameters": [
{
"type": "string",
"description": "start date",
"name": "start",
"in": "query"
},
{
"type": "string",
"description": "end date",
"name": "end",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.ValueOverTime"
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -449,43 +541,6 @@ const docTemplate = `{
} }
} }
}, },
"/v1/items/{id}/attachments/download": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/octet-stream"
],
"tags": [
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Attachment token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/items/{id}/attachments/{attachment_id}": { "/v1/items/{id}/attachments/{attachment_id}": {
"get": { "get": {
"security": [ "security": [
@ -1237,6 +1292,9 @@ const docTemplate = `{
"repo.GroupStatistics": { "repo.GroupStatistics": {
"type": "object", "type": "object",
"properties": { "properties": {
"totalItemPrice": {
"type": "number"
},
"totalItems": { "totalItems": {
"type": "integer" "type": "integer"
}, },
@ -1248,6 +1306,9 @@ const docTemplate = `{
}, },
"totalUsers": { "totalUsers": {
"type": "integer" "type": "integer"
},
"totalWithWarranty": {
"type": "integer"
} }
} }
}, },
@ -1784,6 +1845,20 @@ const docTemplate = `{
} }
} }
}, },
"repo.TotalsByOrganizer": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"total": {
"type": "number"
}
}
},
"repo.UserOut": { "repo.UserOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1821,6 +1896,43 @@ const docTemplate = `{
} }
} }
}, },
"repo.ValueOverTime": {
"type": "object",
"properties": {
"end": {
"type": "string"
},
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ValueOverTimeEntry"
}
},
"start": {
"type": "string"
},
"valueAtEnd": {
"type": "number"
},
"valueAtStart": {
"type": "number"
}
}
},
"repo.ValueOverTimeEntry": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"name": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"server.ErrorResponse": { "server.ErrorResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -140,9 +140,9 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Group" "Statistics"
], ],
"summary": "Get the current user's group", "summary": "Get the current user's group statistics",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -153,6 +153,98 @@
} }
} }
}, },
"/v1/groups/statistics/labels": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Get the current user's group statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.TotalsByOrganizer"
}
}
}
}
}
},
"/v1/groups/statistics/locations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Get the current user's group statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.TotalsByOrganizer"
}
}
}
}
}
},
"/v1/groups/statistics/purchase-price": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Statistics"
],
"summary": "Queries the changes overtime of the purchase price over time",
"parameters": [
{
"type": "string",
"description": "start date",
"name": "start",
"in": "query"
},
{
"type": "string",
"description": "end date",
"name": "end",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.ValueOverTime"
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -441,43 +533,6 @@
} }
} }
}, },
"/v1/items/{id}/attachments/download": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/octet-stream"
],
"tags": [
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Attachment token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/v1/items/{id}/attachments/{attachment_id}": { "/v1/items/{id}/attachments/{attachment_id}": {
"get": { "get": {
"security": [ "security": [
@ -1229,6 +1284,9 @@
"repo.GroupStatistics": { "repo.GroupStatistics": {
"type": "object", "type": "object",
"properties": { "properties": {
"totalItemPrice": {
"type": "number"
},
"totalItems": { "totalItems": {
"type": "integer" "type": "integer"
}, },
@ -1240,6 +1298,9 @@
}, },
"totalUsers": { "totalUsers": {
"type": "integer" "type": "integer"
},
"totalWithWarranty": {
"type": "integer"
} }
} }
}, },
@ -1776,6 +1837,20 @@
} }
} }
}, },
"repo.TotalsByOrganizer": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"total": {
"type": "number"
}
}
},
"repo.UserOut": { "repo.UserOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1813,6 +1888,43 @@
} }
} }
}, },
"repo.ValueOverTime": {
"type": "object",
"properties": {
"end": {
"type": "string"
},
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ValueOverTimeEntry"
}
},
"start": {
"type": "string"
},
"valueAtEnd": {
"type": "number"
},
"valueAtStart": {
"type": "number"
}
}
},
"repo.ValueOverTimeEntry": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"name": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"server.ErrorResponse": { "server.ErrorResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -24,6 +24,8 @@ definitions:
type: object type: object
repo.GroupStatistics: repo.GroupStatistics:
properties: properties:
totalItemPrice:
type: number
totalItems: totalItems:
type: integer type: integer
totalLabels: totalLabels:
@ -32,6 +34,8 @@ definitions:
type: integer type: integer
totalUsers: totalUsers:
type: integer type: integer
totalWithWarranty:
type: integer
type: object type: object
repo.GroupUpdate: repo.GroupUpdate:
properties: properties:
@ -392,6 +396,15 @@ definitions:
total: total:
type: integer type: integer
type: object type: object
repo.TotalsByOrganizer:
properties:
id:
type: string
name:
type: string
total:
type: number
type: object
repo.UserOut: repo.UserOut:
properties: properties:
email: email:
@ -416,6 +429,30 @@ definitions:
name: name:
type: string type: string
type: object type: object
repo.ValueOverTime:
properties:
end:
type: string
entries:
items:
$ref: '#/definitions/repo.ValueOverTimeEntry'
type: array
start:
type: string
valueAtEnd:
type: number
valueAtStart:
type: number
type: object
repo.ValueOverTimeEntry:
properties:
date:
type: string
name:
type: string
value:
type: number
type: object
server.ErrorResponse: server.ErrorResponse:
properties: properties:
error: error:
@ -608,9 +645,64 @@ paths:
$ref: '#/definitions/repo.GroupStatistics' $ref: '#/definitions/repo.GroupStatistics'
security: security:
- Bearer: [] - Bearer: []
summary: Get the current user's group summary: Get the current user's group statistics
tags: tags:
- Group - Statistics
/v1/groups/statistics/labels:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.TotalsByOrganizer'
type: array
security:
- Bearer: []
summary: Get the current user's group statistics
tags:
- Statistics
/v1/groups/statistics/locations:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.TotalsByOrganizer'
type: array
security:
- Bearer: []
summary: Get the current user's group statistics
tags:
- Statistics
/v1/groups/statistics/purchase-price:
get:
parameters:
- description: start date
in: query
name: start
type: string
- description: end date
in: query
name: end
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.ValueOverTime'
security:
- Bearer: []
summary: Queries the changes overtime of the purchase price over time
tags:
- Statistics
/v1/items: /v1/items:
get: get:
parameters: parameters:
@ -846,29 +938,6 @@ paths:
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
tags: tags:
- Items Attachments - Items Attachments
/v1/items/{id}/attachments/download:
get:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Attachment token
in: query
name: token
required: true
type: string
produces:
- application/octet-stream
responses:
"200":
description: OK
security:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items Attachments
/v1/items/import: /v1/items/import:
post: post:
parameters: parameters:

View file

@ -14,7 +14,6 @@ var Files embed.FS
// should be called when the migrations are no longer needed. // should be called when the migrations are no longer needed.
func Write(temp string) error { func Write(temp string) error {
err := os.MkdirAll(temp, 0755) err := os.MkdirAll(temp, 0755)
if err != nil { if err != nil {
return err 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" "strings"
"time" "time"
"entgo.io/ent/dialect/sql"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/data/ent" "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/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/groupinvitationtoken" "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 { type GroupRepository struct {
@ -41,11 +45,34 @@ type (
Uses int `json:"uses"` Uses int `json:"uses"`
Group Group `json:"group"` Group Group `json:"group"`
} }
GroupStatistics struct { GroupStatistics struct {
TotalUsers int `json:"totalUsers"` TotalUsers int `json:"totalUsers"`
TotalItems int `json:"totalItems"` TotalItems int `json:"totalItems"`
TotalLocations int `json:"totalLocations"` TotalLocations int `json:"totalLocations"`
TotalLabels int `json:"totalLabels"` 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 := ` q := `
SELECT SELECT
(SELECT COUNT(*) FROM users WHERE group_users = ?) AS total_users, (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 items WHERE group_items = ? AND items.archived = false) AS total_items,
(SELECT COUNT(*) FROM locations WHERE group_locations = ?) AS total_locations, (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 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 { if err != nil {
return GroupStatistics{}, err return GroupStatistics{}, err
} }

View file

@ -36,7 +36,7 @@ func Test_Group_GroupStatistics(t *testing.T) {
useItems(t, 20) useItems(t, 20)
useLabels(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.NoError(t, err)
assert.Equal(t, 20, stats.TotalItems) assert.Equal(t, 20, stats.TotalItems)

View file

@ -0,0 +1,142 @@
import { faker } from "@faker-js/faker";
import { beforeAll, describe, expect, test } from "vitest";
import { UserClient } from "../../user";
import { factories } from "../factories";
type ImportObj = {
ImportRef: string;
Location: string;
Labels: string;
Quantity: string;
Name: string;
Description: string;
Insured: boolean;
SerialNumber: string;
ModelNumber: string;
Manufacturer: string;
Notes: string;
PurchaseFrom: string;
PurchasedPrice: number;
PurchasedTime: string;
LifetimeWarranty: boolean;
WarrantyExpires: string;
WarrantyDetails: string;
SoldTo: string;
SoldPrice: number;
SoldTime: string;
SoldNotes: string;
};
function toCsv(data: ImportObj[]): string {
const headers = Object.keys(data[0]).join("\t");
const rows = data.map(row => {
return Object.values(row).join("\t");
});
return [headers, ...rows].join("\n");
}
function importFileGenerator(entries: number): ImportObj[] {
const imports: ImportObj[] = [];
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
const labels = faker.random.words(5).split(" ").join(";");
const locations = faker.random.words(3).split(" ");
const half = Math.floor(entries / 2);
for (let i = 0; i < entries; i++) {
imports.push({
ImportRef: faker.database.mongodbObjectId(),
Location: pick(locations),
Labels: labels,
Quantity: faker.random.numeric(1),
Name: faker.random.words(3),
Description: "",
Insured: faker.datatype.boolean(),
SerialNumber: faker.random.alphaNumeric(5),
ModelNumber: faker.random.alphaNumeric(5),
Manufacturer: faker.random.alphaNumeric(5),
Notes: "",
PurchaseFrom: faker.name.fullName(),
PurchasedPrice: faker.datatype.number(100),
PurchasedTime: faker.date.past().toDateString(),
LifetimeWarranty: half > i,
WarrantyExpires: faker.date.future().toDateString(),
WarrantyDetails: "",
SoldTo: faker.name.fullName(),
SoldPrice: faker.datatype.number(100),
SoldTime: faker.date.past().toDateString(),
SoldNotes: "",
});
}
return imports;
}
describe("group related statistics tests", () => {
const TOTAL_ITEMS = 30;
let api: UserClient | undefined;
const imports = importFileGenerator(TOTAL_ITEMS);
beforeAll(async () => {
// -- Setup --
const { client } = await factories.client.singleUse();
api = client;
const csv = toCsv(imports);
const setupResp = await client.items.import(new Blob([csv], { type: "text/csv" }));
expect(setupResp.status).toBe(204);
});
// Write to file system for debugging
// fs.writeFileSync("test.csv", csv);
test("Validate Group Statistics", async () => {
const { status, data } = await api.stats.group();
expect(status).toBe(200);
expect(data.totalItems).toEqual(TOTAL_ITEMS);
expect(data.totalLabels).toEqual(11); // default + new
expect(data.totalLocations).toEqual(11); // default + new
expect(data.totalUsers).toEqual(1);
expect(data.totalWithWarranty).toEqual(Math.floor(TOTAL_ITEMS / 2));
});
const labelData: Record<string, number> = {};
const locationData: Record<string, number> = {};
for (const item of imports) {
for (const label of item.Labels.split(";")) {
labelData[label] = (labelData[label] || 0) + item.PurchasedPrice;
}
locationData[item.Location] = (locationData[item.Location] || 0) + item.PurchasedPrice;
}
test("Validate Labels Statistics", async () => {
const { status, data } = await api.stats.labels();
expect(status).toBe(200);
for (const label of data) {
expect(label.total).toEqual(labelData[label.name]);
}
});
test("Validate Locations Statistics", async () => {
const { status, data } = await api.stats.locations();
expect(status).toBe(200);
for (const location of data) {
expect(location.total).toEqual(locationData[location.name]);
}
});
test("Validate Purchase Over Time", async () => {
const { status, data } = await api.stats.totalPriceOverTime();
expect(status).toBe(200);
expect(data.entries.length).toEqual(TOTAL_ITEMS);
});
});

View file

@ -1,5 +1,5 @@
import { BaseAPI, route } from "../base"; import { BaseAPI, route } from "../base";
import { Group, GroupInvitation, GroupInvitationCreate, GroupStatistics, GroupUpdate } from "../types/data-contracts"; import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
export class GroupApi extends BaseAPI { export class GroupApi extends BaseAPI {
createInvitation(data: GroupInvitationCreate) { createInvitation(data: GroupInvitationCreate) {
@ -21,10 +21,4 @@ export class GroupApi extends BaseAPI {
url: route("/groups"), url: route("/groups"),
}); });
} }
statistics() {
return this.http.get<GroupStatistics>({
url: route("/groups/statistics"),
});
}
} }

View file

@ -50,7 +50,7 @@ export class ItemsApi extends BaseAPI {
return payload; return payload;
} }
import(file: File) { import(file: File | Blob) {
const formData = new FormData(); const formData = new FormData();
formData.append("csv", file); formData.append("csv", file);

View file

@ -0,0 +1,42 @@
import { BaseAPI, route } from "../base";
import { GroupStatistics, TotalsByOrganizer, ValueOverTime } from "../types/data-contracts";
function YYYY_DD_MM(date?: Date): string {
if (!date) {
return "";
}
// with leading zeros
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
export class StatsAPI extends BaseAPI {
totalPriceOverTime(start?: Date, end?: Date) {
return this.http.get<ValueOverTime>({
url: route("/groups/statistics/purchase-price", { start: YYYY_DD_MM(start), end: YYYY_DD_MM(end) }),
});
}
/**
* Returns ths general statistics for the group. This mostly just
* includes the totals for various group properties.
*/
group() {
return this.http.get<GroupStatistics>({
url: route("/groups/statistics"),
});
}
labels() {
return this.http.get<TotalsByOrganizer[]>({
url: route("/groups/statistics/labels"),
});
}
locations() {
return this.http.get<TotalsByOrganizer[]>({
url: route("/groups/statistics/locations"),
});
}
}

View file

@ -25,10 +25,12 @@ export interface Group {
} }
export interface GroupStatistics { export interface GroupStatistics {
totalItemPrice: number;
totalItems: number; totalItems: number;
totalLabels: number; totalLabels: number;
totalLocations: number; totalLocations: number;
totalUsers: number; totalUsers: number;
totalWithWarranty: number;
} }
export interface GroupUpdate { export interface GroupUpdate {
@ -246,6 +248,12 @@ export interface PaginationResultRepoItemSummary {
total: number; total: number;
} }
export interface TotalsByOrganizer {
id: string;
name: string;
total: number;
}
export interface UserOut { export interface UserOut {
email: string; email: string;
groupId: string; groupId: string;
@ -261,6 +269,20 @@ export interface UserUpdate {
name: string; name: string;
} }
export interface ValueOverTime {
end: string;
entries: ValueOverTimeEntry[];
start: string;
valueAtEnd: number;
valueAtStart: number;
}
export interface ValueOverTimeEntry {
date: string;
name: string;
value: number;
}
export interface ServerErrorResponse { export interface ServerErrorResponse {
error: string; error: string;
fields: Record<string, string>; fields: Record<string, string>;

View file

@ -5,6 +5,7 @@ import { LocationsApi } from "./classes/locations";
import { GroupApi } from "./classes/group"; import { GroupApi } from "./classes/group";
import { UserApi } from "./classes/users"; import { UserApi } from "./classes/users";
import { ActionsAPI } from "./classes/actions"; import { ActionsAPI } from "./classes/actions";
import { StatsAPI } from "./classes/stats";
import { Requests } from "~~/lib/requests"; import { Requests } from "~~/lib/requests";
export class UserClient extends BaseAPI { export class UserClient extends BaseAPI {
@ -14,6 +15,7 @@ export class UserClient extends BaseAPI {
group: GroupApi; group: GroupApi;
user: UserApi; user: UserApi;
actions: ActionsAPI; actions: ActionsAPI;
stats: StatsAPI;
constructor(requests: Requests, attachmentToken: string) { constructor(requests: Requests, attachmentToken: string) {
super(requests, attachmentToken); super(requests, attachmentToken);
@ -24,6 +26,7 @@ export class UserClient extends BaseAPI {
this.group = new GroupApi(requests); this.group = new GroupApi(requests);
this.user = new UserApi(requests); this.user = new UserApi(requests);
this.actions = new ActionsAPI(requests); this.actions = new ActionsAPI(requests);
this.stats = new StatsAPI(requests);
Object.freeze(this); Object.freeze(this);
} }

View file

@ -22,7 +22,7 @@
const labels = computed(() => labelsStore.labels); const labels = computed(() => labelsStore.labels);
const { data: statistics } = useAsyncData(async () => { const { data: statistics } = useAsyncData(async () => {
const { data } = await api.group.statistics(); const { data } = await api.stats.group();
return data; return data;
}); });

View file

@ -396,9 +396,11 @@
<DetailsSection :details="itemDetails" /> <DetailsSection :details="itemDetails" />
</BaseCard> </BaseCard>
<BaseCard> <BaseCard v-if="photos && photos.length > 0">
<template #title> Photos </template> <template #title> Photos </template>
<div class="container p-4 flex flex-wrap gap-2 mx-auto max-h-[500px] overflow-scroll"> <div
class="container border-t border-gray-300 p-4 flex flex-wrap gap-2 mx-auto max-h-[500px] overflow-y-scroll scroll-bg"
>
<button v-for="(img, i) in photos" :key="i" @click="openDialog(img)"> <button v-for="(img, i) in photos" :key="i" @click="openDialog(img)">
<img class="rounded max-h-[200px]" :src="img.src" /> <img class="rounded max-h-[200px]" :src="img.src" />
</button> </button>
@ -465,9 +467,18 @@
</BaseContainer> </BaseContainer>
</template> </template>
<style> <style lang="css" scoped>
/* Style dialog background */ /* Style dialog background */
dialog::backdrop { dialog::backdrop {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
.scroll-bg::-webkit-scrollbar {
width: 0.5rem;
}
.scroll-bg::-webkit-scrollbar-thumb {
border-radius: 0.25rem;
@apply bg-base-300;
}
</style> </style>