feat: add new analytics endpoints and charts for enhanced data visualization

- Introduced new API routes for people names, geo photos, and album photos statistics.
- Updated analytics handler to include functions for fetching new statistics.
- Added new chart components for visualizing people names, geo photos, and album photos data in the Exif data analytics page.
- Modified asset deletion function to accept an options parameter for forced deletion.
This commit is contained in:
Varun Raj
2025-10-29 23:10:55 +05:30
parent c0d51998b6
commit 65d6c90707
12 changed files with 284 additions and 5 deletions
@@ -0,0 +1,39 @@
import { Card } from "@/components/ui/card";
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
import { getAlbumPhotosStatistics } from "@/handlers/api/analytics.handler";
import React, { useEffect, useState } from "react";
export default function AlbumPhotosChart() {
const [chartData, setChartData] = useState<IPieChartData[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const data = await getAlbumPhotosStatistics();
setChartData(data);
} catch (error: any) {
setErrorMessage(error?.message || "Failed to fetch album photos data");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return (
<Card
title="Album Distribution"
description="Distribution of photos in and out of albums"
>
<PieChart
data={chartData}
loading={loading}
errorMessage={errorMessage}
/>
</Card>
);
}
@@ -0,0 +1,39 @@
import { Card } from "@/components/ui/card";
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
import { getGeoPhotosStatistics } from "@/handlers/api/analytics.handler";
import React, { useEffect, useState } from "react";
export default function GeoPhotosChart() {
const [chartData, setChartData] = useState<IPieChartData[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const data = await getGeoPhotosStatistics();
setChartData(data);
} catch (error: any) {
setErrorMessage(error?.message || "Failed to fetch geo photos data");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return (
<Card
title="Geo Data"
description="Distribution of photos with and without geo data"
>
<PieChart
data={chartData}
loading={loading}
errorMessage={errorMessage}
/>
</Card>
);
}
@@ -0,0 +1,39 @@
import { Card } from "@/components/ui/card";
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
import { getPeopleNamesStatistics } from "@/handlers/api/analytics.handler";
import React, { useEffect, useState } from "react";
export default function PeopleNamesChart() {
const [chartData, setChartData] = useState<IPieChartData[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const data = await getPeopleNamesStatistics();
setChartData(data);
} catch (error: any) {
setErrorMessage(error?.message || "Failed to fetch people names data");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return (
<Card
title="People Names"
description="Distribution of people with and without names"
>
<PieChart
data={chartData}
loading={loading}
errorMessage={errorMessage}
/>
</Card>
);
}
+3
View File
@@ -59,6 +59,9 @@ export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
export const ASSET_STATISTICS = BASE_PROXY_ENDPOINT + "/assets/statistics";
export const LIVE_PHOTO_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/livephoto";
export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap";
export const PEOPLE_NAMES_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/people-names";
export const GEO_PHOTOS_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/geo-photos";
export const ALBUM_PHOTOS_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/album-photos";
// Common
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
+13 -1
View File
@@ -1,4 +1,4 @@
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS } from "@/config/routes";
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS, PEOPLE_NAMES_STATISTICS, GEO_PHOTOS_STATISTICS, ALBUM_PHOTOS_STATISTICS } from "@/config/routes";
import API from "@/lib/api";
export type ISupportedEXIFColumns =
@@ -18,4 +18,16 @@ export const getLivePhotoStatistics = async () => {
export const getHeatMapData = async () => {
return API.get(HEATMAP_DATA);
}
export const getPeopleNamesStatistics = async () => {
return API.get(PEOPLE_NAMES_STATISTICS);
}
export const getGeoPhotosStatistics = async () => {
return API.get(GEO_PHOTOS_STATISTICS);
}
export const getAlbumPhotosStatistics = async () => {
return API.get(ALBUM_PHOTOS_STATISTICS);
}
+1 -1
View File
@@ -72,7 +72,7 @@ export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => {
return API.get(ASSET_GEO_HEATMAP_PATH, filters);
}
export const deleteAssets = async (ids: string[]) => {
export const deleteAssets = async (ids: string[], options: { force?: boolean } = { force: true }) => {
return API.delete(UPDATE_ASSETS_PATH, { ids, force: true });
}
+11
View File
@@ -11,6 +11,9 @@ import {
CardTitle,
} from "@/components/ui/card";
import AssetHeatMap from "@/components/analytics/exif/AssetHeatMap";
import PeopleNamesChart from "@/components/analytics/exif/PeopleNamesChart";
import GeoPhotosChart from "@/components/analytics/exif/GeoPhotosChart";
import AlbumPhotosChart from "@/components/analytics/exif/AlbumPhotosChart";
import { useEffect, useState } from "react";
import { getAssetStatistics, getLivePhotoStatistics } from "@/handlers/api/analytics.handler";
import { humanizeBytes } from "@/helpers/string.helper";
@@ -130,6 +133,14 @@ export default function ExifDataAnalytics() {
</Card>
</div>
<AssetHeatMap />
{/* New Analytics Charts */}
<div className="grid grid-cols-3 gap-4 mb-6">
<PeopleNamesChart />
<GeoPhotosChart />
<AlbumPhotosChart />
</div>
<div className="grid grid-cols-3 gap-4">
{exifCharts.map((chart) => (
<EXIFDistribution
@@ -0,0 +1,43 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { assets, albumsAssetsAssets } from "@/schema";
import { and, count, eq, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req);
// Count photos that are in albums
const inAlbums = await db.select({
value: count(sql`DISTINCT ${assets.id}`),
})
.from(assets)
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.where(eq(assets.ownerId, currentUser.id));
// Count total photos
const totalPhotos = await db.select({
value: count(),
})
.from(assets)
.where(eq(assets.ownerId, currentUser.id));
const inAlbumsCount = inAlbums[0]?.value || 0;
const totalCount = totalPhotos[0]?.value || 0;
const notInAlbumsCount = totalCount - inAlbumsCount;
return res.status(200).json([
{ label: "In Albums", value: inAlbumsCount },
{ label: "Not in Albums", value: notInAlbumsCount }
]);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}
@@ -0,0 +1,47 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { assets, exif } from "@/schema";
import { and, count, eq, isNotNull, isNull, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req);
// Count photos with geo data (latitude and longitude not null)
const withGeo = await db.select({
value: count(),
})
.from(assets)
.innerJoin(exif, eq(assets.id, exif.assetId))
.where(and(
eq(assets.ownerId, currentUser.id),
isNotNull(exif.latitude),
isNotNull(exif.longitude)
));
// Count photos without geo data (latitude or longitude is null)
const withoutGeo = await db.select({
value: count(),
})
.from(assets)
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(and(
eq(assets.ownerId, currentUser.id),
sql`(${exif.latitude} IS NULL OR ${exif.longitude} IS NULL)`
));
return res.status(200).json([
{ label: "With Geo Data", value: withGeo[0]?.value || 0 },
{ label: "Without Geo Data", value: withoutGeo[0]?.value || 0 }
]);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}
@@ -0,0 +1,45 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { person } from "@/schema";
import { and, count, eq, ne, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req);
// Count people with names (non-empty names)
const withNames = await db.select({
value: count(),
})
.from(person)
.where(and(
eq(person.ownerId, currentUser.id),
ne(person.name, ''),
ne(person.name, 'Unknown')
));
// Count people without names (empty or 'Unknown' names)
const withoutNames = await db.select({
value: count(),
})
.from(person)
.where(and(
eq(person.ownerId, currentUser.id),
sql`(${person.name} = '' OR ${person.name} = 'Unknown')`
));
return res.status(200).json([
{ label: "With Names", value: withNames[0]?.value || 0 },
{ label: "Without Names", value: withoutNames[0]?.value || 0 }
]);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}
+3 -3
View File
@@ -101,8 +101,8 @@ export default function EmptyVideosPage() {
})
}
const handleDelete = () => {
return deleteAssets(contextState.selectedIds).then(() => {
const handleDelete = (force?: boolean) => {
return deleteAssets(contextState.selectedIds, { force }).then(() => {
const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id));
setAssets(newAssets)
contextState.updateContext({
@@ -264,7 +264,7 @@ export default function EmptyVideosPage() {
<AlertDialog
title="Delete the selected videos?"
description="This action will delete the selected videos and cannot be undone."
onConfirm={handleDelete}
onConfirm={() => handleDelete(true)}
disabled={contextState.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={contextState.selectedIds.length === 0}>
+1
View File
@@ -3,4 +3,5 @@ export { exif } from './exif.schema';
export { person } from './person.schema';
export { assetFaces } from './assetFaces.schema';
export { users } from './users.schema';
export { albumsAssetsAssets } from './albumAssetsAssets.schema';
export * from './relationships';