diff --git a/components/ClimbingDashboard.vue b/components/ClimbingDashboard.vue
index 0851ba2..fac8060 100644
--- a/components/ClimbingDashboard.vue
+++ b/components/ClimbingDashboard.vue
@@ -69,7 +69,7 @@
-
Total Sport Climbs
+
Total Climbs
{{ totalClimbs }}
@@ -77,31 +77,14 @@
{{ totalSends }}
(Onsight, Flash, Redpoint, Pinkpoint)
-
-
Most Common Grade
-
{{ commonGrade }}
-
-
-
+
Climbs by Grade
-
Progress Over Time
+ Climbs Per Year
@@ -134,7 +117,7 @@
-
Recent Climbs
+
Climbs
@@ -143,26 +126,20 @@
| Rating |
Style |
Area |
+ Notes |
-
+
| {{ climb.Date }} |
{{ climb.Route }} |
{{ climb.Rating }} |
{{ climb["Lead Style"] }} |
{{ climb.Area }} |
+ {{ climb.Notes }} |
-
-
-
@@ -289,11 +266,9 @@ export default {
// Climbing data
totalClimbs: "--",
totalSends: "--",
- commonGrade: "--",
orderedClimbs: [],
gradeData: [],
sendGradeData: [],
- showSendsOnly: true,
timeData: {},
popularAreas: [],
popularCrags: [],
@@ -328,17 +303,24 @@ export default {
},
uniqueGrades() {
const grades = new Set();
+
this.orderedClimbs.forEach((climb) => {
if (climb.Rating) grades.add(climb.Rating);
});
+
return [...grades].sort((a, b) => {
- // Try to sort climbing grades in a logical order
- const aNum = parseFloat(a.replace(/[^\d.]/g, ""));
- const bNum = parseFloat(b.replace(/[^\d.]/g, ""));
+ const aStr = String(a);
+ const bStr = String(b);
+
+ const aNum = parseFloat(aStr.replace(/[^\d.]/g, ""));
+ const bNum = parseFloat(bStr.replace(/[^\d.]/g, ""));
+
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum - bNum;
}
- return a.localeCompare(b);
+
+ // Fall back to string comparison
+ return aStr.localeCompare(bStr);
});
},
uniqueStyles() {
@@ -450,12 +432,6 @@ export default {
// Set dashboard data
this.totalClimbs = data.total_climbs;
this.totalSends = data.total_sends;
- this.commonGrade =
- data.send_grades && data.send_grades.length > 0
- ? data.send_grades[0][0]
- : data.grades && data.grades.length > 0
- ? data.grades[0][0]
- : "";
// Process climb data
this.orderedClimbs = data.all_climbs || data.ordered_climbs || [];
@@ -498,10 +474,21 @@ export default {
},
createBarChart() {
+ // Exit early if the chart element doesn't exist
if (!this.$refs.gradeChart) {
+ console.log("Chart reference not found");
return;
}
+ const ctx = this.$refs.gradeChart.getContext("2d");
+
+ // Clear any existing chart
+ if (this.gradeChart) {
+ this.gradeChart.destroy();
+ this.gradeChart = null;
+ }
+
+ // Define the order of climbing grades we want to display
const GRADE_ORDER = [
"5.6",
"5.7",
@@ -525,48 +512,117 @@ export default {
"5.13d",
];
- const ctx = this.$refs.gradeChart.getContext("2d");
-
- if (this.gradeChart) {
- this.gradeChart.destroy();
- }
+ // Prepare data for sends and attempts separately
+ let sendCounts = {};
+ let attemptCounts = {};
- const dataToUse = this.showSendsOnly
- ? this.sendGradeData
- : this.gradeData;
+ // Initialize with zero counts for all grades
+ GRADE_ORDER.forEach((grade) => {
+ sendCounts[grade] = 0;
+ attemptCounts[grade] = 0;
+ });
- const gradeCounts = {};
- dataToUse.forEach(([grade, count]) => {
- gradeCounts[grade] = count;
+ // Count sends (climbs where Lead Style is one of the send styles)
+ this.orderedClimbs.forEach((climb) => {
+ if (!climb.Rating) return;
+
+ const grade = climb.Rating.toString();
+ if (!GRADE_ORDER.includes(grade)) return;
+
+ const style = climb["Lead Style"] || "";
+ const sendStyles = [
+ "Onsight",
+ "Flash",
+ "Redpoint",
+ "Pinkpoint",
+ "Send",
+ ];
+
+ // If it's a send, add to sends count, otherwise to attempts count
+ if (sendStyles.includes(style)) {
+ sendCounts[grade] += 1;
+ } else {
+ attemptCounts[grade] += 1;
+ }
});
- const orderedGradeData = GRADE_ORDER.map((grade) => [
- grade,
- gradeCounts[grade] || 0,
- ]);
+ // Prepare the datasets for the chart
+ const labels = GRADE_ORDER;
+ const sendsData = GRADE_ORDER.map((grade) => sendCounts[grade] || 0);
+ const attemptsData = GRADE_ORDER.map(
+ (grade) => attemptCounts[grade] || 0
+ );
+ // Create the stacked bar chart
this.gradeChart = new Chart(ctx, {
type: "bar",
data: {
- labels: orderedGradeData.map((item) => item[0]),
+ labels: labels,
datasets: [
{
- label: "Climbs by Grade",
- data: orderedGradeData.map((item) => item[1]),
- backgroundColor: this.showSendsOnly ? "#2ECC71" : "#3498DB",
+ label: "Sends",
+ data: sendsData,
+ backgroundColor: "#2ECC71", // Green for sends
+ borderColor: "#27AE60",
+ borderWidth: 1,
+ },
+ {
+ label: "Attempts",
+ data: attemptsData,
+ backgroundColor: "#3498DB", // Blue for attempts
+ borderColor: "#2980B9",
+ borderWidth: 1,
},
],
},
options: {
responsive: true,
- plugins: {
- legend: {
- display: false,
- },
- },
scales: {
+ x: {
+ stacked: true,
+ title: {
+ display: true,
+ text: "Climbing Grade",
+ },
+ },
y: {
+ stacked: true,
beginAtZero: true,
+ title: {
+ display: true,
+ text: "Number of Climbs",
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: "top",
+ },
+ tooltip: {
+ callbacks: {
+ // Custom tooltip to show both the count and percentage
+ afterLabel: function (context) {
+ const datasetIndex = context.datasetIndex;
+ const index = context.dataIndex;
+ const grade = labels[index];
+ const sends = sendCounts[grade] || 0;
+ const attempts = attemptCounts[grade] || 0;
+ const total = sends + attempts;
+
+ if (datasetIndex === 0) {
+ // Sends dataset
+ return `${Math.round(
+ (sends / total) * 100
+ )}% of ${grade} climbs`;
+ } else {
+ // Attempts dataset
+ return `${Math.round(
+ (attempts / total) * 100
+ )}% of ${grade} climbs`;
+ }
+ },
+ },
},
},
},
@@ -830,29 +886,51 @@ export default {
margin-bottom: 15px;
}
+.chart-legend {
+ display: flex;
+ gap: 15px;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+}
+
+.color-box {
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ margin-right: 5px;
+ border-radius: 3px;
+}
+
+.sends-color {
+ background-color: #2ecc71;
+}
+
+.attempts-color {
+ background-color: #3498db;
+}
+
.chart-card h3 {
margin-top: 0;
color: #555;
}
-.toggle-container {
+.radio-group {
display: flex;
- align-items: center;
+ gap: 15px;
}
-.toggle {
- display: inline-flex;
+.radio-label {
+ display: flex;
align-items: center;
cursor: pointer;
}
-.toggle input {
- margin-right: 8px;
-}
-
-.toggle-label {
- font-size: 14px;
- color: #555;
+.radio-text {
+ margin-left: 5px;
}
.locations-container {
diff --git a/server/api/tick-export.js b/server/api/tick-export.js
index 2637d93..8eeedb4 100644
--- a/server/api/tick-export.js
+++ b/server/api/tick-export.js
@@ -1,10 +1,5 @@
import Papa from "papaparse";
-/**
- * Parses a Mountain Project location string to extract Area, Crag, and Wall components
- * @param {string} location - The full location string from Mountain Project
- * @returns {Object} An object with area, crag and wall properties
- */
function parseLocation(location) {
const result = {
area: "",
@@ -14,27 +9,10 @@ function parseLocation(location) {
if (!location) return result;
- // Split the location by ">" and trim each part
- const parts = location.split(">").map((part) => part.trim());
+ // Split the location by ">"
+ const parts = location.split(">");
- // Need at least 2 parts to have any meaningful location data
- if (parts.length < 2) return result;
-
- // For Red River Gorge locations
- if (parts.length >= 2 && parts[1] === "Red River Gorge") {
- result.area = "Red River Gorge";
-
- if (parts.length >= 3) {
- // Example: Kentucky > Red River Gorge > Bald Rock Recreational Preserve (BRRP)
- result.crag = parts[2];
-
- if (parts.length >= 4) {
- // Example: Kentucky > Red River Gorge > Bald Rock Recreational Preserve (BRRP) > Chocolate Factory
- result.wall = parts[3];
- }
- }
- } else if (parts.length >= 2) {
- // For locations outside Red River Gorge
+ if (parts.length >= 2) {
result.area = parts[1];
if (parts.length >= 3) {
@@ -49,11 +27,7 @@ function parseLocation(location) {
return result;
}
-/**
- * Normalizes a grade by handling slashes like "5.11a/b" -> "5.11b"
- * @param {string} grade - The climbing grade to normalize
- * @returns {string} The normalized grade
- */
+// Normalizes a grade by handling slashes like "5.11a/b" -> "5.11b"
function normalizeGrade(grade) {
if (!grade || typeof grade !== "string") return grade;
@@ -66,17 +40,9 @@ function normalizeGrade(grade) {
return grade;
}
-/**
- * Gets the lead style from a row, handling different possible field names
- * @param {Object} row - A data row from Mountain Project CSV
- * @returns {string|null} The lead style or null if not found
- */
+// Gets the lead style from a row, handling different possible field names
function getLedStyle(row) {
- const possibleFields = ["Lead Style", "LeadStyle", "leadstyle", "Lead_Style"];
- for (const field of possibleFields) {
- if (row[field] !== undefined) return row[field];
- }
- return null;
+ return row["Lead Style"] !== undefined ? row["Lead Style"] : null;
}
export default defineEventHandler(async (event) => {
@@ -89,17 +55,13 @@ export default defineEventHandler(async (event) => {
let url =
"https://www.mountainproject.com/user/201253016/nick-mackowski/tick-export";
- // If userId and userName are provided, use them instead (self-service mode)
+ // If userId and userName are provided, use them instead
if (userId && userName) {
- console.log(
- `Received request for userId: ${userId}, userName: ${userName}`
- );
url = `https://www.mountainproject.com/user/${userId}/${userName}/tick-export`;
- console.log(`Fetching data from: ${url}`);
}
try {
- // Add timeout and retry logic for more reliable fetching
+ // Add timeout for more reliable fetching
const fetchWithTimeout = async (url, options = {}, timeout = 10000) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
@@ -117,23 +79,7 @@ export default defineEventHandler(async (event) => {
}
};
- // Try up to 3 times with increasing timeouts
- let csvText;
- let attempts = 0;
- const maxAttempts = 3;
-
- while (attempts < maxAttempts) {
- try {
- csvText = await fetchWithTimeout(url, {}, 10000 * (attempts + 1));
- break;
- } catch (error) {
- attempts++;
- console.log(`Attempt ${attempts} failed: ${error.message}`);
- if (attempts >= maxAttempts) throw error;
- // Wait before retry
- await new Promise((resolve) => setTimeout(resolve, 1000));
- }
- }
+ const csvText = await fetchWithTimeout(url);
// Check if we got data
if (!csvText || csvText.trim() === "") {
@@ -164,21 +110,11 @@ export default defineEventHandler(async (event) => {
row.Rating
);
- console.log(`Parsed ${rows.length} valid rows`);
-
- if (!rows.length) {
- return {
- error: "No climbing data",
- details:
- "No valid climbing data found. Make sure you've logged climbs on Mountain Project.",
- };
- }
-
- // Process the data
+ // Output total number of climbs
const total_climbs = rows.length;
- // Define send styles - include boulder problems with V grades
- const sendStyles = ["Onsight", "Flash", "Redpoint", "Pinkpoint", "Send"];
+ // Define send styles
+ const sendStyles = ["Onsight", "Flash", "Redpoint", "Pinkpoint"];
// Count total sends properly, including boulder problems
const total_sends = rows.filter((row) => {