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>
);

View File

@@ -41,6 +41,7 @@ import type {
GraphDataResponse,
HomeResponse,
ImportResultsResponse,
InfoResponse,
LoginRequest,
LoginResponse,
LogsResponse,
@@ -1680,6 +1681,130 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
/**
* @summary Get server information
*/
export type getInfoResponse200 = {
data: InfoResponse
status: 200
}
export type getInfoResponse500 = {
data: ErrorResponse
status: 500
}
export type getInfoResponseSuccess = (getInfoResponse200) & {
headers: Headers;
};
export type getInfoResponseError = (getInfoResponse500) & {
headers: Headers;
};
export type getInfoResponse = (getInfoResponseSuccess | getInfoResponseError)
export const getGetInfoUrl = () => {
return `/api/v1/info`
}
export const getInfo = async ( options?: RequestInit): Promise<getInfoResponse> => {
const res = await fetch(getGetInfoUrl(),
{
...options,
method: 'GET'
}
)
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: getInfoResponse['data'] = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } as getInfoResponse
}
export const getGetInfoQueryKey = () => {
return [
`/api/v1/info`
] as const;
}
export const getGetInfoQueryOptions = <TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
) => {
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetInfoQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getInfo>>> = ({ signal }) => getInfo({ signal, ...fetchOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetInfoQueryResult = NonNullable<Awaited<ReturnType<typeof getInfo>>>
export type GetInfoQueryError = ErrorResponse
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getInfo>>,
TError,
Awaited<ReturnType<typeof getInfo>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getInfo>>,
TError,
Awaited<ReturnType<typeof getInfo>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get server information
*/
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetInfoQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get home page data
*/

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface ConfigResponse {
version: string;
search_enabled: boolean;
registration_enabled: boolean;
}

View File

@@ -9,6 +9,7 @@
export * from './activity';
export * from './activityResponse';
export * from './backupType';
export * from './configResponse';
export * from './createDocumentBody';
export * from './databaseInfo';
export * from './device';
@@ -33,6 +34,7 @@ export * from './importResult';
export * from './importResultsResponse';
export * from './importResultStatus';
export * from './importType';
export * from './infoResponse';
export * from './leaderboardData';
export * from './leaderboardEntry';
export * from './logEntry';

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface InfoResponse {
version: string;
search_enabled: boolean;
registration_enabled: boolean;
}

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { useGetActivity } from '../generated/anthoLumeAPIV1';
import { Table } from '../components/Table';
import { formatDuration } from '../utils/formatters';
export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
@@ -28,18 +29,7 @@ export default function ActivityPage() {
key: 'duration' as const,
header: 'Duration',
render: (value: any) => {
if (!value) return 'N/A';
// Format duration (in seconds) to readable format
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = value % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
return formatDuration(value || 0);
},
},
{

View File

@@ -1,5 +1,6 @@
import { useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { formatDuration, formatNumber } from '../utils/formatters';
interface Document {
id: string;
@@ -30,26 +31,6 @@ interface Progress {
author?: string;
}
// Helper function to format seconds nicely (mirroring legacy niceSeconds)
function niceSeconds(seconds: number): string {
if (seconds === 0) return 'N/A';
const days = Math.floor(seconds / 60 / 60 / 24);
const remainingSeconds = seconds % (60 * 60 * 24);
const hours = Math.floor(remainingSeconds / 60 / 60);
const remainingAfterHours = remainingSeconds % (60 * 60);
const minutes = Math.floor(remainingAfterHours / 60);
const remainingSeconds2 = remainingAfterHours % 60;
let result = '';
if (days > 0) result += `${days}d `;
if (hours > 0) result += `${hours}h `;
if (minutes > 0) result += `${minutes}m `;
if (remainingSeconds2 > 0) result += `${remainingSeconds2}s`;
return result || 'N/A';
}
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
@@ -163,7 +144,7 @@ export default function DocumentPage() {
</div>
<div className="relative">
<p className="text-lg font-medium">
{document.total_time_seconds ? niceSeconds(document.total_time_seconds) : 'N/A'}
{document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'}
</p>
</div>
</div>
@@ -191,7 +172,9 @@ export default function DocumentPage() {
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-gray-500">Words</p>
<p className="font-medium">{document.words || 'N/A'}</p>
<p className="font-medium">
{document.words != null ? formatNumber(document.words) : 'N/A'}
</p>
</div>
<div>
<p className="text-gray-500">Created</p>
@@ -212,7 +195,9 @@ export default function DocumentPage() {
</div>
<div className="flex items-center gap-2">
<p className="text-gray-500">Est. Time Left:</p>
<p className="whitespace-nowrap font-medium">{niceSeconds(totalTimeLeftSeconds)}</p>
<p className="whitespace-nowrap font-medium">
{formatDuration(totalTimeLeftSeconds)}
</p>
</div>
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'
import { Activity, Download, Search, Upload } from 'lucide-react';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters';
interface DocumentCardProps {
doc: {
@@ -23,16 +24,6 @@ function DocumentCard({ doc }: DocumentCardProps) {
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
// Convert seconds to nice format (e.g., "2h 30m")
const niceSeconds = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
return (
<div className="relative w-full">
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
@@ -67,7 +58,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Time Read</p>
<p className="font-medium">{niceSeconds(totalTimeSeconds)}</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
import type { LeaderboardData } from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters';
interface InfoCardProps {
title: string;
@@ -93,7 +95,30 @@ interface LeaderboardCardProps {
data: LeaderboardData;
}
type TimePeriod = 'all' | 'year' | 'month' | 'week';
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>('all');
const formatValue = (value: number): string => {
switch (name) {
case 'WPM':
return `${value.toFixed(2)} WPM`;
case 'Duration':
return formatDuration(value);
case 'Words':
return formatNumber(value);
default:
return value.toString();
}
};
const currentData = data[selectedPeriod];
const handlePeriodChange = (period: TimePeriod) => {
setSelectedPeriod(period);
};
return (
<div className="w-full">
<div className="flex size-full flex-col justify-between rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
@@ -102,28 +127,52 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
{name} Leaderboard
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="cursor-pointer hover:text-black dark:hover:text-white">all</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">year</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">month</span>
<span className="cursor-pointer hover:text-black dark:hover:text-white">week</span>
<div className="flex gap-2 text-xs text-gray-400 items-center">
<button
type="button"
onClick={() => handlePeriodChange('all')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'all' ? '!text-black dark:!text-white' : ''}`}
>
all
</button>
<button
type="button"
onClick={() => handlePeriodChange('year')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'year' ? '!text-black dark:!text-white' : ''}`}
>
year
</button>
<button
type="button"
onClick={() => handlePeriodChange('month')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'month' ? '!text-black dark:!text-white' : ''}`}
>
month
</button>
<button
type="button"
onClick={() => handlePeriodChange('week')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'week' ? '!text-black dark:!text-white' : ''}`}
>
week
</button>
</div>
</div>
</div>
{/* All time data */}
{/* Current period data */}
<div className="my-6 flex items-end space-x-2">
{data.all.length === 0 ? (
{currentData?.length === 0 ? (
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
) : (
<p className="text-5xl font-bold text-black dark:text-white">
{data.all[0]?.user_id || 'N/A'}
{currentData[0]?.user_id || 'N/A'}
</p>
)}
</div>
<div className="dark:text-white">
{data.all.slice(0, 3).map((item: any, index: number) => (
{currentData?.slice(0, 3).map((item: any, index: number) => (
<div
key={index}
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
@@ -131,7 +180,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<div>
<p>{item.user_id}</p>
</div>
<div className="flex items-end font-bold">{item.value}</div>
<div className="flex items-end font-bold">{formatValue(item.value)}</div>
</div>
))}
</div>
@@ -140,8 +189,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
);
}
export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome();
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });

View File

@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { formatNumber, formatDuration } from './formatters';
describe('formatNumber', () => {
it('formats zero', () => {
expect(formatNumber(0)).toBe('0');
});
it('formats small numbers', () => {
expect(formatNumber(5)).toBe('5.00');
expect(formatNumber(15)).toBe('15.0');
expect(formatNumber(99)).toBe('99.0');
});
it('formats thousands', () => {
expect(formatNumber(19823)).toBe('19.8k');
expect(formatNumber(1984)).toBe('1.98k');
expect(formatNumber(1000)).toBe('1.00k');
});
it('formats millions', () => {
expect(formatNumber(1500000)).toBe('1.50M');
expect(formatNumber(198236461)).toBe('198M');
expect(formatNumber(1000000)).toBe('1.00M');
});
it('formats large numbers', () => {
expect(formatNumber(1500000000)).toBe('1.50B');
expect(formatNumber(1500000000000)).toBe('1.50T');
});
it('formats negative numbers', () => {
expect(formatNumber(-12345)).toBe('-12.3k');
expect(formatNumber(-1500000)).toBe('-1.50M');
});
it('matches Go test cases exactly', () => {
expect(formatNumber(0)).toBe('0');
expect(formatNumber(19823)).toBe('19.8k');
expect(formatNumber(1500000)).toBe('1.50M');
expect(formatNumber(-12345)).toBe('-12.3k');
});
});
describe('formatDuration', () => {
it('formats zero as N/A', () => {
expect(formatDuration(0)).toBe('N/A');
});
it('formats seconds only', () => {
expect(formatDuration(5)).toBe('5s');
expect(formatDuration(15)).toBe('15s');
});
it('formats minutes and seconds', () => {
expect(formatDuration(60)).toBe('1m');
expect(formatDuration(75)).toBe('1m 15s');
expect(formatDuration(315)).toBe('5m 15s');
});
it('formats hours, minutes, and seconds', () => {
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(3665)).toBe('1h 1m 5s');
expect(formatDuration(3915)).toBe('1h 5m 15s');
});
it('formats days, hours, minutes, and seconds', () => {
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
});
it('matches Go test cases exactly', () => {
expect(formatDuration(0)).toBe('N/A');
expect(formatDuration(22 * 24 * 60 * 60 + 7 * 60 * 60 + 39 * 60 + 31)).toBe('22d 7h 39m 31s');
expect(formatDuration(5 * 60 + 15)).toBe('5m 15s');
});
});

View File

@@ -0,0 +1,72 @@
/**
* FormatNumber takes a number and returns a human-readable string.
* For example: 19823 -> "19.8k", 1500000 -> "1.50M"
*/
export function formatNumber(input: number): string {
if (input === 0) {
return '0';
}
// Handle negative numbers
const negative = input < 0;
if (negative) {
input = -input;
}
const abbreviations = ['', 'k', 'M', 'B', 'T'];
const abbrevIndex = Math.floor(Math.log10(input) / 3);
// Bounds check
const safeIndex = Math.min(abbrevIndex, abbreviations.length - 1);
const scaledNumber = input / Math.pow(10, safeIndex * 3);
let result: string;
if (scaledNumber >= 100) {
result = `${Math.round(scaledNumber)}${abbreviations[safeIndex]}`;
} else if (scaledNumber >= 10) {
result = `${scaledNumber.toFixed(1)}${abbreviations[safeIndex]}`;
} else {
result = `${scaledNumber.toFixed(2)}${abbreviations[safeIndex]}`;
}
if (negative) {
result = `-${result}`;
}
return result;
}
/**
* FormatDuration takes duration in seconds and returns a human-readable string.
* For example: 1928371 seconds -> "22d 7h 39m 31s"
*/
export function formatDuration(seconds: number): string {
if (seconds === 0) {
return 'N/A';
}
const parts: string[] = [];
const days = Math.floor(seconds / (60 * 60 * 24));
seconds %= 60 * 60 * 24;
const hours = Math.floor(seconds / (60 * 60));
seconds %= 60 * 60;
const minutes = Math.floor(seconds / 60);
seconds %= 60;
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (seconds > 0) {
parts.push(`${seconds}s`);
}
return parts.join(' ');
}