Adds stats page

This commit is contained in:
Mihovil Ilakovac 2023-07-03 14:45:50 +02:00
parent 080f7cbacc
commit 68b88b2bcf
8 changed files with 320 additions and 41 deletions

View File

@ -17,7 +17,15 @@ app waspAi {
("async-mutex", "0.4.0"),
("@headlessui/react", "1.7.15"),
("@heroicons/react", "2.0.18"),
("react-parallax-tilt", "1.7.151")
("react-parallax-tilt", "1.7.151"),
("timeago.js", "4.0.2"),
("@visx/mock-data", "3.0.0"),
("@visx/group", "3.0.0"),
("@visx/shape", "3.0.0"),
("@visx/scale", "3.2.0"),
("@visx/responsive", "3.0.0"),
("@visx/gradient", "3.0.0"),
("@visx/axis", "3.2.0")
],
client: {
rootComponent: import { RootComponent } from "@client/RootComponent.jsx",
@ -37,6 +45,11 @@ page ResultPage {
component: import { ResultPage } from "@client/pages/ResultPage.jsx"
}
route StatsRoute { path: "/stats", to: StatsPage }
page StatsPage {
component: import { Stats } from "@client/pages/StatsPage.jsx"
}
action startGeneratingNewApp {
fn: import { startGeneratingNewApp } from "@server/operations.js",
entities: [
@ -53,6 +66,13 @@ query getAppGenerationResult {
]
}
query getStats {
fn: import { getStats } from "@server/operations.js",
entities: [
Project
]
}
entity Project {=psl
id String @id @default(uuid())
name String

View File

@ -0,0 +1,120 @@
import { useMemo } from "react";
import { Bar } from "@visx/shape";
import { Group } from "@visx/group";
import { scaleBand, scaleLinear } from "@visx/scale";
import { AxisBottom, AxisLeft } from "@visx/axis";
function generateLast24HoursData(projects) {
const buckets = [];
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
for (let i = 0; i < 24; i++) {
const bucketStart = new Date(last24Hours.getTime() + i * 60 * 60 * 1000);
const bucket = {
date: bucketStart,
count: 0,
};
buckets.push(bucket);
}
projects.forEach((project) => {
const createdAt = new Date(project.createdAt);
const bucketIndex = Math.floor(
(createdAt.getTime() - last24Hours.getTime()) / (60 * 60 * 1000)
);
// Count only projects that were created in the last 24 hours
if (bucketIndex >= 0 && bucketIndex < 24) {
buckets[bucketIndex].count++;
}
});
return buckets;
}
const verticalMargin = 50;
const margins = {
left: 30,
};
const getLetter = (d) => d.letter;
const getLetterFrequency = (d) => Number(d.frequency) * 100;
export function BarChart({ projects, width, height, events = false }) {
const data = useMemo(() => generateLast24HoursData(projects), [projects]);
// bounds
const xMax = width - margins.left;
const yMax = height - verticalMargin;
// scales, memoize for performance
const xScale = useMemo(
() =>
scaleBand({
range: [0, xMax],
round: true,
domain: data.map((bucket) => {
const hour = bucket.date.getHours();
return hour;
}),
padding: 0.4,
}),
[xMax]
);
const yScale = useMemo(
() =>
scaleLinear({
range: [yMax, 0],
round: true,
domain: [0, Math.max(...data.map((bucket) => bucket.count))],
}),
[yMax]
);
return width < 10 ? null : (
<svg width={width} height={height}>
<rect width={width} height={height} className="fill-slate-100" rx={14} />
<Group top={verticalMargin / 2} left={margins.left}>
{data.map((d) => {
const letter = getLetter(d);
const barWidth = xScale.bandwidth();
const barHeight = yMax - (yScale(getLetterFrequency(d)) ?? 0);
const barX = xScale(letter);
const barY = yMax - barHeight;
return (
<Bar
key={`bar-${letter}`}
x={barX}
y={barY}
width={barWidth}
height={barHeight}
className="fill-pink-300"
onClick={() => {
if (events)
alert(`clicked: ${JSON.stringify(Object.values(d))}`);
}}
/>
);
})}
<AxisBottom
numTicks={data.length}
top={yMax}
scale={xScale}
tickLabelProps={() => ({
fill: "#333",
fontSize: 11,
textAnchor: "middle",
})}
/>
<AxisLeft
scale={yScale.nice()}
numTicks={10}
top={0}
tickLabelProps={(e) => ({
fill: "#333",
fontSize: 10,
textAnchor: "end",
x: -12,
y: (yScale(e) ?? 0) + 3,
})}
/>
</Group>
</svg>
);
}

View File

@ -0,0 +1,38 @@
import tailwindColors from "tailwindcss/colors";
export function Color({ value }) {
return (
<div
className="w-5 h-5 rounded-full"
style={{
backgroundColor: value,
}}
></div>
);
}
export const availableColors = Object.entries(tailwindColors)
.map(([name, color]) => {
return {
name,
color: color[500],
};
})
.filter(
(color) =>
![
"black",
"white",
"transparent",
"inherit",
"current",
"lightBlue",
"warmGray",
"trueGray",
"coolGray",
"blueGray",
"gray",
"neutral",
"zinc",
].includes(color.name)
);

View File

@ -1,7 +1,7 @@
// @ts-check
import { Fragment, useState } from "react";
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { Color } from "./Color";
export function MyDropdown({ value, onChange, options }) {
return (
@ -63,12 +63,7 @@ function Option({ value, selected = false }) {
>
{value.color && (
<span className="mr-2">
<div
className={`w-5 h-5 rounded-full`}
style={{
backgroundColor: value.color,
}}
></div>
<Color value={value.color} />
</span>
)}
{value.name}

View File

@ -1,4 +1,4 @@
export function StatusPill({ children, status, className = "" }) {
export function StatusPill({ children, status, className = "", sm = false }) {
const statusToClassName = {
idle: "bg-gray-100 text-gray-700",
inProgress: "bg-sky-100 text-sky-700",
@ -9,7 +9,9 @@ export function StatusPill({ children, status, className = "" }) {
return (
<div className={`flex items-center ${className}`}>
<span
className={`text-center inline-flex items-center pl-3 pr-4 py-2 rounded-lg shadow-md ${statusToClassName[status]}`}
className={`text-center inline-flex items-center rounded-lg shadow-md ${statusToClassName[status]} ${
sm ? "py-1 pl-2 pr-2" : "py-2 pl-3 pr-4"
}`}
>
<span className="w-1.5 h-1.5 rounded-full mr-2 bg-current"></span>
{children}

View File

@ -2,9 +2,9 @@ import { useState, useMemo } from "react";
import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
import { useHistory } from "react-router-dom";
import { MyDropdown } from "../components/Dropdown";
import tailwindColors from "tailwindcss/colors";
import { ExampleCard } from "../components/ExampleCard";
import { Header } from "../components/Header";
import { availableColors } from "../components/Color";
const MainPage = () => {
const [appName, setAppName] = useState("");
@ -15,34 +15,6 @@ const MainPage = () => {
});
const history = useHistory();
const availableColors = useMemo(() => {
return Object.entries(tailwindColors)
.map(([name, color]) => {
return {
name,
color: color[500],
};
})
.filter(
(color) =>
![
"black",
"white",
"transparent",
"inherit",
"current",
"lightBlue",
"warmGray",
"trueGray",
"coolGray",
"blueGray",
"gray",
"neutral",
"zinc",
].includes(color.name)
);
}, []);
const [appPrimaryColor, setAppPrimaryColor] = useState(
availableColors.find((color) => color.name === "sky")
);

View File

@ -0,0 +1,120 @@
import { useMemo } from "react";
import getStats from "@wasp/queries/getStats";
import { useQuery } from "@wasp/queries";
import { Link } from "react-router-dom";
import { Color, availableColors } from "../components/Color";
import { format } from "timeago.js";
import { StatusPill } from "../components/StatusPill";
import { BarChart } from "../components/BarChart";
import ParentSize from "@visx/responsive/lib/components/ParentSize";
export function Stats() {
const { data: stats, isLoading, error } = useQuery(getStats);
function getColorValue(colorName) {
return availableColors.find((color) => color.name === colorName).color;
}
function getStatusName(status) {
switch (status) {
case "in-progress":
return "inProgress";
case "success":
return "success";
case "failure":
return "error";
default:
return "idle";
}
}
function getStatusText(status) {
switch (status) {
case "in-progress":
return "In progress";
case "success":
return "Success";
case "failure":
return "Error";
default:
return "Idle";
}
}
// Visx projects throught last 24 hours time bar chart
const projectInLast24Hours = useMemo(() => {
if (!stats) {
return [];
}
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return stats.projects.filter((project) => {
return project.createdAt > last24Hours;
});
}, [stats]);
return (
<div className="big-box">
<h1 className="text-3xl font-semibold text-slate-800 mb-4">Stats</h1>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
{stats && (
<>
<div style={{ height: 300, width: "100%" }} className="mb-4">
<ParentSize>
{({ width, height }) => <BarChart projects={stats.projects} width={width} height={height} />}
</ParentSize>
</div>
<div className="relative overflow-x-auto shadow-md sm:rounded-lg">
<table className="w-full text-sm text-left text-slate-500">
<thead className="text-xs text-slate-700 uppercase bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3">
App Name
</th>
<th scope="col" className="px-6 py-3">
Status
</th>
<th scope="col" className="px-6 py-3">
Created At
</th>
<th scope="col" className="px-6 py-3"></th>
</tr>
</thead>
<tbody>
{stats.projects.map((stat) => (
<tr className="bg-white border-b" key={stat.id}>
<th
scope="row"
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
>
<Color value={getColorValue(stat.primaryColor)} />{" "}
{stat.name}
</th>
<td className="px-6 py-4">
<StatusPill status={getStatusName(stat.status)} sm>
{getStatusText(stat.status)}
</StatusPill>
</td>
<td className="px-6 py-4" title={`${stat.createdAt.toLocaleDateString()} ${stat.createdAt.toLocaleTimeString()}`}>{format(stat.createdAt)}</td>
<td className="px-6 py-4">
<Link
to={`/result/${stat.id}`}
className="font-medium text-sky-600 hover:underline"
>
View the app &rarr;
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { StartGeneratingNewApp } from "@wasp/actions/types";
import { GetAppGenerationResult } from "@wasp/queries/types";
import { GetAppGenerationResult, GetStats } from "@wasp/queries/types";
import HttpError from "@wasp/core/HttpError.js";
import { spawn } from "child_process";
import { Mutex } from "async-mutex";
@ -196,3 +196,15 @@ export const getAppGenerationResult = (async (args, context) => {
}) satisfies GetAppGenerationResult<{
appId: string;
}>;
export const getStats = (async (args, context) => {
const { Project } = context.entities;
const projects = await Project.findMany({
orderBy: {
createdAt: "desc",
},
});
return {
projects,
};
}) satisfies GetStats<{}>;