Merge pull request #271 from dgilperez/feat/analytics-heatmap-year-selector

feat(analytics): year selector on asset heatmap
This commit is contained in:
Sai charan Guntupalli authored and GitHub committed 2026-05-01 16:31:56 +05:30
commit b8dee930b5
3 files changed
+140 -70

No files matched your search

+45 -14
View File
@@ -1,13 +1,16 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { getHeatMapData } from "@/handlers/api/analytics.handler";
import { useConfig } from "@/contexts/ConfigContext";
import { Tooltip } from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type HeatMapEntry = {
date: string;
count: number;
};
const ROLLING = "rolling" as const;
const COLOR_SCALE = [
"bg-zinc-100 dark:bg-zinc-800/60",
"bg-emerald-200 dark:bg-emerald-900/70",
@@ -25,29 +28,39 @@ export default function AssetHeatMap() {
const [heatMapData, setHeatMapData] = useState<HeatMapEntry[][]>([]);
const [loading, setLoading] = useState(false);
const [weeksPerMonth, setWeeksPerMonth] = useState<number[]>([]);
const [availableYears, setAvailableYears] = useState<number[]>([]);
const [selection, setSelection] = useState<typeof ROLLING | number>(ROLLING);
const fetchHeatMapData = async () => {
const fetchHeatMapData = async (sel: typeof ROLLING | number) => {
setLoading(true);
try {
const data = await getHeatMapData();
setHeatMapData(formatHeatMapData(data));
const resp = await getHeatMapData(sel === ROLLING ? undefined : sel);
setAvailableYears(resp.availableYears ?? []);
setHeatMapData(formatHeatMapData(resp.data));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchHeatMapData();
}, []);
fetchHeatMapData(selection);
}, [selection]);
const currentDate = new Date();
const months: string[] = [];
for (let i = 0; i < 12; i++) {
months.push(currentDate.toLocaleString("default", { month: "short" }));
currentDate.setMonth(currentDate.getMonth() - 1);
}
months.reverse();
const months = useMemo(() => {
if (selection === ROLLING) {
const cursor = new Date();
const out: string[] = [];
for (let i = 0; i < 12; i++) {
out.push(cursor.toLocaleString("default", { month: "short" }));
cursor.setMonth(cursor.getMonth() - 1);
}
return out.reverse();
}
// Calendar year: Jan-Dec in order.
return Array.from({ length: 12 }, (_, i) =>
new Date(selection, i, 1).toLocaleString("default", { month: "short" })
);
}, [selection]);
const flattenedData = heatMapData.flat();
const minCount = Math.min(...flattenedData.map((entry) => entry.count));
@@ -106,6 +119,24 @@ export default function AssetHeatMap() {
return (
<div className="w-full">
<div className="flex items-center justify-end mb-2">
<Select
value={selection === ROLLING ? ROLLING : String(selection)}
onValueChange={(v) => setSelection(v === ROLLING ? ROLLING : Number(v))}
>
<SelectTrigger className="w-[180px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ROLLING}>Last 12 months</SelectItem>
{availableYears.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto">
<table className="table-auto border-separate border-spacing-[3px]">
<thead>
+9 -2
View File
@@ -16,8 +16,15 @@ export const getLivePhotoStatistics = async () => {
return API.get(LIVE_PHOTO_STATISTICS);
}
export const getHeatMapData = async () => {
return API.get(HEATMAP_DATA);
export interface IHeatMapResponse {
data: Array<{ date: string; count: number }>;
year: number | null;
availableYears: number[];
}
export const getHeatMapData = async (year?: number): Promise<IHeatMapResponse> => {
const params = year ? { year: String(year) } : {};
return API.get(HEATMAP_DATA, params);
}
export const getPeopleNamesStatistics = async () => {
+86 -54
View File
@@ -1,69 +1,101 @@
// 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, gte, isNotNull, ne, sql, } from "drizzle-orm";
import { assets } from "@/schema";
import { and, between, eq, gte, sql } from "drizzle-orm";
import { count } 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];
return date.toISOString().split("T")[0];
}
function fillMissingDates(data: any[]) {
const today = new Date();
/**
* Fill gaps in a date-indexed series so the heatmap always has 365ish cells
* drawn, even for dates with zero photos.
*
* - No `year` → 12-month rolling window ending today (legacy behaviour).
* - `year` given → fill every day of that calendar year.
*/
function fillMissingDates(data: Array<{ date: string; count: number }>, year?: number) {
const existing = new Map(data.map((d) => [d.date, d]));
// 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
let start: Date;
let end: Date;
if (year) {
start = new Date(Date.UTC(year, 0, 1));
end = new Date(Date.UTC(year, 11, 31));
} else {
end = new Date();
start = new Date(end.getFullYear() - 1, end.getMonth() + 1, 1);
}
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 });
}
const filled: Array<{ date: string; count: number }> = [];
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
// Legacy quirk preserved: skip days that land in the repeating "current
// month" of the previous year in the rolling 12-month window, so the
// heatmap isn't double-counted on the seam.
if (!year && d.getFullYear() === end.getFullYear() - 1 && d.getMonth() === end.getMonth()) {
continue;
}
// Sort the filled data in descending order
filledData.sort((a, b) => a.date.localeCompare(b.date));
return filledData;
const key = formatDate(d);
filled.push(existing.get(key) ?? { date: key, count: 0 });
}
filled.sort((a, b) => a.date.localeCompare(b.date));
return filled;
}
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(),
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const currentUser = await getCurrentUser(req);
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
const rawYear = (req.query.year as string | undefined)?.trim();
const year =
rawYear && /^\d{4}$/.test(rawYear) ? Number.parseInt(rawYear, 10) : undefined;
// Range clause: either a whole calendar year, or the legacy 12-month rolling window.
const rangeClause = year
? between(
assets.fileCreatedAt,
sql`${`${year}-01-01`}::timestamptz`,
sql`${`${year}-12-31 23:59:59`}::timestamptz`
)
: gte(assets.fileCreatedAt, sql`CURRENT_DATE - INTERVAL '1 YEAR'`);
const [dataFromDB, yearsRaw] = await Promise.all([
db
.select({
date: sql<string>`TO_CHAR(DATE(${assets.fileCreatedAt}), 'YYYY-MM-DD')`.as("date"),
count: count(),
})
.from(assets)
.where(
and(
eq(assets.ownerId, currentUser.id),
gte(assets.fileCreatedAt, sql`CURRENT_DATE - INTERVAL '1 YEAR'`))
)
.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,
});
}
.from(assets)
.where(and(eq(assets.ownerId, currentUser.id), rangeClause))
.groupBy(sql`DATE(${assets.fileCreatedAt})`)
.orderBy(sql`DATE(${assets.fileCreatedAt}) DESC`),
// Distinct years the user has assets for, for the UI year selector.
db
.select({
year: sql<number>`EXTRACT(YEAR FROM ${assets.fileCreatedAt})::int`.as("year"),
})
.from(assets)
.where(eq(assets.ownerId, currentUser.id))
.groupBy(sql`EXTRACT(YEAR FROM ${assets.fileCreatedAt})`)
.orderBy(sql`EXTRACT(YEAR FROM ${assets.fileCreatedAt}) DESC`),
]);
const filled = fillMissingDates(
(dataFromDB as Array<{ date: string; count: number }>),
year
);
return res.status(200).json({
data: filled,
year: year ?? null,
availableYears: (yearsRaw as Array<{ year: number }>).map((r) => r.year).filter(Boolean),
});
} catch (error: any) {
console.error(error);
return res.status(500).json({ error: error?.message });
}
}