diff --git a/app/components/board-provider/board-provider-context.tsx b/app/components/board-provider/board-provider-context.tsx index ed1ead6..0ef5038 100644 --- a/app/components/board-provider/board-provider-context.tsx +++ b/app/components/board-provider/board-provider-context.tsx @@ -333,7 +333,6 @@ export function BoardProvider({ boardName, children }: { boardName: BoardName; c loginInfo: null, }); }; - const value = { isAuthenticated: !!authState.token, token: authState.token, diff --git a/app/components/logbook/logbook-drawer.tsx b/app/components/logbook/logbook-drawer.tsx index 07a3f8c..d17237b 100644 --- a/app/components/logbook/logbook-drawer.tsx +++ b/app/components/logbook/logbook-drawer.tsx @@ -3,6 +3,7 @@ import Drawer from 'antd/es/drawer'; import React, { useState } from 'react'; import { LogAscentForm } from './logascent-form'; import { LogbookView } from './logbook-view'; +import { LogBookStats } from './logbook-stats'; import { BoardDetails, Climb } from '@/app/lib/types'; interface LogbookDrawerProps { @@ -10,6 +11,8 @@ interface LogbookDrawerProps { closeDrawer: () => void; currentClimb: Climb | null; boardDetails: BoardDetails; + boardName: string; + userId: string; } export const LogbookDrawer: React.FC = ({ @@ -17,22 +20,53 @@ export const LogbookDrawer: React.FC = ({ closeDrawer, currentClimb, boardDetails, + boardName, + userId, }) => { + // State to manage the drawer expansion and active view const [expanded, setExpanded] = useState(false); const [showLogbookView, setShowLogbookView] = useState(false); const [showLogAscentForm, setShowLogAscentForm] = useState(false); + const [showLogBookStats, setShowLogBookStats] = useState(false); const handleClose = () => { setExpanded(false); setShowLogbookView(false); setShowLogAscentForm(false); + setShowLogBookStats(false); closeDrawer(); }; + const handleButtonClick = (view: 'logbook' | 'logAscent' | 'stats') => { + setExpanded(true); + + if (view === 'logbook') { + setShowLogbookView(true); + setShowLogAscentForm(false); + setShowLogBookStats(false); + } else if (view === 'logAscent') { + setShowLogAscentForm(true); + setShowLogbookView(false); + setShowLogBookStats(false); + } else if (view === 'stats') { + setShowLogBookStats(true); + setShowLogbookView(false); + setShowLogAscentForm(false); + } + }; + return ( = ({ type="primary" block style={{ maxWidth: '400px', width: '100%' }} - onClick={() => { - setShowLogbookView(true); - setExpanded(true); - }} + onClick={() => handleButtonClick('logbook')} > Logbook - {/* */} + Log Ascent + ) : ( <> - {/* TODO: Make sure these buttons never become visible - when there is no climb selected */} {showLogbookView && currentClimb && } {showLogAscentForm && currentClimb && ( )} + {showLogBookStats && } )} diff --git a/app/components/logbook/logbook-stats.tsx b/app/components/logbook/logbook-stats.tsx new file mode 100644 index 0000000..57542c4 --- /dev/null +++ b/app/components/logbook/logbook-stats.tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useState } from 'react'; +import { Bar, Pie } from 'react-chartjs-2'; +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, TooltipItem } from 'chart.js'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +dayjs.extend(isoWeek); + +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement); + +const optionsBar = { + responsive: true, + plugins: { + legend: { + display: true, + position: 'top' as const, + }, + title: { + display: true, + text: 'Ascents by Difficulty (Stacked)', + }, + }, + scales: { + x: { + stacked: true, + }, + y: { + stacked: true, + }, + }, +}; + +const optionsPie = { + responsive: true, + plugins: { + legend: { + position: 'top' as const, + }, + title: { + display: true, + text: 'Route Count by Angle', + }, + }, +}; + +const optionsWeeklyBar = { + responsive: true, + plugins: { + legend: { + display: true, + position: 'top' as const, + }, + title: { + display: true, + text: 'Weekly Attempts by Difficulty', + }, + tooltip: { + callbacks: { + label: function (context: TooltipItem<'bar'>) { + const label = context.dataset.label || ''; + const value = (context.raw as number) || 0; + return value > 0 ? `${label}: ${value}` : ''; + }, + footer: function (tooltipItems: TooltipItem<'bar'>[]) { + let total = 0; + tooltipItems.forEach((tooltipItem) => { + total += (tooltipItem.raw as number) || 0; + }); + return `Total: ${total}`; + }, + }, + mode: 'index' as const, + intersect: false, + }, + }, + scales: { + x: { + stacked: true, + }, + y: { + stacked: true, + }, + }, +}; + +const difficultyMapping: Record = { + 10: '4a', + 11: '4b', + 12: '4c', + 13: '5a', + 14: '5b', + 15: '5c', + 16: '6a', + 17: '6a+', + 18: '6b', + 19: '6b+', + 20: '6c', + 21: '6c+', + 22: '7a', + 23: '7a+', + 24: '7b', + 25: '7b+', + 26: '7c', + 27: '7c+', + 28: '8a', + 29: '8a+', + 30: '8b', + 31: '8b+', + 32: '8c', + 33: '8c+', +}; + +const gradeColors: Record = { + '4a': 'rgba(153,255,153,0.7)', // Light Green + '4b': 'rgba(179,255,128,0.7)', // Soft Green-Yellow + '4c': 'rgba(204,255,102,0.7)', // Yellow-Green + '5a': 'rgba(230,255,77,0.7)', // Yellowish + '5b': 'rgba(255,255,51,0.7)', // Yellow + '5c': 'rgba(255,230,25,0.7)', // Dark Yellow + '6a': 'rgba(255,204,51,0.7)', // Golden Yellow + '6a+': 'rgba(255,179,77,0.7)', // Light Orange + '6b': 'rgba(255,153,102,0.7)', // Orange + '6b+': 'rgba(255,128,128,0.7)', // Peachy Red + '6c': 'rgba(204,102,204,0.7)', // Light Violet + '6c+': 'rgba(153,102,255,0.7)', // Indigo + '7a': 'rgba(102,102,255,0.7)', // Blue + '7a+': 'rgba(77,128,255,0.7)', // Light Blue + '7b': 'rgba(51,153,255,0.7)', // Sky Blue + '7b+': 'rgba(25,179,255,0.7)', // Cyan + '7c': 'rgba(25,204,230,0.7)', // Light Cyan + '7c+': 'rgba(51,204,204,0.7)', // Blue-Green + '8a': 'rgba(255,77,77,0.7)', // Red + '8a+': 'rgba(204,51,153,0.7)', // Deep Magenta + '8b': 'rgba(153,51,204,0.9)', // Purple + '8b+': 'rgba(102,51,153,1)', // Dark Purple + '8c': 'rgba(77,25,128,1)', // Very Dark Purple + '8c+': 'rgba(51,0,102,1)', // Deep Violet +}; + +// Define types for logbook entries +interface LogbookEntry { + climbed_at: string; + difficulty: number; + tries: number; + angle: number; +} + +// Define types for chart data +interface ChartData { + labels: string[]; + datasets: { + label: string; + data: number[]; + backgroundColor: string | string[]; + }[]; +} + +export const LogBookStats: React.FC<{ boardName: string; userId: string }> = ({ boardName, userId }) => { + const [logbook, setLogbook] = useState([]); + const [chartDataBar, setChartDataBar] = useState(null); + const [chartDataPie, setChartDataPie] = useState(null); + const [chartDataWeeklyBar, setChartDataWeeklyBar] = useState(null); + const [timeframe, setTimeframe] = useState('all'); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + + useEffect(() => { + const fetchLogbook = async () => { + if (!boardName || !userId) return; + try { + const response = await fetch(`/api/v1/${boardName}/proxy/getLogbook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, climbUuids: "", }), + }); + const data = await response.json(); + setLogbook(data); + } catch (error) { + console.error('Error fetching logbook:', error); + } + }; + fetchLogbook(); + }, [boardName, userId]); + + const filterLogbookByTimeframe = (logbook: LogbookEntry[]) => { + const now = dayjs(); + switch (timeframe) { + case 'lastWeek': + return logbook.filter(entry => dayjs(entry.climbed_at).isAfter(now.subtract(1, 'week'))); + case 'lastMonth': + return logbook.filter(entry => dayjs(entry.climbed_at).isAfter(now.subtract(1, 'month'))); + case 'lastYear': + return logbook.filter(entry => dayjs(entry.climbed_at).isAfter(now.subtract(1, 'year'))); + case 'custom': + return logbook.filter(entry => { + const climbedAt = dayjs(entry.climbed_at); + return climbedAt.isAfter(dayjs(fromDate)) && climbedAt.isBefore(dayjs(toDate)); + }); + case 'all': + default: + return logbook; + } + }; + + const filteredLogbook = filterLogbookByTimeframe(logbook); + + useEffect(() => { + if (filteredLogbook.length > 0) { + const greaterThanOne: Record = {}; + const equalToOne: Record = {}; + filteredLogbook.forEach((entry) => { + const difficulty = difficultyMapping[entry.difficulty]; + if (entry.tries > 1) { + greaterThanOne[difficulty] = (greaterThanOne[difficulty] || 0) + entry.tries; + } else if (entry.tries === 1) { + equalToOne[difficulty] = (equalToOne[difficulty] || 0) + 1; + } + }); + const labels = Object.keys({ ...greaterThanOne, ...equalToOne }).sort(); + setChartDataBar({ + labels, + datasets: [ + { + label: 'Flash', + data: labels.map((label) => equalToOne[label] || 0), + backgroundColor: 'rgba(75,192,192,0.5)', + }, + { + label: 'Redpoint', + data: labels.map((label) => greaterThanOne[label] || 0), + backgroundColor: 'rgba(192,75,75,0.5)', + }, + ], + }); + } + }, [filteredLogbook]); + + useEffect(() => { + if (filteredLogbook.length > 0) { + const angles = filteredLogbook.reduce((acc: Record, entry) => { + const angle = `${entry.angle}°`; + acc[angle] = (acc[angle] || 0) + 1; + return acc; + }, {}); + + setChartDataPie({ + labels: Object.keys(angles), + datasets: [ + { + label: 'Routes by Angle', + data: Object.values(angles), + backgroundColor: Object.keys(angles).map((_, index) => { + const angleColors = [ + 'rgba(255,77,77,0.7)', // Red + 'rgba(51,0,102,1)', // Deep Violet + 'rgba(77,128,255,0.7)', // Light Blue + 'rgba(255,204,51,0.7)', // Golden Yellow + 'rgba(204,51,153,0.7)', // Deep Magenta + 'rgba(51,204,204,0.7)', // Blue-Green + 'rgba(255,230,25,0.7)', // Dark Yellow + 'rgba(102,102,255,0.7)', // Blue + 'rgba(51,153,255,0.7)', // Sky Blue + 'rgba(25,179,255,0.7)', // Cyan + 'rgba(255,255,51,0.7)', // Yellow + 'rgba(102,51,153,1)', // Dark Purple + 'rgba(179,255,128,0.7)', // Soft Green-Yellow + ]; + return angleColors[index] || 'rgba(200,200,200,0.7)'; + }), + }, + ], + }); + } + }, [filteredLogbook]); + + useEffect(() => { + if (filteredLogbook.length > 0) { + const weeks: string[] = []; + const first = dayjs(filteredLogbook[filteredLogbook.length - 1]?.climbed_at).startOf('isoWeek'); + const last = dayjs(filteredLogbook[0]?.climbed_at).endOf('isoWeek'); + let current = first; + while (current.isBefore(last) || current.isSame(last)) { + weeks.push(`W.${current.isoWeek()} / ${current.year()}`); + current = current.add(1, 'week'); + } + const weeklyData: Record> = {}; + filteredLogbook.forEach((entry) => { + const week = `W.${dayjs(entry.climbed_at).isoWeek()} / ${dayjs(entry.climbed_at).year()}`; + const difficulty = difficultyMapping[entry.difficulty]; + weeklyData[week] = { + ...(weeklyData[week] || {}), + [difficulty]: (weeklyData[week]?.[difficulty] || 0) + 1, + }; + }); + const datasets = Object.values(difficultyMapping).map((difficulty) => { + const data = weeks.map((week) => weeklyData[week]?.[difficulty] || 0); + return { + label: difficulty, + data, + backgroundColor: gradeColors[difficulty], + }; + }).filter(dataset => dataset.data.some(value => value > 0)); // Ensure datasets with all zero values are filtered out + + setChartDataWeeklyBar({ + labels: weeks, + datasets, + }); + } + }, [filteredLogbook]); + + const buttonStyle = (btnTimeframe: string) => ({ + marginRight: '10px', + backgroundColor: timeframe === btnTimeframe ? '#007bff' : '#f8f9fa', + color: timeframe === btnTimeframe ? '#fff' : '#000', + border: '1px solid #007bff', + padding: '5px 10px', + cursor: 'pointer', + }); + + return ( +
+

LogBook Stats

+
+ + + + + + {timeframe === 'custom' && ( +
+ + +
+ )} +
+
+ {chartDataWeeklyBar ? ( + + ) : ( +

Loading weekly bar chart...

+ )} +
+
+
+ {chartDataBar ? ( + + ) : ( +

Loading bar chart...

+ )} +
+
+ {chartDataPie ? ( + + ) : ( +

Loading pie chart...

+ )} +
+
+
+ ); +}; diff --git a/app/components/logbook/tick-button.tsx b/app/components/logbook/tick-button.tsx index 78f858a..938461c 100644 --- a/app/components/logbook/tick-button.tsx +++ b/app/components/logbook/tick-button.tsx @@ -45,7 +45,7 @@ const LoginForm = ({ }; export const TickButton: React.FC = ({ currentClimb, angle, boardDetails }) => { - const { logbook, login, isAuthenticated } = useBoardProvider(); + const { logbook, login, isAuthenticated, user } = useBoardProvider(); // Assuming 'user' is available in the context const [drawerVisible, setDrawerVisible] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false); @@ -56,7 +56,6 @@ export const TickButton: React.FC = ({ currentClimb, angle, boa setIsLoggingIn(true); try { await login(boardDetails.board_name, username, password); - // Drawer content will automatically update once isAuthenticated changes } catch (error) { console.error('Login failed:', error); } finally { @@ -68,6 +67,9 @@ export const TickButton: React.FC = ({ currentClimb, angle, boa const hasSuccessfulAscent = filteredLogbook.some((asc) => asc.is_ascent); const badgeCount = filteredLogbook.length; + const boardName = boardDetails.board_name; + const userId = String(user?.id || ''); + return ( <> = ({ currentClimb, angle, boa closeDrawer={closeDrawer} currentClimb={currentClimb} boardDetails={boardDetails} + boardName={boardName} + userId={userId} /> ) : ( diff --git a/app/components/setup-wizard/angle-selection.tsx b/app/components/setup-wizard/angle-selection.tsx index b473612..11fc6c1 100644 --- a/app/components/setup-wizard/angle-selection.tsx +++ b/app/components/setup-wizard/angle-selection.tsx @@ -23,7 +23,7 @@ const AngleSelection = ({ board_name }: { board_name: BoardName }) => { return (
- Select a angle + Select an angle