From 3806ac389206ac06da3cbdba3f18d7e6fee30f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gil=20P=C3=A9rez?= Date: Tue, 21 Apr 2026 00:26:40 +0200 Subject: [PATCH] feat(analytics): year selector on asset heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today the Analytics heatmap hard-codes "last 12 months" both in the SQL (`WHERE fileCreatedAt >= CURRENT_DATE - INTERVAL '1 YEAR'`) and in the frontend's month labels loop. Users with multi-year libraries can't look back at 2018 or 2020 from this view. Changes: - API `/api/analytics/statistics/heatmap` now accepts an optional `year` query param. With it, the query spans the full calendar year (Jan 1 to Dec 31) and `fillMissingDates` walks that range. Without it, the legacy rolling 12-month behaviour is preserved (including the "skip the repeating current month from last year" seam guard). - Response shape changed from `[]` to `{ data, year, availableYears }`. `availableYears` comes from a DISTINCT EXTRACT(YEAR ...) over the user's assets so the UI can render a correct dropdown without guessing. - Component shows a Select next to the heatmap: "Last 12 months" (default) plus one entry per year that actually has assets. Month labels and the `weeksPerMonth` computation switch accordingly. - No migrations needed; drizzle query only. Callers of `getHeatMapData()` now receive an object — there was only one caller (the heatmap component) so the change is localised. --- .../analytics/exif/AssetHeatMap.tsx | 59 ++++++-- src/handlers/api/analytics.handler.ts | 11 +- .../api/analytics/statistics/heatmap.tsx | 140 +++++++++++------- 3 files changed, 140 insertions(+), 70 deletions(-) diff --git a/src/components/analytics/exif/AssetHeatMap.tsx b/src/components/analytics/exif/AssetHeatMap.tsx index 1418185..3b06679 100644 --- a/src/components/analytics/exif/AssetHeatMap.tsx +++ b/src/components/analytics/exif/AssetHeatMap.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [weeksPerMonth, setWeeksPerMonth] = useState([]); + const [availableYears, setAvailableYears] = useState([]); + const [selection, setSelection] = useState(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 (
+
+ +
diff --git a/src/handlers/api/analytics.handler.ts b/src/handlers/api/analytics.handler.ts index 605792e..523c7bb 100644 --- a/src/handlers/api/analytics.handler.ts +++ b/src/handlers/api/analytics.handler.ts @@ -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 => { + const params = year ? { year: String(year) } : {}; + return API.get(HEATMAP_DATA, params); } export const getPeopleNamesStatistics = async () => { diff --git a/src/pages/api/analytics/statistics/heatmap.tsx b/src/pages/api/analytics/statistics/heatmap.tsx index d330b1a..c502720 100644 --- a/src/pages/api/analytics/statistics/heatmap.tsx +++ b/src/pages/api/analytics/statistics/heatmap.tsx @@ -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`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`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 }); + } }