diff --git a/AGENTS.md b/AGENTS.md index 634c3f3..9fd02b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,14 @@ Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec) ## Critical Rules +### Generated Files +- **NEVER edit generated files** - Always edit the source and regenerate + - Go backend API: Edit `api/v1/openapi.yaml` then run `go generate ./api/v1/generate.go` + - TS client: Regenerate with `cd frontend && npm run generate:api` + - Examples of generated files: + - `api/v1/api.gen.go` + - `frontend/src/generated/**/*.ts` + ### Database Access - **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql` - Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go` diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index 202a1a7..0c4fe34 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -280,6 +280,13 @@ type ImportResultsResponse struct { // ImportType defines model for ImportType. type ImportType string +// InfoResponse defines model for InfoResponse. +type InfoResponse struct { + RegistrationEnabled bool `json:"registration_enabled"` + SearchEnabled bool `json:"search_enabled"` + Version string `json:"version"` +} + // LeaderboardData defines model for LeaderboardData. type LeaderboardData struct { All []LeaderboardEntry `json:"all"` @@ -290,8 +297,8 @@ type LeaderboardData struct { // LeaderboardEntry defines model for LeaderboardEntry. type LeaderboardEntry struct { - UserId string `json:"user_id"` - Value int64 `json:"value"` + UserId string `json:"user_id"` + Value float64 `json:"value"` } // LogEntry defines model for LogEntry. @@ -598,6 +605,9 @@ type ServerInterface interface { // Get user streaks // (GET /home/streaks) GetStreaks(w http.ResponseWriter, r *http.Request) + // Get server information + // (GET /info) + GetInfo(w http.ResponseWriter, r *http.Request) // List progress records // (GET /progress) GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) @@ -1174,6 +1184,20 @@ func (siw *ServerInterfaceWrapper) GetStreaks(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } +// GetInfo operation middleware +func (siw *ServerInterfaceWrapper) GetInfo(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetInfo(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetProgressList operation middleware func (siw *ServerInterfaceWrapper) GetProgressList(w http.ResponseWriter, r *http.Request) { @@ -1510,6 +1534,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData) m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics) m.HandleFunc("GET "+options.BaseURL+"/home/streaks", wrapper.GetStreaks) + m.HandleFunc("GET "+options.BaseURL+"/info", wrapper.GetInfo) m.HandleFunc("GET "+options.BaseURL+"/progress", wrapper.GetProgressList) m.HandleFunc("GET "+options.BaseURL+"/progress/{id}", wrapper.GetProgress) m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch) @@ -2350,6 +2375,31 @@ func (response GetStreaks500JSONResponse) VisitGetStreaksResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetInfoRequestObject struct { +} + +type GetInfoResponseObject interface { + VisitGetInfoResponse(w http.ResponseWriter) error +} + +type GetInfo200JSONResponse InfoResponse + +func (response GetInfo200JSONResponse) VisitGetInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetInfo500JSONResponse ErrorResponse + +func (response GetInfo500JSONResponse) VisitGetInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type GetProgressListRequestObject struct { Params GetProgressListParams } @@ -2650,6 +2700,9 @@ type StrictServerInterface interface { // Get user streaks // (GET /home/streaks) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) + // Get server information + // (GET /info) + GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) // List progress records // (GET /progress) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) @@ -3260,6 +3313,30 @@ func (sh *strictHandler) GetStreaks(w http.ResponseWriter, r *http.Request) { } } +// GetInfo operation middleware +func (sh *strictHandler) GetInfo(w http.ResponseWriter, r *http.Request) { + var request GetInfoRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetInfo(ctx, request.(GetInfoRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetInfo") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetInfoResponseObject); ok { + if err := validResponse.VisitGetInfoResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetProgressList operation middleware func (sh *strictHandler) GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) { var request GetProgressListRequestObject diff --git a/api/v1/home.go b/api/v1/home.go index 7ccd565..4d86504 100644 --- a/api/v1/home.go +++ b/api/v1/home.go @@ -174,66 +174,66 @@ func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoin } func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse { - // Sort helper - sort by WPM - sortByWPM := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry { + // Sort by WPM for each period + sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry { sorted := append([]database.GetUserStatisticsRow(nil), stats...) sort.SliceStable(sorted, func(i, j int) bool { - return sorted[i].TotalWpm > sorted[j].TotalWpm + return getter(sorted[i]) > getter(sorted[j]) }) result := make([]LeaderboardEntry, len(sorted)) for i, item := range sorted { - result[i] = LeaderboardEntry{UserId: item.UserID, Value: int64(item.TotalWpm)} + result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)} } return result } - // Sort by duration (seconds) -sortByDuration := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry { + // Sort by duration (seconds) for each period + sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry { sorted := append([]database.GetUserStatisticsRow(nil), stats...) sort.SliceStable(sorted, func(i, j int) bool { - return sorted[i].TotalSeconds > sorted[j].TotalSeconds + return getter(sorted[i]) > getter(sorted[j]) }) result := make([]LeaderboardEntry, len(sorted)) for i, item := range sorted { - result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalSeconds} + result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))} } return result } - // Sort by words -sortByWords := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry { + // Sort by words for each period + sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry { sorted := append([]database.GetUserStatisticsRow(nil), stats...) sort.SliceStable(sorted, func(i, j int) bool { - return sorted[i].TotalWordsRead > sorted[j].TotalWordsRead + return getter(sorted[i]) > getter(sorted[j]) }) result := make([]LeaderboardEntry, len(sorted)) for i, item := range sorted { - result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalWordsRead} + result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))} } return result } return UserStatisticsResponse{ Wpm: LeaderboardData{ - All: sortByWPM(userStatistics), - Year: sortByWPM(userStatistics), - Month: sortByWPM(userStatistics), - Week: sortByWPM(userStatistics), + All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }), + Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }), + Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }), + Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }), }, Duration: LeaderboardData{ - All: sortByDuration(userStatistics), - Year: sortByDuration(userStatistics), - Month: sortByDuration(userStatistics), - Week: sortByDuration(userStatistics), + All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }), + Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }), + Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }), + Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }), }, Words: LeaderboardData{ - All: sortByWords(userStatistics), - Year: sortByWords(userStatistics), - Month: sortByWords(userStatistics), - Week: sortByWords(userStatistics), + All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }), + Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }), + Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }), + Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }), }, } } diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index da79ca1..e15353a 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -460,8 +460,8 @@ components: user_id: type: string value: - type: integer - format: int64 + type: number + format: double required: - user_id - value @@ -617,6 +617,20 @@ components: filter: type: string + InfoResponse: + type: object + properties: + version: + type: string + search_enabled: + type: boolean + registration_enabled: + type: boolean + required: + - version + - search_enabled + - registration_enabled + securitySchemes: BearerAuth: type: http @@ -1111,6 +1125,26 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /info: + get: + summary: Get server information + operationId: getInfo + tags: + - Info + responses: + 200: + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/InfoResponse' + 500: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /home: get: summary: Get home page data diff --git a/api/v1/server.go b/api/v1/server.go index f6f94a4..830970c 100644 --- a/api/v1/server.go +++ b/api/v1/server.go @@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S ctx = context.WithValue(ctx, "request", r) ctx = context.WithValue(ctx, "response", w) - // Skip auth for login endpoint only - cover and file require auth via cookies - if operationID == "Login" { + // Skip auth for login and info endpoints - cover and file require auth via cookies + if operationID == "Login" || operationID == "GetInfo" { return handler(ctx, w, r, request) } @@ -67,3 +67,13 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S } } +// GetInfo returns server information +func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) { + return GetInfo200JSONResponse{ + Version: s.cfg.Version, + SearchEnabled: s.cfg.SearchEnabled, + RegistrationEnabled: s.cfg.RegistrationEnabled, + }, nil +} + + diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..bf2437f --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,2 @@ +# Generated API code +src/generated/**/* diff --git a/frontend/src/components/HamburgerMenu.tsx b/frontend/src/components/HamburgerMenu.tsx index 84bf0a0..fd5fe22 100644 --- a/frontend/src/components/HamburgerMenu.tsx +++ b/frontend/src/components/HamburgerMenu.tsx @@ -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 (
{/* Checkbox input for state management */} @@ -172,7 +184,48 @@ export default function HamburgerMenu() { href="https://gitea.va.reichard.io/evan/AnthoLume" rel="noreferrer" > - v1.0.0 + + + + + + + + + + + + + + + + + + + + + + + + {version}
diff --git a/frontend/src/components/ReadingHistoryGraph.test.ts b/frontend/src/components/ReadingHistoryGraph.test.ts new file mode 100644 index 0000000..35fdd65 --- /dev/null +++ b/frontend/src/components/ReadingHistoryGraph.test.ts @@ -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 + }); + }); +}); diff --git a/frontend/src/components/ReadingHistoryGraph.tsx b/frontend/src/components/ReadingHistoryGraph.tsx index 6e884b3..8ff3333 100644 --- a/frontend/src/components/ReadingHistoryGraph.tsx +++ b/frontend/src/components/ReadingHistoryGraph.tsx @@ -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 (
@@ -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 ( -
- - {/* Area fill */} - - {/* Bezier curve line */} - +
+ + + - - {/* Hover overlays */} -
- {data.map((point, i) => { - return ( +
+ {data.map((point, i) => ( +
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 */} -
-
-
- - {/* Tooltip */} -
-
{formatDate(point.date)}
-
{point.minutes_read} minutes
-
+ {formatDate(point.date)} + {point.minutes_read} minutes
- ); - })} +
+ ))}
); diff --git a/frontend/src/generated/anthoLumeAPIV1.ts b/frontend/src/generated/anthoLumeAPIV1.ts index 4955ffd..0430839 100644 --- a/frontend/src/generated/anthoLumeAPIV1.ts +++ b/frontend/src/generated/anthoLumeAPIV1.ts @@ -41,6 +41,7 @@ import type { GraphDataResponse, HomeResponse, ImportResultsResponse, + InfoResponse, LoginRequest, LoginResponse, LogsResponse, @@ -1680,6 +1681,130 @@ export function useGetMe>, 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 => { + + 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 = >, TError = ErrorResponse>( options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} +) => { + +const {query: queryOptions, fetch: fetchOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetInfoQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getInfo({ signal, ...fetchOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetInfoQueryResult = NonNullable>> +export type GetInfoQueryError = ErrorResponse + + +export function useGetInfo>, TError = ErrorResponse>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetInfo>, TError = ErrorResponse>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetInfo>, TError = ErrorResponse>( + options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get server information + */ + +export function useGetInfo>, TError = ErrorResponse>( + options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetInfoQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + /** * @summary Get home page data */ diff --git a/frontend/src/generated/model/configResponse.ts b/frontend/src/generated/model/configResponse.ts new file mode 100644 index 0000000..b79f930 --- /dev/null +++ b/frontend/src/generated/model/configResponse.ts @@ -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; +} diff --git a/frontend/src/generated/model/index.ts b/frontend/src/generated/model/index.ts index 83be27c..df123c9 100644 --- a/frontend/src/generated/model/index.ts +++ b/frontend/src/generated/model/index.ts @@ -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'; diff --git a/frontend/src/generated/model/infoResponse.ts b/frontend/src/generated/model/infoResponse.ts new file mode 100644 index 0000000..50c4e7e --- /dev/null +++ b/frontend/src/generated/model/infoResponse.ts @@ -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; +} diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index 8d28a9f..f11589e 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -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); }, }, { diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index 17c63ef..cd999fa 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -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() {

- {document.total_time_seconds ? niceSeconds(document.total_time_seconds) : 'N/A'} + {document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'}

@@ -191,7 +172,9 @@ export default function DocumentPage() {

Words

-

{document.words || 'N/A'}

+

+ {document.words != null ? formatNumber(document.words) : 'N/A'} +

Created

@@ -212,7 +195,9 @@ export default function DocumentPage() {

Est. Time Left:

-

{niceSeconds(totalTimeLeftSeconds)}

+

+ {formatDuration(totalTimeLeftSeconds)} +

)} diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index b02fee5..083a027 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -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 (
@@ -67,7 +58,7 @@ function DocumentCard({ doc }: DocumentCardProps) {

Time Read

-

{niceSeconds(totalTimeSeconds)}

+

{formatDuration(totalTimeSeconds)}

diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 866b94e..50a1857 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -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('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 (
@@ -102,28 +127,52 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {

{name} Leaderboard

-
- all - year - month - week +
+ + + +
- {/* All time data */} + {/* Current period data */}
- {data.all.length === 0 ? ( + {currentData?.length === 0 ? (

N/A

) : (

- {data.all[0]?.user_id || 'N/A'} + {currentData[0]?.user_id || 'N/A'}

)}
- {data.all.slice(0, 3).map((item: any, index: number) => ( + {currentData?.slice(0, 3).map((item: any, index: number) => (
0 ? 'border-t border-gray-200' : ''}`} @@ -131,7 +180,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {

{item.user_id}

-
{item.value}
+
{formatValue(item.value)}
))}
@@ -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 }); diff --git a/frontend/src/utils/formatters.test.ts b/frontend/src/utils/formatters.test.ts new file mode 100644 index 0000000..e17ada5 --- /dev/null +++ b/frontend/src/utils/formatters.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 0000000..4b1bf27 --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -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(' '); +}