mirror of
https://github.com/hay-kot/homebox.git
synced 2025-05-28 08:02:31 +00:00
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:
parent
de419dc37d
commit
d6da63187b
19 changed files with 925 additions and 149 deletions
142
frontend/lib/api/__test__/user/stats.test.ts
Normal file
142
frontend/lib/api/__test__/user/stats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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<GroupStatistics>({
|
||||
url: route("/groups/statistics"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
42
frontend/lib/api/classes/stats.ts
Normal file
42
frontend/lib/api/classes/stats.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<string, string>;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue