This commit is contained in:
2026-03-21 15:41:30 -04:00
parent 197a1577c2
commit ba919bbde4
19 changed files with 824 additions and 198 deletions

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, FileText, Activity, Search, Settings } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
interface NavItem {
path: string;
@@ -36,6 +37,17 @@ export default function HamburgerMenu() {
const [isOpen, setIsOpen] = useState(false);
const isAdmin = user?.is_admin ?? false;
// Fetch server info for version
const { data: infoData } = useGetInfo({
query: {
staleTime: Infinity, // Info doesn't change frequently
},
});
const version =
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
? infoData.data.version
: 'v1.0.0';
return (
<div className="relative z-40 ml-6 flex flex-col">
{/* Checkbox input for state management */}
@@ -172,7 +184,48 @@ export default function HamburgerMenu() {
href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer"
>
<span className="text-xs">v1.0.0</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-black dark:text-white"
height="20"
viewBox="0 0 219 92"
fill="currentColor"
>
<defs>
<clipPath id="gitea_a">
<path d="M159 .79h25V69h-25Zm0 0" />
</clipPath>
<clipPath id="gitea_b">
<path d="M183 9h35.371v60H183Zm0 0" />
</clipPath>
<clipPath id="gitea_c">
<path d="M0 .79h92V92H0Zm0 0" />
</clipPath>
</defs>
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
/>
<g clipPath="url(#gitea_a)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
/>
</g>
<g clipPath="url(#gitea_b)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
/>
</g>
<g clipPath="url(#gitea_c)">
<path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
/>
</g>
</svg>
<span className="text-xs">{version}</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,52 @@
import { getSVGGraphData } from './ReadingHistoryGraph';
// Test data matching Go test exactly
const testInput = [
{ date: '2024-01-01', minutes_read: 10 },
{ date: '2024-01-02', minutes_read: 90 },
{ date: '2024-01-03', minutes_read: 50 },
{ date: '2024-01-04', minutes_read: 5 },
{ date: '2024-01-05', minutes_read: 10 },
{ date: '2024-01-06', minutes_read: 5 },
{ date: '2024-01-07', minutes_read: 70 },
{ date: '2024-01-08', minutes_read: 60 },
{ date: '2024-01-09', minutes_read: 50 },
{ date: '2024-01-10', minutes_read: 90 },
];
const svgWidth = 500;
const svgHeight = 100;
describe('ReadingHistoryGraph', () => {
describe('getSVGGraphData', () => {
it('should match exactly', () => {
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
// Expected values from Go test
const expectedBezierPath =
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
const expectedBezierFill = 'L 500,98 L 50,98 Z';
const expectedWidth = 500;
const expectedHeight = 100;
const expectedOffset = 50;
expect(result.BezierPath).toBe(expectedBezierPath);
expect(result.BezierFill).toBe(expectedBezierFill);
expect(svgWidth).toBe(expectedWidth);
expect(svgHeight).toBe(expectedHeight);
expect(result.Offset).toBe(expectedOffset);
// Verify line points are integers like Go
result.LinePoints.forEach((p, _i) => {
expect(Number.isInteger(p.x)).toBe(true);
expect(Number.isInteger(p.y)).toBe(true);
});
// Expected line points from Go calculation:
// idx 0: itemSize=5, itemY=95, lineX=50
// idx 1: itemSize=45, itemY=55, lineX=100
// idx 2: itemSize=25, itemY=75, lineX=150
// ...and so on
});
});
});

View File

@@ -2,11 +2,9 @@ import type { GraphDataPoint } from '../generated/model';
interface ReadingHistoryGraphProps {
data: GraphDataPoint[];
width?: number;
height?: number;
}
interface SVGPoint {
export interface SVGPoint {
x: number;
y: number;
}
@@ -14,6 +12,20 @@ interface SVGPoint {
/**
* Generates bezier control points for smooth curves
*/
function getSVGBezierOpposedLine(
pointA: SVGPoint,
pointB: SVGPoint
): { Length: number; Angle: number } {
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)),
};
}
function getBezierControlPoint(
currentPoint: SVGPoint,
prevPoint: SVGPoint | null,
@@ -21,92 +33,169 @@ function getBezierControlPoint(
isReverse: boolean
): SVGPoint {
// First / Last Point
const pPrev = prevPoint || currentPoint;
const pNext = nextPoint || currentPoint;
let pPrev = prevPoint;
let pNext = nextPoint;
if (!pPrev) {
pPrev = currentPoint;
}
if (!pNext) {
pNext = currentPoint;
}
const smoothingRatio = 0.2;
const directionModifier = isReverse ? Math.PI : 0;
// Modifiers
const smoothingRatio: number = 0.2;
const directionModifier: number = 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;
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
const lineAngle: number = opposingLine.Angle + directionModifier;
const lineLength: number = 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: currentPoint.x + Math.cos(angle) * controlPointLength,
y: currentPoint.y + Math.sin(angle) * controlPointLength,
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 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}`;
function getSVGBezierPath(points: SVGPoint[]): string {
if (points.length === 0) {
return '';
}
return path;
let bezierSVGPath: string = '';
for (let index = 0; index < points.length; index++) {
const point = points[index];
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}`;
}
}
return bezierSVGPath;
}
export interface SVGGraphData {
LinePoints: SVGPoint[];
BezierPath: string;
BezierFill: string;
Offset: number;
}
/**
* Calculate points for SVG rendering
* Get SVG Graph Data
*/
function calculatePoints(
data: GraphDataPoint[],
width: number,
height: number
): SVGPoint[] {
if (data.length === 0) return [];
export function getSVGGraphData(
inputData: GraphDataPoint[],
svgWidth: number,
svgHeight: number
): SVGGraphData {
// Derive Height
let maxHeight: number = 0;
for (const item of inputData) {
if (item.minutes_read > maxHeight) {
maxHeight = item.minutes_read;
}
}
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;
// Vertical Graph Real Estate
const sizePercentage: number = 0.5;
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 };
});
// 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;
for (let idx = 0; idx < inputData.length; idx++) {
// Go uses int conversion
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
const itemY = svgHeight - itemSize;
const lineX = (idx + 1) * blockOffset;
linePoints.push({
x: lineX,
y: itemY,
});
if (lineX > maxBX) {
maxBX = lineX;
}
if (lineX < minBX) {
minBX = lineX;
}
if (itemY > maxBY) {
maxBY = itemY;
}
}
// Return Data
return {
LinePoints: linePoints,
BezierPath: getSVGBezierPath(linePoints),
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
Offset: blockOffset,
};
}
/**
* Formats a date string
* 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);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
// 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,
width = 800,
height = 70,
}: ReadingHistoryGraphProps) {
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800;
const svgHeight = 70;
if (!data || data.length < 2) {
return (
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
@@ -115,62 +204,49 @@ export default function ReadingHistoryGraph({
);
}
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`;
const {
BezierPath,
BezierFill,
LinePoints: _linePoints,
} = getSVGGraphData(data, svgWidth, svgHeight);
return (
<div className="relative w-full">
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
width="100%"
height="100%"
className="h-24"
>
{/* Area fill */}
<path
fill="#316BBE"
fillOpacity="0.5"
stroke="none"
d={areaPath}
/>
{/* Bezier curve line */}
<path
fill="none"
stroke="#316BBE"
strokeWidth="2"
d={bezierPath}
/>
<div className="relative">
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
<path fill="none" stroke="#316BBE" d={BezierPath} />
</svg>
{/* Hover overlays */}
<div className="absolute top-0 size-full">
{data.map((point, i) => {
return (
<div
className="absolute top-0 flex size-full"
style={{
width: 'calc(100% * 31 / 30)',
transform: 'translateX(-50%)',
left: '50%',
}}
>
{data.map((point, i) => (
<div
key={i}
onClick
className="w-full opacity-0 hover:opacity-100"
style={{
background:
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
}}
>
<div
key={i}
className="group relative flex-1 cursor-pointer"
onClick={(e) => e.preventDefault()}
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
style={{
transform: 'translateX(-50%)',
left: '50%',
backgroundColor: 'rgba(128, 128, 128, 0.2)',
}}
>
{/* Vertical indicator line on hover */}
<div className="absolute inset-0 flex items-center opacity-0 group-hover:opacity-100">
<div className="h-full w-px bg-gray-400 opacity-30" />
</div>
{/* Tooltip */}
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-gray-200 dark:text-gray-900">
<div className="font-medium">{formatDate(point.date)}</div>
<div>{point.minutes_read} minutes</div>
</div>
<span>{formatDate(point.date)}</span>
<span>{point.minutes_read} minutes</span>
</div>
);
})}
</div>
))}
</div>
</div>
);