From 0e88bc50532c717d023170fd0b9cd3c3f94eb1f1 Mon Sep 17 00:00:00 2001 From: pinks Date: Tue, 10 Oct 2023 18:21:25 +0200 Subject: [PATCH] feat: home page with counters --- api/statsRoute.ts | 74 +++++++++++++++++++++++++++++++------- api/usersRoute.ts | 42 +++++++++++++++------- app/userDailyStatsStore.ts | 24 +++++++++++++ app/userStatsStore.ts | 32 +++++++++++------ deno.json | 4 +-- ui/App.tsx | 5 +-- ui/AppHeader.tsx | 13 +++++-- ui/Counter.tsx | 61 +++++++++++++++++++++++++++++++ ui/HomePage.tsx | 48 +++++++++++++++++++++++++ 9 files changed, 261 insertions(+), 42 deletions(-) create mode 100644 app/userDailyStatsStore.ts create mode 100644 ui/Counter.tsx create mode 100644 ui/HomePage.tsx diff --git a/api/statsRoute.ts b/api/statsRoute.ts index 170bb0a..c2bfbf9 100644 --- a/api/statsRoute.ts +++ b/api/statsRoute.ts @@ -1,17 +1,26 @@ +// deno-lint-ignore-file require-await import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { liveGlobalStats } from "../app/globalStatsStore.ts"; import { getDailyStats } from "../app/dailyStatsStore.ts"; +import { getUserStats } from "../app/userStatsStore.ts"; +import { getUserDailyStats } from "../app/userDailyStatsStore.ts"; export const statsRoute = createPathFilter({ - "global": createMethodFilter({ + "": createMethodFilter({ GET: createEndpoint( { query: null, body: null }, async () => { + const stats = liveGlobalStats; return { status: 200, body: { type: "application/json", - data: liveGlobalStats, + data: { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + userCount: stats.userIds.length, + timestamp: stats.timestamp, + }, }, }; }, @@ -24,20 +33,61 @@ export const statsRoute = createPathFilter({ const year = Number(params.year); const month = Number(params.month); const day = Number(params.day); - const minDate = new Date("2023-01-01"); - const maxDate = new Date(); - const date = new Date(Date.UTC(year, month - 1, day)); - if (date < minDate || date > maxDate) { - return { - status: 404, - body: { type: "text/plain", data: "Not found" }, - }; - } + const stats = await getDailyStats(year, month, day); return { status: 200, body: { type: "application/json", - data: await getDailyStats(year, month, day), + data: { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + userCount: stats.userIds.length, + timestamp: stats.timestamp, + }, + }, + }; + }, + ), + }), + "users/{userId}": createMethodFilter({ + GET: createEndpoint( + { query: null, body: null }, + async ({ params }) => { + const userId = Number(params.userId); + const stats = await getUserStats(userId); + return { + status: 200, + body: { + type: "application/json", + data: { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + tagCountMap: stats.tagCountMap, + timestamp: stats.timestamp, + }, + }, + }; + }, + ), + }), + "users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({ + GET: createEndpoint( + { query: null, body: null }, + async ({ params }) => { + const userId = Number(params.userId); + const year = Number(params.year); + const month = Number(params.month); + const day = Number(params.day); + const stats = await getUserDailyStats(userId, year, month, day); + return { + status: 200, + body: { + type: "application/json", + data: { + imageCount: stats.imageCount, + pixelCount: stats.pixelCount, + timestamp: stats.timestamp, + }, }, }; }, diff --git a/api/usersRoute.ts b/api/usersRoute.ts index 9cb3066..a17db99 100644 --- a/api/usersRoute.ts +++ b/api/usersRoute.ts @@ -1,4 +1,3 @@ -import { encode } from "std/encoding/base64.ts"; import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { getConfig } from "../app/config.ts"; import { bot } from "../bot/mod.ts"; @@ -12,22 +11,41 @@ export const usersRoute = createPathFilter({ if (chat.type !== "private") { throw new Error("Chat is not private"); } - const photoData = chat.photo?.small_file_id - ? encode( - await fetch( - `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( - chat.photo.small_file_id, - ).then((file) => file.file_path)}`, - ).then((resp) => resp.arrayBuffer()), - ) - : undefined; const config = await getConfig(); - const isAdmin = config?.adminUsernames?.includes(chat.username); + const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username); return { status: 200, body: { type: "application/json", - data: { ...chat, photoData, isAdmin }, + data: { ...chat, isAdmin }, + }, + }; + }, + ), + }), + "{userId}/photo": createMethodFilter({ + GET: createEndpoint( + { query: null, body: null }, + async ({ params }) => { + const chat = await bot.api.getChat(params.userId); + if (chat.type !== "private") { + throw new Error("Chat is not private"); + } + const photoData = chat.photo?.small_file_id + ? await fetch( + `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( + chat.photo.small_file_id, + ).then((file) => file.file_path)}`, + ).then((resp) => resp.arrayBuffer()) + : undefined; + if (!photoData) { + return { status: 404, body: { type: "text/plain", data: "User has no photo" } }; + } + return { + status: 200, + body: { + type: "image/jpeg", + data: new Blob([photoData], { type: "image/jpeg" }), }, }; }, diff --git a/app/userDailyStatsStore.ts b/app/userDailyStatsStore.ts new file mode 100644 index 0000000..63e1751 --- /dev/null +++ b/app/userDailyStatsStore.ts @@ -0,0 +1,24 @@ +import { JsonSchema, jsonType } from "t_rest/server"; +import { kvMemoize } from "./kvMemoize.ts"; +import { db } from "./db.ts"; +import { generationStore } from "./generationStore.ts"; + +export const userDailyStatsSchema = { + type: "object", + properties: { + imageCount: { type: "number" }, + pixelCount: { type: "number" }, + timestamp: { type: "number" }, + }, + required: ["imageCount", "pixelCount", "timestamp"], +} as const satisfies JsonSchema; + +export type UserDailyStats = jsonType; + +export const getUserDailyStats = kvMemoize( + db, + ["userDailyStats"], + async (userId: number, year: number, month: number, day: number): Promise => { + throw new Error("Not implemented"); + }, +); diff --git a/app/userStatsStore.ts b/app/userStatsStore.ts index 961c523..a72823c 100644 --- a/app/userStatsStore.ts +++ b/app/userStatsStore.ts @@ -14,13 +14,13 @@ export const userStatsSchema = { userId: { type: "number" }, imageCount: { type: "number" }, pixelCount: { type: "number" }, - tagsCount: { + tagCountMap: { type: "object", additionalProperties: { type: "number" }, }, timestamp: { type: "number" }, }, - required: ["userId", "imageCount", "pixelCount", "tagsCount", "timestamp"], + required: ["userId", "imageCount", "pixelCount", "tagCountMap", "timestamp"], } as const satisfies JsonSchema; export type UserStats = jsonType; @@ -49,7 +49,7 @@ export const getUserStats = kvMemoize( async (userId: number): Promise => { let imageCount = 0; let pixelCount = 0; - const tagsCount: Record = {}; + const tagCountMap: Record = {}; logger().info(`Calculating user stats for ${userId}`); @@ -58,15 +58,27 @@ export const getUserStats = kvMemoize( ) { imageCount++; pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0); - const tags = generation.value.info?.prompt.split(/[,;.\n]/) + + const tags = generation.value.info?.prompt + // split on punctuation and newlines + .split(/[,;.]\s+|\n/) + // remove `:weight` syntax + .map((tag) => tag.replace(/:[\d\.]+/g, " ")) + // remove `(tag)` and `[tag]` syntax + .map((tag) => tag.replace(/[()[\]]/g, " ")) + // collapse multiple whitespace to one + .map((tag) => tag.replace(/\s+/g, " ")) + // trim whitespace .map((tag) => tag.trim()) + // remove empty tags .filter((tag) => tag.length > 0) - .map((tag) => tag.toLowerCase()) - .map((tag) => tag.replace(/[()[\]]/, "")) - .map((tag) => tag.replace(/:[\d.]/g, "")) - .map((tag) => tag.replace(/ +/g, " ")) ?? []; + // lowercase tags + .map((tag) => tag.toLowerCase()) ?? + // default to empty array + []; + for (const tag of tags) { - tagsCount[tag] = (tagsCount[tag] ?? 0) + 1; + tagCountMap[tag] = (tagCountMap[tag] ?? 0) + 1; } } @@ -74,7 +86,7 @@ export const getUserStats = kvMemoize( userId, imageCount, pixelCount, - tagsCount, + tagCountMap, timestamp: Date.now(), }; }, diff --git a/deno.json b/deno.json index ae61dd7..24b7ec7 100644 --- a/deno.json +++ b/deno.json @@ -36,9 +36,9 @@ "png_chunks_extract": "https://esm.sh/png-chunks-extract@1.0.0", "png_chunk_text": "https://esm.sh/png-chunk-text@1.0.0", "openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6", - "t_rest/server": "https://esm.sh/ty-rest@0.3.2/server?dev", + "t_rest/server": "https://esm.sh/ty-rest@0.4.0/server?dev", - "t_rest/client": "https://esm.sh/ty-rest@0.3.2/client?dev", + "t_rest/client": "https://esm.sh/ty-rest@0.4.0/client?dev", "react": "https://esm.sh/react@18.2.0?dev", "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev", "react-router-dom": "https://esm.sh/react-router-dom@6.16.0?dev", diff --git a/ui/App.tsx b/ui/App.tsx index b03ffb7..e221fd0 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -5,6 +5,7 @@ import { AppHeader } from "./AppHeader.tsx"; import { QueuePage } from "./QueuePage.tsx"; import { SettingsPage } from "./SettingsPage.tsx"; import { fetchApi, handleResponse } from "./apiClient.tsx"; +import { HomePage } from "./HomePage.tsx"; export function App() { // store session ID in the local storage @@ -37,7 +38,3 @@ export function App() { ); } - -function HomePage() { - return

hi

; -} diff --git a/ui/AppHeader.tsx b/ui/AppHeader.tsx index 8e6edd6..a45171b 100644 --- a/ui/AppHeader.tsx +++ b/ui/AppHeader.tsx @@ -33,6 +33,15 @@ export function AppHeader( (args) => fetchApi(...args).then(handleResponse), ); + const userPhoto = useSWR( + session.data?.userId + ? ["users/{userId}/photo", "GET", { + params: { userId: String(session.data.userId) }, + }] as const + : null, + (args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)), + ); + return (
diff --git a/ui/Counter.tsx b/ui/Counter.tsx new file mode 100644 index 0000000..a3ce098 --- /dev/null +++ b/ui/Counter.tsx @@ -0,0 +1,61 @@ +import { cx } from "@twind/core"; +import React from "react"; + +function CounterDigit(props: { value: number; transitionDurationMs?: number }) { + const { value, transitionDurationMs = 1000 } = props; + const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1; + + return ( + + {Array.from({ length: 10 }).map((_, i) => ( + + {i} + + ))} + + ); +} + +export function Counter(props: { + value: number; + digits: number; + transitionDurationMs?: number; + className?: string; +}) { + const { value, digits, transitionDurationMs, className } = props; + + return ( + + {Array.from({ length: digits }) + .flatMap((_, i) => [ + i > 0 && i % 3 === 0 + ? ( + + ) + : null, + , + ]) + .reverse()} + + ); +} diff --git a/ui/HomePage.tsx b/ui/HomePage.tsx new file mode 100644 index 0000000..8597fe9 --- /dev/null +++ b/ui/HomePage.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { fetchApi, handleResponse } from "./apiClient.tsx"; +import useSWR from "swr"; +import { eachDayOfInterval, endOfMonth, startOfMonth, subMonths } from "date-fns"; +import { UTCDateMini } from "@date-fns/utc"; +import { Counter } from "./Counter.tsx"; + +export function HomePage() { + const globalStats = useSWR( + ["stats", "GET", {}] as const, + (args) => fetchApi(...args).then(handleResponse), + { refreshInterval: 2_000 }, + ); + + return ( +
+

+ Pixels painted + +

+
+

+ Images generated + +

+

+ Unique users + +

+
+
+ ); +}