From 197a1577c2ba2d7a864ae608275bbc0646d977e1 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 16 Mar 2026 19:52:36 -0400 Subject: [PATCH] wip 14 --- .../src/components/ReadingHistoryGraph.tsx | 177 ++++++++++++++++++ frontend/src/components/index.ts | 3 + frontend/src/pages/HomePage.tsx | 32 +--- 3 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/ReadingHistoryGraph.tsx diff --git a/frontend/src/components/ReadingHistoryGraph.tsx b/frontend/src/components/ReadingHistoryGraph.tsx new file mode 100644 index 0000000..6e884b3 --- /dev/null +++ b/frontend/src/components/ReadingHistoryGraph.tsx @@ -0,0 +1,177 @@ +import type { GraphDataPoint } from '../generated/model'; + +interface ReadingHistoryGraphProps { + data: GraphDataPoint[]; + width?: number; + height?: number; +} + +interface SVGPoint { + x: number; + y: number; +} + +/** + * Generates bezier control points for smooth curves + */ +function getBezierControlPoint( + currentPoint: SVGPoint, + prevPoint: SVGPoint | null, + nextPoint: SVGPoint | null, + isReverse: boolean +): SVGPoint { + // First / Last Point + const pPrev = prevPoint || currentPoint; + const pNext = nextPoint || currentPoint; + + const smoothingRatio = 0.2; + const directionModifier = isReverse ? Math.PI : 0; + + const lengthX = pNext.x - pPrev.x; + const lengthY = pNext.y - pPrev.y; + + const length = Math.sqrt(lengthX * lengthX + lengthY * lengthY); + const angle = Math.atan2(lengthY, lengthX) + directionModifier; + const controlPointLength = length * smoothingRatio; + + return { + x: currentPoint.x + Math.cos(angle) * controlPointLength, + y: currentPoint.y + Math.sin(angle) * controlPointLength, + }; +} + +/** + * Generates the bezier path for the graph + */ +function generateBezierPath(points: SVGPoint[]): string { + if (points.length === 0) return ''; + + const first = points[0]; + let path = `M ${first.x},${first.y}`; + + for (let i = 1; i < points.length; i++) { + const current = points[i]; + const prev = points[i - 1]; + const prevPrev = i - 2 >= 0 ? points[i - 2] : current; + const next = i + 1 < points.length ? points[i + 1] : current; + + const startControl = getBezierControlPoint(prev, prevPrev, current, false); + const endControl = getBezierControlPoint(current, prev, next, true); + + path += ` C${startControl.x},${startControl.y} ${endControl.x},${endControl.y} ${current.x},${current.y}`; + } + + return path; +} + +/** + * Calculate points for SVG rendering + */ +function calculatePoints( + data: GraphDataPoint[], + width: number, + height: number +): SVGPoint[] { + if (data.length === 0) return []; + + const maxMinutes = Math.max(...data.map((d) => d.minutes_read), 1); + const paddingX = width * 0.03; // 3% padding on sides + const paddingY = height * 0.1; // 10% padding on top/bottom + const usableWidth = width - paddingX * 2; + const usableHeight = height - paddingY * 2; + + return data.map((point, index) => { + const x = paddingX + (index / (data.length - 1)) * usableWidth; + // Y is inverted (0 is top in SVG) + const y = + paddingY + usableHeight - (point.minutes_read / maxMinutes) * usableHeight; + return { x, y }; + }); +} + +/** + * Formats a date string + */ +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * ReadingHistoryGraph component + * + * Displays a bezier curve graph of daily reading totals with hover tooltips. + */ +export default function ReadingHistoryGraph({ + data, + width = 800, + height = 70, +}: ReadingHistoryGraphProps) { + if (!data || data.length < 2) { + return ( +
+

No data available

+
+ ); + } + + const points = calculatePoints(data, width, height); + const bezierPath = generateBezierPath(points); + + // Calculate fill path (closed loop for area fill) + const firstX = Math.min(...points.map((p) => p.x)); + const lastX = Math.max(...points.map((p) => p.x)); + + const areaPath = `${bezierPath} L ${lastX},${height} L ${firstX},${height} Z`; + + return ( +
+ + {/* Area fill */} + + {/* Bezier curve line */} + + + + {/* Hover overlays */} +
+ {data.map((point, i) => { + return ( +
e.preventDefault()} + > + {/* Vertical indicator line on hover */} +
+
+
+ + {/* Tooltip */} +
+
{formatDate(point.date)}
+
{point.minutes_read} minutes
+
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index fea8839..fba8417 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,3 +1,6 @@ +// Reading History Graph +export { default as ReadingHistoryGraph } from './ReadingHistoryGraph'; + // Toast components export { Toast } from './Toast'; export { ToastProvider, useToasts } from './ToastContext'; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 2eef0b7..866b94e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom'; import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1'; -import type { GraphDataPoint, LeaderboardData } from '../generated/model'; +import type { LeaderboardData } from '../generated/model'; +import ReadingHistoryGraph from '../components/ReadingHistoryGraph'; interface InfoCardProps { title: string; @@ -139,34 +140,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) { ); } -function GraphVisualization({ data }: { data: GraphDataPoint[] }) { - if (!data || data.length === 0) { - return ( -
-

No data available

-
- ); - } - // Simple bar visualization (could be enhanced with SVG bezier curve like SSR) - const maxMinutes = Math.max(...data.map(d => d.minutes_read), 1); - - return ( -
- {data.map((point, i) => ( -
-
- {point.minutes_read} min -
-
- ))} -
- ); -} export default function HomePage() { const { data: homeData, isLoading: homeLoading } = useGetHome(); @@ -190,7 +164,7 @@ export default function HomePage() {

Daily Read Totals

- +