feat: heatmap based on the assets in past year
This commit is contained in:
1 parent
7df379336d
commit
360c19699e
7 files changed
+283
-160
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getHeatMapData } from "@/handlers/api/analytics.handler";
|
||||
|
||||
type HeatMapEntry = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function AssetHeatMap() {
|
||||
const [heatMapData, setHeatMapData] = useState<HeatMapEntry[][]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [weeksPerMonth, setWeeksPerMonth] = useState<number[]>([]); // Store weeks per month
|
||||
|
||||
const fetchHeatMapData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getHeatMapData();
|
||||
setHeatMapData(formatHeatMapData(data));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHeatMapData();
|
||||
}, []);
|
||||
|
||||
const currentDate = new Date();
|
||||
const months: string[] = [];
|
||||
|
||||
// Build an array of the last 12 months
|
||||
for (let i = 0; i < 12; i++) {
|
||||
months.push(currentDate.toLocaleString("default", { month: "short" }));
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
}
|
||||
months.reverse();
|
||||
|
||||
// Flatten heatMapData to calculate min and max counts
|
||||
const flattenedData = heatMapData.flat();
|
||||
const minCount = Math.min(...flattenedData.map((entry) => entry.count));
|
||||
const maxCount = Math.max(...flattenedData.map((entry) => entry.count));
|
||||
|
||||
// Check if a week contains a date with the first day of the month
|
||||
const hasFirstDayOfMonth = (arr: HeatMapEntry[]) => {
|
||||
return arr.some((entry) => new Date(entry.date).getDate() === 1);
|
||||
};
|
||||
|
||||
const getColor = (count: number) => {
|
||||
if (count === -1) return "";
|
||||
|
||||
// Calculate thresholds based on the data range
|
||||
const range = maxCount - minCount;
|
||||
const threshold1 = minCount + range * 0.2;
|
||||
const threshold2 = minCount + range * 0.4;
|
||||
const threshold3 = minCount + range * 0.6;
|
||||
const threshold4 = minCount + range * 0.8;
|
||||
if (count === 0) return "bg-zinc-800";
|
||||
if (count <= threshold1) return "bg-green-200";
|
||||
if (count <= threshold2) return "bg-green-400";
|
||||
if (count <= threshold3) return "bg-green-500";
|
||||
if (count <= threshold4) return "bg-green-600";
|
||||
return "bg-green-800";
|
||||
};
|
||||
|
||||
const formatHeatMapData = (data: HeatMapEntry[]) => {
|
||||
const chunks = [];
|
||||
const weeksPerMonthTemp: number[] = [];
|
||||
let previousMonthIndex = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 7) {
|
||||
const thisWeek = data.slice(i, i + 7);
|
||||
chunks.push(thisWeek);
|
||||
|
||||
if (hasFirstDayOfMonth(thisWeek)) {
|
||||
const changeWeek = (i - previousMonthIndex) / 7;
|
||||
previousMonthIndex = i;
|
||||
weeksPerMonthTemp.push(changeWeek);
|
||||
}
|
||||
}
|
||||
weeksPerMonthTemp.reverse();
|
||||
setWeeksPerMonth(weeksPerMonthTemp);
|
||||
return chunks;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Past Year</h2>
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto border-separate border-spacing-1 w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{weeksPerMonth.map((weeks, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="text-center"
|
||||
colSpan={weeks}
|
||||
>
|
||||
{months[index]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{heatMapData[0]?.map((_, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{heatMapData.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className={`h-6 ${getColor(column[rowIndex]?.count ?? -1)} rounded-lg`}
|
||||
style={{ width: `calc(${100 / 60}%)` }}
|
||||
>
|
||||
{
|
||||
column[rowIndex]?.date ? (
|
||||
<a
|
||||
href={`http://localhost:2283/search?query=%7B%22takenAfter%22%3A%22${column[rowIndex]?.date ?? "N/A"}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22${column[rowIndex]?.date ?? "N/A"}T23%3A59%3A59.999Z%22%7D`}
|
||||
className="block h-full w-full"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
title={`Date: ${column[rowIndex]?.date ?? "N/A"}, Count: ${column[rowIndex]?.count ?? 0}`}
|
||||
/>
|
||||
</a>) : <div
|
||||
className="h-full w-full"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getAssetStatistics } from "@/handlers/api/analytics.handler";
|
||||
|
||||
// List of month labels
|
||||
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const weeksPerMonth = [4, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 5];
|
||||
// Function to map weeks to months
|
||||
const getMonthLabels = () => {
|
||||
// Approximate number of weeks per month
|
||||
const monthLabels = [];
|
||||
let cumulativeWeeks = 0;
|
||||
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
monthLabels.push({
|
||||
month: months[i],
|
||||
startWeek: cumulativeWeeks,
|
||||
span: weeksPerMonth[i],
|
||||
});
|
||||
cumulativeWeeks += weeksPerMonth[i];
|
||||
}
|
||||
|
||||
return monthLabels;
|
||||
};
|
||||
|
||||
// Function to generate the last 365 days of contribution data
|
||||
const generateContributions = () => {
|
||||
const contributions: number[][] = Array.from({ length: 7 }, () => Array(52).fill(0));
|
||||
for (let week = 0; week < 52; week++) {
|
||||
for (let day = 0; day < 7; day++) {
|
||||
// Generate a random number of contributions for each day (0 to 5 for example)
|
||||
contributions[day][week] = Math.floor(Math.random() * 6);
|
||||
}
|
||||
}
|
||||
return contributions;
|
||||
};
|
||||
|
||||
// Function to determine the background color based on the contribution count
|
||||
const getColor = (count: number) => {
|
||||
if (count === 0) return 'bg-zinc-900';
|
||||
if (count === 1) return 'bg-green-200';
|
||||
if (count === 2) return 'bg-green-400';
|
||||
if (count === 3) return 'bg-green-500';
|
||||
if (count === 4) return 'bg-green-600';
|
||||
return 'bg-green-800';
|
||||
};
|
||||
|
||||
const ContributionGraph: React.FC = () => {
|
||||
const contributions = generateContributions();
|
||||
const [statistics, setStatistics] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true); // Set loading to true when starting to fetch data
|
||||
return getAssetStatistics()
|
||||
.then((data) => {
|
||||
setStatistics(data); // Update statistics with the returned data
|
||||
})
|
||||
.finally(() => setLoading(false)); // Stop loading after data is fetched
|
||||
};
|
||||
|
||||
const monthLabels = getMonthLabels();
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Past Year</h2>
|
||||
<div className="overflow-x-auto">
|
||||
{/* Month labels */}
|
||||
{/* <div className="flex mb-1">
|
||||
{monthLabels.map(({ month, startWeek, span }) => (
|
||||
<div
|
||||
key={month}
|
||||
style={{ width: `calc(${span / 52 * 100}%)`, marginLeft: `calc(${startWeek / 52 * 100}%)` }}
|
||||
className="text-center font-bold text-sm"
|
||||
>
|
||||
{month}
|
||||
</div>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
<table className="table-auto border-separate border-spacing-1 w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{months.map((month, index) => (
|
||||
<th key={index} colSpan={weeksPerMonth[index]}>{month}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{contributions.map((dayContributions, dayIndex) => (
|
||||
<tr key={dayIndex}>
|
||||
{dayContributions.map((count, weekIndex) => (
|
||||
<td
|
||||
key={weekIndex}
|
||||
className={`h-6 ${getColor(count)} rounded-lg`}
|
||||
style={{ width: `calc(${100 / 52}%)` }}
|
||||
title={`${count} Images`}
|
||||
></td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContributionGraph;
|
||||
@@ -38,4 +38,6 @@ export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
|
||||
|
||||
//Analytics
|
||||
|
||||
export const ASSET_STATISTICS = BASE_PROXY_ENDPOINT + "/assets/statistics";
|
||||
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";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH } from "@/config/routes";
|
||||
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS } from "@/config/routes";
|
||||
import API from "@/lib/api";
|
||||
|
||||
export type ISupportedEXIFColumns =
|
||||
@@ -10,4 +10,12 @@ export const getExifDistribution = async (column: ISupportedEXIFColumns) => {
|
||||
|
||||
export const getAssetStatistics = async () => {
|
||||
return API.get(ASSET_STATISTICS);
|
||||
}
|
||||
|
||||
export const getLivePhotoStatistics = async () => {
|
||||
return API.get(LIVE_PHOTO_STATISTICS);
|
||||
}
|
||||
|
||||
export const getHeatMapData = async () => {
|
||||
return API.get(HEATMAP_DATA);
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import ContributionGraph from "@/components/analytics/exif/contributiongraph";
|
||||
import AssetHeatMap from "@/components/analytics/exif/AssetHeatMap";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAssetStatistics } from "@/handlers/api/analytics.handler";
|
||||
import { getAssetStatistics, getLivePhotoStatistics } from "@/handlers/api/analytics.handler";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -73,72 +73,58 @@ const exifCharts: IEXIFDistributionProps[] = [
|
||||
export default function ExifDataAnalytics() {
|
||||
const [statistics, setStatistics] = useState({ images: 0, videos: 0, total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [livePhotoStatistics, setLivePhotoStatistics] = useState({ total: 0 });
|
||||
|
||||
const fetchLivePhotoStatistics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getLivePhotoStatistics();
|
||||
const livePhotoData = Array.isArray(data) && data.length ? data[0].value : 0;
|
||||
setLivePhotoStatistics({ total: livePhotoData });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStatisticsData = async () => {
|
||||
setLoading(true); // Set loading to true when starting to fetch data
|
||||
return getAssetStatistics()
|
||||
.then((data) => {
|
||||
console.log("asdasdadasd")
|
||||
setStatistics(data); // Update statistics with the returned data
|
||||
})
|
||||
.finally(() => setLoading(false)); // Stop loading after data is fetched
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAssetStatistics();
|
||||
setStatistics(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatisticsData();
|
||||
fetchLivePhotoStatistics();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<Header leftComponent="Exif Data" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{["Total", "Images", "Videos"].map((type, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-mono">{type}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-lg font-mono">
|
||||
{loading ? "Loading..." : statistics[type.toLowerCase() as keyof typeof statistics].toLocaleString()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-mono">Total Files</CardTitle>
|
||||
<CardTitle className="text-center text-lg font-mono">Live Photos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-lg font-mono">
|
||||
{(() => {
|
||||
const a = statistics.total;
|
||||
return a.toLocaleString();
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-mono">Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-lg font-mono">
|
||||
{(() => {
|
||||
const a = statistics.images;
|
||||
return a.toLocaleString();
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-mono">Videos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-lg font-mono">
|
||||
{(() => {
|
||||
const a = statistics.videos;
|
||||
return a.toLocaleString();
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-mono">Live</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-lg font-mono">
|
||||
{(() => {
|
||||
const a = 50000;
|
||||
return a.toLocaleString();
|
||||
})()}
|
||||
{loading ? "Loading..." : livePhotoStatistics.total.toLocaleString()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ContributionGraph />
|
||||
<AssetHeatMap />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{exifCharts.map((chart) => (
|
||||
<EXIFDistribution
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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, desc, eq, isNotNull, ne, sql, } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
// Helper function to format date as YYYY-MM-DD
|
||||
function formatDate(date: Date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function fillMissingDates(data: any[]) {
|
||||
const today = new Date();
|
||||
|
||||
// Set the start date as one year ago, excluding the current month in the previous year
|
||||
const oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth() + 1, 1); // First day of the next month last year
|
||||
|
||||
const existingDates = new Set(data.map(item => item.date));
|
||||
|
||||
const filledData = [];
|
||||
|
||||
// Iterate from today to one year ago, but skip the current month from last year
|
||||
for (let d = new Date(today); d >= oneYearAgo; d.setDate(d.getDate() - 1)) {
|
||||
const dateStr = formatDate(d);
|
||||
|
||||
// Skip dates in the current month of the previous year
|
||||
if (d.getFullYear() === today.getFullYear() - 1 && d.getMonth() === today.getMonth()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingDates.has(dateStr)) {
|
||||
filledData.push(data.find(item => item.date === dateStr));
|
||||
} else {
|
||||
filledData.push({ date: dateStr, count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the filled data in descending order
|
||||
filledData.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return filledData;
|
||||
}
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
|
||||
try {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const dataFromDB = await db.select({
|
||||
date: sql`DATE(${assets.fileCreatedAt})`.as('date'),
|
||||
count: count(),
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.ownerId, currentUser.id))
|
||||
.groupBy(sql`DATE(${assets.fileCreatedAt})`)
|
||||
.orderBy(sql`DATE(${assets.fileCreatedAt}) DESC`)
|
||||
const updatedData = fillMissingDates(dataFromDB);
|
||||
return res.status(200).json(updatedData);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { CHART_COLORS } from "@/config/constants/chart.constant";
|
||||
import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { assets, exif } from "@/schema";
|
||||
import { and, count, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
|
||||
try {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const dataFromDB = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(assets)
|
||||
.where(and(
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
isNotNull(assets.livePhotoVideoId))
|
||||
);
|
||||
return res.status(200).json(dataFromDB);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user