Merge pull request #271 from dgilperez/feat/analytics-heatmap-year-selector
feat(analytics): year selector on asset heatmap
This commit is contained in:
3 files changed
+140
-70
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user