From d6da63187b091488866e48d74da923d5266d6537 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 5 Dec 2022 12:36:32 -0900 Subject: [PATCH] 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 --- .vscode/settings.json | 15 +- backend/app/api/handlers/v1/v1_ctrl_group.go | 20 -- .../app/api/handlers/v1/v1_ctrl_statistics.go | 109 ++++++++++ backend/app/api/routes.go | 3 + backend/app/api/static/docs/docs.go | 190 ++++++++++++++---- backend/app/api/static/docs/swagger.json | 190 ++++++++++++++---- backend/app/api/static/docs/swagger.yaml | 119 ++++++++--- .../internal/data/migrations/migrations.go | 1 - backend/internal/data/repo/query_helpers.go | 18 ++ backend/internal/data/repo/repo_group.go | 169 +++++++++++++++- backend/internal/data/repo/repo_group_test.go | 2 +- frontend/lib/api/__test__/user/stats.test.ts | 142 +++++++++++++ frontend/lib/api/classes/group.ts | 8 +- frontend/lib/api/classes/items.ts | 2 +- frontend/lib/api/classes/stats.ts | 42 ++++ frontend/lib/api/types/data-contracts.ts | 22 ++ frontend/lib/api/user.ts | 3 + frontend/pages/home.vue | 2 +- frontend/pages/item/[id]/index.vue | 17 +- 19 files changed, 925 insertions(+), 149 deletions(-) create mode 100644 backend/app/api/handlers/v1/v1_ctrl_statistics.go create mode 100644 backend/internal/data/repo/query_helpers.go create mode 100644 frontend/lib/api/__test__/user/stats.test.ts create mode 100644 frontend/lib/api/classes/stats.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index b330533..f05ebc8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,4 @@ { - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, "yaml.schemas": { "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" }, @@ -13,5 +10,15 @@ }, "cSpell.words": [ "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" + }, + } diff --git a/backend/app/api/handlers/v1/v1_ctrl_group.go b/backend/app/api/handlers/v1/v1_ctrl_group.go index b27622d..a3e8992 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_group.go +++ b/backend/app/api/handlers/v1/v1_ctrl_group.go @@ -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 // @Summary Get the current user's group // @Tags Group diff --git a/backend/app/api/handlers/v1/v1_ctrl_statistics.go b/backend/app/api/handlers/v1/v1_ctrl_statistics.go new file mode 100644 index 0000000..6c09bc6 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_statistics.go @@ -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) + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index e5a7948..1a42aeb 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -79,6 +79,9 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), 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 a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), userMW...) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 6e8cebd..632b9e5 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -148,9 +148,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Group" + "Statistics" ], - "summary": "Get the current user's group", + "summary": "Get the current user's group statistics", "responses": { "200": { "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": { "get": { "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}": { "get": { "security": [ @@ -1237,6 +1292,9 @@ const docTemplate = `{ "repo.GroupStatistics": { "type": "object", "properties": { + "totalItemPrice": { + "type": "number" + }, "totalItems": { "type": "integer" }, @@ -1248,6 +1306,9 @@ const docTemplate = `{ }, "totalUsers": { "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": { "type": "object", "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": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index f09cffa..69ba931 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -140,9 +140,9 @@ "application/json" ], "tags": [ - "Group" + "Statistics" ], - "summary": "Get the current user's group", + "summary": "Get the current user's group statistics", "responses": { "200": { "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": { "get": { "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}": { "get": { "security": [ @@ -1229,6 +1284,9 @@ "repo.GroupStatistics": { "type": "object", "properties": { + "totalItemPrice": { + "type": "number" + }, "totalItems": { "type": "integer" }, @@ -1240,6 +1298,9 @@ }, "totalUsers": { "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": { "type": "object", "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": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index 1e3b528..802fdf8 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -24,6 +24,8 @@ definitions: type: object repo.GroupStatistics: properties: + totalItemPrice: + type: number totalItems: type: integer totalLabels: @@ -32,6 +34,8 @@ definitions: type: integer totalUsers: type: integer + totalWithWarranty: + type: integer type: object repo.GroupUpdate: properties: @@ -392,6 +396,15 @@ definitions: total: type: integer type: object + repo.TotalsByOrganizer: + properties: + id: + type: string + name: + type: string + total: + type: number + type: object repo.UserOut: properties: email: @@ -416,6 +429,30 @@ definitions: name: type: string 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: properties: error: @@ -608,9 +645,64 @@ paths: $ref: '#/definitions/repo.GroupStatistics' security: - Bearer: [] - summary: Get the current user's group + summary: Get the current user's group statistics 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: get: parameters: @@ -846,29 +938,6 @@ paths: summary: retrieves an attachment for an item tags: - 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: post: parameters: diff --git a/backend/internal/data/migrations/migrations.go b/backend/internal/data/migrations/migrations.go index 83354aa..5fcb8e3 100644 --- a/backend/internal/data/migrations/migrations.go +++ b/backend/internal/data/migrations/migrations.go @@ -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 } diff --git a/backend/internal/data/repo/query_helpers.go b/backend/internal/data/repo/query_helpers.go new file mode 100644 index 0000000..2205d81 --- /dev/null +++ b/backend/internal/data/repo/query_helpers.go @@ -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 +} diff --git a/backend/internal/data/repo/repo_group.go b/backend/internal/data/repo/repo_group.go index 9a9ef7a..8e5ea14 100644 --- a/backend/internal/data/repo/repo_group.go +++ b/backend/internal/data/repo/repo_group.go @@ -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 } diff --git a/backend/internal/data/repo/repo_group_test.go b/backend/internal/data/repo/repo_group_test.go index b608d16..4321fec 100644 --- a/backend/internal/data/repo/repo_group_test.go +++ b/backend/internal/data/repo/repo_group_test.go @@ -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) diff --git a/frontend/lib/api/__test__/user/stats.test.ts b/frontend/lib/api/__test__/user/stats.test.ts new file mode 100644 index 0000000..247b024 --- /dev/null +++ b/frontend/lib/api/__test__/user/stats.test.ts @@ -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 = {}; + const locationData: Record = {}; + + 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); + }); +}); diff --git a/frontend/lib/api/classes/group.ts b/frontend/lib/api/classes/group.ts index 9c8fefa..7468f09 100644 --- a/frontend/lib/api/classes/group.ts +++ b/frontend/lib/api/classes/group.ts @@ -1,5 +1,5 @@ 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 { createInvitation(data: GroupInvitationCreate) { @@ -21,10 +21,4 @@ export class GroupApi extends BaseAPI { url: route("/groups"), }); } - - statistics() { - return this.http.get({ - url: route("/groups/statistics"), - }); - } } diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index ee18f01..f4fb38d 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -50,7 +50,7 @@ export class ItemsApi extends BaseAPI { return payload; } - import(file: File) { + import(file: File | Blob) { const formData = new FormData(); formData.append("csv", file); diff --git a/frontend/lib/api/classes/stats.ts b/frontend/lib/api/classes/stats.ts new file mode 100644 index 0000000..b605270 --- /dev/null +++ b/frontend/lib/api/classes/stats.ts @@ -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({ + 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({ + url: route("/groups/statistics"), + }); + } + + labels() { + return this.http.get({ + url: route("/groups/statistics/labels"), + }); + } + + locations() { + return this.http.get({ + url: route("/groups/statistics/locations"), + }); + } +} diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 464b8e7..09f10e7 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -25,10 +25,12 @@ export interface Group { } export interface GroupStatistics { + totalItemPrice: number; totalItems: number; totalLabels: number; totalLocations: number; totalUsers: number; + totalWithWarranty: number; } export interface GroupUpdate { @@ -246,6 +248,12 @@ export interface PaginationResultRepoItemSummary { total: number; } +export interface TotalsByOrganizer { + id: string; + name: string; + total: number; +} + export interface UserOut { email: string; groupId: string; @@ -261,6 +269,20 @@ export interface UserUpdate { 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 { error: string; fields: Record; diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 31538a8..079f113 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -5,6 +5,7 @@ import { LocationsApi } from "./classes/locations"; import { GroupApi } from "./classes/group"; import { UserApi } from "./classes/users"; import { ActionsAPI } from "./classes/actions"; +import { StatsAPI } from "./classes/stats"; import { Requests } from "~~/lib/requests"; export class UserClient extends BaseAPI { @@ -14,6 +15,7 @@ export class UserClient extends BaseAPI { group: GroupApi; user: UserApi; actions: ActionsAPI; + stats: StatsAPI; constructor(requests: Requests, attachmentToken: string) { super(requests, attachmentToken); @@ -24,6 +26,7 @@ export class UserClient extends BaseAPI { this.group = new GroupApi(requests); this.user = new UserApi(requests); this.actions = new ActionsAPI(requests); + this.stats = new StatsAPI(requests); Object.freeze(this); } diff --git a/frontend/pages/home.vue b/frontend/pages/home.vue index 7723d95..92edee3 100644 --- a/frontend/pages/home.vue +++ b/frontend/pages/home.vue @@ -22,7 +22,7 @@ const labels = computed(() => labelsStore.labels); const { data: statistics } = useAsyncData(async () => { - const { data } = await api.group.statistics(); + const { data } = await api.stats.group(); return data; }); diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 256ff0c..ecafe01 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -396,9 +396,11 @@ - + -
+
@@ -465,9 +467,18 @@ -