Skip to content

Commit

Permalink
User statistics (#17)
Browse files Browse the repository at this point in the history
* User statistics

* db types
  • Loading branch information
kubk authored Feb 23, 2024
1 parent 9ee5d16 commit f5826a5
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 2 deletions.
5 changes: 5 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from "../../functions/order.ts";
import { MyPlansResponse } from "../../functions/my-plans.ts";
import { DuplicateFolderResponse } from "../../functions/duplicate-folder.ts";
import { MyStatisticsResponse } from "../../functions/my-statistics.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand Down Expand Up @@ -163,3 +164,7 @@ export const createOrderRequest = (planId: number) => {
export const myPlansRequest = () => {
return request<MyPlansResponse>("/my-plans");
};

export const myStatisticsRequest = () => {
return request<MyStatisticsResponse>("/my-statistics");
}
9 changes: 9 additions & 0 deletions src/screens/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { FolderFormStoreProvider } from "./folder-form/store/folder-form-store-c
import { FolderScreen } from "./folder-review/folder-screen.tsx";
import { useSettingsButton } from "../lib/telegram/use-settings-button.ts";
import { ComponentCatalogPage } from "./component-catalog/component-catalog-page.tsx";
import { UserStatisticsStoreProvider } from "./user-statistics/store/user-statistics-store-context.tsx";
import { UserStatisticsScreen } from "./user-statistics/user-statistics-screen.tsx";

export const App = observer(() => {
useRestoreFullScreenExpand();
Expand Down Expand Up @@ -125,6 +127,13 @@ export const App = observer(() => {
<ComponentCatalogPage />
</PreventTelegramSwipeDownClosingIos>
)}
{screenStore.screen.type === "userStatistics" && (
<PreventTelegramSwipeDownClosingIos>
<UserStatisticsStoreProvider>
<UserStatisticsScreen />
</UserStatisticsStoreProvider>
</PreventTelegramSwipeDownClosingIos>
)}
</div>
);
});
12 changes: 12 additions & 0 deletions src/screens/deck-list/main-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,19 @@ export const MainScreen = observer(() => {
{t("telegram_channel")}
</Button>
</div>

<div>
<ListHeader text={"Profile"} />
<Button
icon={"mdi-chart-bar"}
onClick={() => {
screenStore.go({ type: "userStatistics" });
}}
>
{t("user_stats_btn")}
</Button>
<div className={css({ height: 8 })} />

<Button
icon={"mdi-cog"}
onClick={() => {
Expand Down
9 changes: 7 additions & 2 deletions src/screens/shared/deck-loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { css } from "@emotion/css";
import React from "react";
import ContentLoader from "react-content-loader";

export const DeckLoading = () => {
type Props = {
speed?: number;
};

export const DeckLoading = (props: Props) => {
const speed = props.speed || 2;
return (
<div
className={css({
Expand All @@ -17,7 +22,7 @@ export const DeckLoading = () => {
})}
>
<ContentLoader
speed={2}
speed={speed}
width={"100%"}
height={20}
viewBox="0 0 400 20"
Expand Down
16 changes: 16 additions & 0 deletions src/screens/user-statistics/legend-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { css } from "@emotion/css";
import React from "react";

export const LegendItem = (props: { color: string }) => {
const { color } = props;
return (
<div
className={css({
height: 14,
width: 14,
backgroundColor: color,
borderRadius: 4
})}
/>
);
};
86 changes: 86 additions & 0 deletions src/screens/user-statistics/pie-chart-canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { colord } from "colord";
import { theme } from "../../ui/theme.tsx";
import React, { useEffect, useRef } from "react";

export type PieChartData = {
interval_range: string;
frequency: number;
};

export const chartStart = colord(theme.buttonColorLighter)
.lighten(0.05)
.toRgbString();

export const chartFinish = colord(theme.buttonColorComputed)
.darken(0.2)
.toRgbString();

const interpolateColor = (
color1: string,
color2: string,
factor: number,
): string => {
// Assumes color1 and color2 are CSS color strings "rgb(r, g, b)"
const result = color1
.slice(4, -1)
.split(",")
.map(Number)
.map((c1, i) => {
const c2 = Number(color2.slice(4, -1).split(",")[i]);
return Math.round(c1 + (c2 - c1) * factor);
});
return `rgb(${result.join(", ")})`;
};

type Props = {
data: PieChartData[];
width: number;
height: number;
};

export const PieChartCanvas = ({ data, width, height }: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
if (!canvasRef.current) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}

ctx.clearRect(0, 0, width, height);

const totalFrequency = data.reduce((acc, item) => acc + item.frequency, 0);
let startAngle = 0;

data.forEach((item, index) => {
const sliceAngle = (item.frequency / totalFrequency) * 2 * Math.PI;
const endAngle = startAngle + sliceAngle;

ctx.beginPath();
ctx.moveTo(width / 2, height / 2);
ctx.arc(
width / 2,
height / 2,
Math.min(width, height) / 2,
startAngle,
endAngle,
);
ctx.closePath();

ctx.fillStyle = interpolateColor(
chartStart,
chartFinish,
index / (data.length - 1),
);
ctx.fill();

startAngle = endAngle;
});
}, [data, height, width]);

return <canvas ref={canvasRef} width={width} height={height} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createContext, ReactNode, useContext } from "react";
import { UserStatisticsStore } from "./user-statistics-store.ts";
import { assert } from "../../../lib/typescript/assert.ts";

const Context = createContext<UserStatisticsStore | null>(null);

export const UserStatisticsStoreProvider = (props: { children: ReactNode }) => {
return (
<Context.Provider value={new UserStatisticsStore()}>
{props.children}
</Context.Provider>
);
};

export const useUserStatisticsStore = () => {
const store = useContext(Context);
assert(store, "UserStatisticsStoreProvider not found");
return store;
};
47 changes: 47 additions & 0 deletions src/screens/user-statistics/store/user-statistics-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { makeAutoObservable } from "mobx";
import { fromPromise, IPromiseBasedObservable } from "mobx-utils";
import { MyStatisticsResponse } from "../../../../functions/my-statistics.ts";
import { myStatisticsRequest } from "../../../api/api.ts";
import { PieChartData } from "../pie-chart-canvas.tsx";

export class UserStatisticsStore {
userStatistics?: IPromiseBasedObservable<MyStatisticsResponse>;

constructor() {
makeAutoObservable(this, {}, { autoBind: true });
}

load() {
this.userStatistics = fromPromise(myStatisticsRequest());
}

get isLoading() {
return this.userStatistics?.state === "pending";
}

get know() {
if (this.userStatistics?.state !== "fulfilled") {
return 0;
}
return this.userStatistics.value.cardsLearning.know ?? 0;
}

get learning() {
if (this.userStatistics?.state !== "fulfilled") {
return 0;
}
return this.userStatistics.value.cardsLearning.learning ?? 0;
}

get total() {
return this.know + this.learning;
}

get frequencyChart(): PieChartData[] {
if (this.userStatistics?.state !== "fulfilled") {
return [];
}

return this.userStatistics.value.intervalFrequency;
}
}
125 changes: 125 additions & 0 deletions src/screens/user-statistics/user-statistics-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Screen } from "../shared/screen.tsx";
import { observer } from "mobx-react-lite";
import { useMount } from "../../lib/react/use-mount.ts";
import { useUserStatisticsStore } from "./store/user-statistics-store-context.tsx";
import { useBackButton } from "../../lib/telegram/use-back-button.tsx";
import { screenStore } from "../../store/screen-store.ts";
import { CardRow } from "../../ui/card-row.tsx";
import { HintTransparent } from "../../ui/hint-transparent.tsx";
import React from "react";
import { css } from "@emotion/css";
import { t } from "../../translations/t.ts";
import {
chartFinish,
chartStart,
PieChartCanvas,
} from "./pie-chart-canvas.tsx";
import { LegendItem } from "./legend-item.tsx";
import { DeckLoading } from "../shared/deck-loading.tsx";

export const UserStatisticsScreen = observer(() => {
const userStatisticsStore = useUserStatisticsStore();

useBackButton(() => {
screenStore.back();
});

useMount(() => {
userStatisticsStore.load();
});

return (
<Screen title={t("user_stats_page")}>
{userStatisticsStore.isLoading ? (
<DeckLoading speed={1} />
) : (
<CardRow>
<span>{t("user_stats_remembered")}</span>
<span>{userStatisticsStore.know}</span>
</CardRow>
)}
<HintTransparent>{t("user_stats_remembered_hint")}</HintTransparent>

{userStatisticsStore.isLoading ? (
<DeckLoading speed={1} />
) : (
<CardRow>
<span>{t("user_stats_learning")}</span>
<span>{userStatisticsStore.learning}</span>
</CardRow>
)}
<HintTransparent>{t("user_stats_learning_hint")}</HintTransparent>

{userStatisticsStore.isLoading ? (
<DeckLoading speed={1} />
) : (
<CardRow>
<span>{t("user_stats_total")}</span>
<span>{userStatisticsStore.total}</span>
</CardRow>
)}
<HintTransparent>{t("user_stats_total_hint")}</HintTransparent>

{!userStatisticsStore.isLoading ? (
<>
<div
className={css({
marginTop: 10,
marginLeft: "auto",
marginRight: "auto",
textAlign: "center",
fontWeight: 500,
})}
>
{t("user_stats_learning_time")}
</div>

<div
className={css({
marginTop: 10,
marginLeft: "auto",
marginRight: "auto",
})}
>
<PieChartCanvas
data={userStatisticsStore.frequencyChart}
width={250}
height={200}
/>
</div>

<div
className={css({
display: "flex",
flexDirection: "column",
gap: 4,
alignSelf: "center",
})}
>
<div
className={css({ display: "flex", gap: 4, alignItems: "center" })}
>
<LegendItem color={chartStart} />
<span className={css({ fontSize: 14 })}>
{t("user_stats_chart_min_expl")}
</span>
</div>
<div
className={css({ display: "flex", gap: 4, alignItems: "center" })}
>
<LegendItem color={chartFinish} />
<span className={css({ fontSize: 14 })}>
{t("user_stats_chart_max_expl")}
</span>
</div>
</div>
<p>
<HintTransparent>
{t("user_stats_learning_time_hint")}
</HintTransparent>
</p>
</>
) : null}
</Screen>
);
});
1 change: 1 addition & 0 deletions src/store/screen-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Route =
| { type: "shareDeck"; deckId: number; shareId: string }
| { type: "shareFolder"; folderId: number; shareId: string }
| { type: "componentCatalog" }
| { type: "userStatistics" }
| { type: "userSettings" };

export type RouteType = Route["type"];
Expand Down
Loading

0 comments on commit f5826a5

Please sign in to comment.