This commit is contained in:
2026-03-22 11:20:51 -04:00
parent 27e651c4f5
commit 9ed63b2695
16 changed files with 303 additions and 156 deletions

View File

@@ -1,3 +1,4 @@
import { describe, expect, it } from 'vitest';
import { getSVGGraphData } from './ReadingHistoryGraph';
// Test data matching Go test exactly

View File

@@ -9,9 +9,6 @@ export interface SVGPoint {
y: number;
}
/**
* Generates bezier control points for smooth curves
*/
function getSVGBezierOpposedLine(
pointA: SVGPoint,
pointB: SVGPoint
@@ -19,7 +16,6 @@ function getSVGBezierOpposedLine(
const lengthX = pointB.x - pointA.x;
const lengthY = pointB.y - pointA.y;
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
return {
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
@@ -32,7 +28,6 @@ function getBezierControlPoint(
nextPoint: SVGPoint | null,
isReverse: boolean
): SVGPoint {
// First / Last Point
let pPrev = prevPoint;
let pNext = nextPoint;
if (!pPrev) {
@@ -42,57 +37,49 @@ function getBezierControlPoint(
pNext = currentPoint;
}
// Modifiers
const smoothingRatio: number = 0.2;
const directionModifier: number = isReverse ? Math.PI : 0;
const smoothingRatio = 0.2;
const directionModifier = isReverse ? Math.PI : 0;
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
const lineAngle: number = opposingLine.Angle + directionModifier;
const lineLength: number = opposingLine.Length * smoothingRatio;
const lineAngle = opposingLine.Angle + directionModifier;
const lineLength = opposingLine.Length * smoothingRatio;
// Calculate Control Point - Go converts everything to int
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
return {
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
};
}
/**
* Generates the bezier path for the graph
*/
function getSVGBezierPath(points: SVGPoint[]): string {
if (points.length === 0) {
return '';
}
let bezierSVGPath: string = '';
let bezierSVGPath = '';
for (let index = 0; index < points.length; index++) {
const point = points[index];
if (!point) {
continue;
}
if (index === 0) {
bezierSVGPath += `M ${point.x},${point.y}`;
} else {
const pointPlusOne = points[index + 1];
const pointMinusOne = points[index - 1];
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
const startControlPoint: SVGPoint = getBezierControlPoint(
pointMinusOne,
pointMinusTwo,
point,
false
);
const endControlPoint: SVGPoint = getBezierControlPoint(
point,
pointMinusOne,
pointPlusOne || point,
true
);
// Go converts all coordinates to int
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
continue;
}
const pointMinusOne = points[index - 1];
if (!pointMinusOne) {
continue;
}
const pointPlusOne = points[index + 1] ?? point;
const pointMinusTwo = index - 2 >= 0 ? (points[index - 2] ?? null) : null;
const startControlPoint = getBezierControlPoint(pointMinusOne, pointMinusTwo, point, false);
const endControlPoint = getBezierControlPoint(point, pointMinusOne, pointPlusOne, true);
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
}
return bezierSVGPath;
@@ -105,42 +92,35 @@ export interface SVGGraphData {
Offset: number;
}
/**
* Get SVG Graph Data
*/
export function getSVGGraphData(
inputData: GraphDataPoint[],
svgWidth: number,
svgHeight: number
): SVGGraphData {
// Derive Height
let maxHeight: number = 0;
let maxHeight = 0;
for (const item of inputData) {
if (item.minutes_read > maxHeight) {
maxHeight = item.minutes_read;
}
}
// Vertical Graph Real Estate
const sizePercentage: number = 0.5;
const sizePercentage = 0.5;
const sizeRatio = maxHeight > 0 ? (svgHeight * sizePercentage) / maxHeight : 0;
const blockOffset = inputData.length > 0 ? Math.floor(svgWidth / inputData.length) : 0;
// Scale Ratio -> Desired Height
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
// Point Block Offset
const blockOffset: number = Math.floor(svgWidth / inputData.length);
// Line & Bar Points
const linePoints: SVGPoint[] = [];
// Bezier Fill Coordinates (Max X, Min X, Max Y)
let maxBX: number = 0;
let maxBY: number = 0;
let minBX: number = 0;
let maxBX = 0;
let maxBY = 0;
let minBX = 0;
for (let idx = 0; idx < inputData.length; idx++) {
// Go uses int conversion
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
const item = inputData[idx];
if (!item) {
continue;
}
const itemSize = Math.floor(item.minutes_read * sizeRatio);
const itemY = svgHeight - itemSize;
const lineX = (idx + 1) * blockOffset;
@@ -162,7 +142,6 @@ export function getSVGGraphData(
}
}
// Return Data
return {
LinePoints: linePoints,
BezierPath: getSVGBezierPath(linePoints),
@@ -171,27 +150,14 @@ export function getSVGGraphData(
};
}
/**
* Formats a date string to YYYY-MM-DD format (ISO-like)
* Note: The date string from the API is already in YYYY-MM-DD format,
* but since JavaScript Date parsing can add timezone offsets, we use UTC
* methods to ensure we get the correct date.
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
// Use UTC methods to avoid timezone offset issues
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ReadingHistoryGraph component
*
* Displays a bezier curve graph of daily reading totals with hover tooltips.
* Exact copy of Go template implementation.
*/
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800;
const svgHeight = 70;
@@ -204,11 +170,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
);
}
const {
BezierPath,
BezierFill,
LinePoints: _linePoints,
} = getSVGGraphData(data, svgWidth, svgHeight);
const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
return (
<div className="relative">
@@ -227,7 +189,6 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
{data.map((point, i) => (
<div
key={i}
onClick
className="w-full opacity-0 hover:opacity-100"
style={{
background:

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn';
export interface Column<T extends Record<string, unknown>> {
export interface Column<T extends object> {
key: keyof T;
header: string;
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
className?: string;
}
export interface TableProps<T extends Record<string, unknown>> {
export interface TableProps<T extends object> {
columns: Column<T>[];
data: T[];
loading?: boolean;
@@ -17,7 +17,6 @@ export interface TableProps<T extends Record<string, unknown>> {
rowKey?: keyof T | ((row: T) => string);
}
// Skeleton table component for loading state
function SkeletonTable({
rows = 5,
columns = 4,
@@ -28,7 +27,7 @@ function SkeletonTable({
className?: string;
}) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<div className={cn('overflow-hidden rounded-lg bg-white dark:bg-gray-700', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
@@ -58,19 +57,19 @@ function SkeletonTable({
);
}
export function Table<T extends Record<string, unknown>>({
export function Table<T extends object>({
columns,
data,
loading = false,
emptyMessage = 'No Results',
rowKey,
}: TableProps<T>) {
const getRowKey = (_row: T, index: number): string => {
const getRowKey = (row: T, index: number): string => {
if (typeof rowKey === 'function') {
return rowKey(_row);
return rowKey(row);
}
if (rowKey) {
return String(_row[rowKey] ?? index);
return String(row[rowKey] ?? index);
}
return `row-${index}`;
};
@@ -113,7 +112,9 @@ export function Table<T extends Record<string, unknown>>({
key={`${getRowKey(row, index)}-${String(column.key)}`}
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
>
{column.render ? column.render(row[column.key], row, index) : row[column.key]}
{column.render
? column.render(row[column.key], row, index)
: (row[column.key] as React.ReactNode)}
</td>
))}
</tr>