Add app opens & services activity charts to admin dashboard

- New `app_opens` table (id, user_id, app_id, created_at) and Drizzle
  schema; exported from lib/db schema index.
- New `POST /api/apps/{id}/open` endpoint logs an open for the current
  user (auth required, 204 on success, 404 for unknown app id).
- Extended `GET /api/stats/admin` with appOpensByDay/appOpensLast7Days/
  appOpensPrev7Days and servicesCreatedByDay/servicesCreatedLast7Days,
  refactored around a shared buildSeries helper.
- Regenerated api-client/zod and added two new bar charts (App opens,
  Services added) to the admin Dashboard alongside the existing
  Sign-ups chart, with EN/AR translations.
- Home page fires bare `logAppOpen(id, { keepalive: true })` before
  navigating so the request survives client-side route changes; the
  bottom dock buttons also use this helper.
- Restructured SortableAppIcon so the click target and dnd-kit
  listeners live on the same <button>, fixing a click-vs-drag
  interaction that prevented the open event from firing in tests.

Rebase notes:
- home.tsx: incoming main reworked AppIcon styling, the apps grid
  header (with count and empty state), and the dock button. Kept all
  incoming visual changes while preserving this task's
  AppIconContent fragment, single-button SortableAppIcon, and
  openApp() wiring (so dock + grid both log opens).
- opengraph.jpg: kept incoming binary version.

E2E verified: clicking app icons increments app_opens; admin dashboard
renders all three charts.
This commit is contained in:
Riyadh
2026-04-20 15:16:19 +00:00
parent 10c8242511
commit d15aceefca
13 changed files with 393 additions and 39 deletions
@@ -323,10 +323,25 @@ export type AdminStatsSignupsByDayItem = {
count: number;
};
export type AdminStatsAppOpensByDayItem = {
date: string;
count: number;
};
export type AdminStatsServicesCreatedByDayItem = {
date: string;
count: number;
};
export interface AdminStats {
newUsersLast7Days: number;
newUsersPrev7Days: number;
activeServices: number;
inactiveServices: number;
signupsByDay: AdminStatsSignupsByDayItem[];
appOpensByDay: AdminStatsAppOpensByDayItem[];
appOpensLast7Days: number;
appOpensPrev7Days: number;
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
servicesCreatedLast7Days: number;
}
+84
View File
@@ -930,6 +930,90 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options));
};
/**
* @summary Log an app open event for the current user
*/
export const getLogAppOpenUrl = (id: number) => {
return `/api/apps/${id}/open`;
};
export const logAppOpen = async (
id: number,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getLogAppOpenUrl(id), {
...options,
method: "POST",
});
};
export const getLogAppOpenMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["logAppOpen"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof logAppOpen>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return logAppOpen(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type LogAppOpenMutationResult = NonNullable<
Awaited<ReturnType<typeof logAppOpen>>
>;
export type LogAppOpenMutationError = ErrorType<ErrorResponse>;
/**
* @summary Log an app open event for the current user
*/
export const useLogAppOpen = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getLogAppOpenMutationOptions(options));
};
/**
* @summary Set the current user's preferred home apps order
*/
+56
View File
@@ -236,6 +236,27 @@ paths:
"204":
description: Deleted
/apps/{id}/open:
post:
operationId: logAppOpen
tags: [apps]
summary: Log an app open event for the current user
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"204":
description: Logged
"404":
description: App not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/me/app-order:
put:
operationId: updateMyAppOrder
@@ -1336,9 +1357,44 @@ components:
required:
- date
- count
appOpensByDay:
type: array
items:
type: object
properties:
date:
type: string
count:
type: integer
required:
- date
- count
appOpensLast7Days:
type: integer
appOpensPrev7Days:
type: integer
servicesCreatedByDay:
type: array
items:
type: object
properties:
date:
type: string
count:
type: integer
required:
- date
- count
servicesCreatedLast7Days:
type: integer
required:
- newUsersLast7Days
- newUsersPrev7Days
- activeServices
- inactiveServices
- signupsByDay
- appOpensByDay
- appOpensLast7Days
- appOpensPrev7Days
- servicesCreatedByDay
- servicesCreatedLast7Days
+22
View File
@@ -197,6 +197,13 @@ export const DeleteAppParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Log an app open event for the current user
*/
export const LogAppOpenParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Set the current user's preferred home apps order
*/
@@ -721,4 +728,19 @@ export const GetAdminStatsResponse = zod.object({
count: zod.number(),
}),
),
appOpensByDay: zod.array(
zod.object({
date: zod.string(),
count: zod.number(),
}),
),
appOpensLast7Days: zod.number(),
appOpensPrev7Days: zod.number(),
servicesCreatedByDay: zod.array(
zod.object({
date: zod.string(),
count: zod.number(),
}),
),
servicesCreatedLast7Days: zod.number(),
});
+23
View File
@@ -0,0 +1,23 @@
import { pgTable, serial, integer, timestamp, index } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
import { appsTable } from "./apps";
export const appOpensTable = pgTable(
"app_opens",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
appId: integer("app_id")
.notNull()
.references(() => appsTable.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("app_opens_created_at_idx").on(t.createdAt),
index("app_opens_app_id_idx").on(t.appId),
],
);
export type AppOpen = typeof appOpensTable.$inferSelect;
+1
View File
@@ -6,3 +6,4 @@ export * from "./conversations";
export * from "./notifications";
export * from "./settings";
export * from "./user-app-orders";
export * from "./app-opens";