Ver Fonte

Add track type emoji in file list, folder type inference, always-visible buttons, and stats type filter

- Item actions (buttons) now use absolute positioning so they always
  overlay the row on hover, even when the sidebar is narrow
- Track type emoji moved to appear after the track name (not before)
- Folders display a type emoji when all their items share the same type
  (computed recursively from cached dir contents)
- Stats tab gains a type filter bar: pill buttons for each track type
  plus Untyped; multi-select; backend filters via ?type= query param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be há 5 horas atrás
pai
commit
b7c4a11b58

+ 24 - 1
gpx-vis-backend/src/routes/stats.js

@@ -1,6 +1,9 @@
 const router = require('express').Router();
 const { requireAuth } = require('../middleware/auth');
 const { Track } = require('../models');
+const { Op } = require('sequelize');
+
+const VALID_TYPES = ['hiking', 'running', 'cycling', 'driving', 'train', 'other'];
 
 function getWeek(date) {
   const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
@@ -12,8 +15,28 @@ function getWeek(date) {
 
 router.get('/', requireAuth, async (req, res) => {
   try {
+    const where = { userId: req.user.id };
+
+    // ?type=hiking,cycling  or  ?type=none  (untyped)  or omitted (all)
+    if (req.query.type !== undefined && req.query.type !== '') {
+      const requested = req.query.type.split(',').map(s => s.trim()).filter(Boolean);
+      const typeConditions = [];
+      const validRequested = requested.filter(t => VALID_TYPES.includes(t));
+      const includeNone = requested.includes('none');
+      if (validRequested.length > 0) typeConditions.push({ trackType: { [Op.in]: validRequested } });
+      if (includeNone) typeConditions.push({ trackType: null });
+      if (typeConditions.length === 1) {
+        Object.assign(where, typeConditions[0]);
+      } else if (typeConditions.length > 1) {
+        where[Op.or] = typeConditions;
+      } else {
+        // Nothing matched → return empty
+        return res.json({ byYear: [], byMonth: [], byWeek: [] });
+      }
+    }
+
     const tracks = await Track.findAll({
-      where: { userId: req.user.id },
+      where,
       attributes: ['totalDistance', 'trackDate', 'uploadDate'],
     });
 

+ 46 - 0
gpx-vis-frontend/css/style.css

@@ -567,6 +567,13 @@ form button[type="submit"]:hover {
   display: none;
   gap: 4px;
   flex-shrink: 0;
+  position: absolute;
+  right: 8px;
+  top: 0;
+  bottom: 0;
+  align-items: center;
+  padding-left: 6px;
+  background: var(--color-bg);
 }
 
 .dir-item:hover .item-actions,
@@ -574,6 +581,14 @@ form button[type="submit"]:hover {
   display: flex;
 }
 
+.multi-selected:hover .item-actions {
+  background: #d6ecf8;
+}
+
+.dir-item.selected:hover .item-actions {
+  background: #d6ecf8;
+}
+
 .item-btn {
   background: none;
   border: 1px solid #ddd;
@@ -940,6 +955,37 @@ form button[type="submit"]:hover {
   background: var(--color-red);
 }
 
+/* ===== Stats filter ===== */
+.stats-filter {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-bottom: 16px;
+}
+
+.stats-type-btn {
+  padding: 4px 10px;
+  border: 1px solid var(--color-border);
+  border-radius: 12px;
+  background: var(--color-white);
+  color: var(--color-text-light);
+  font-size: 12px;
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s, border-color 0.15s;
+  white-space: nowrap;
+}
+
+.stats-type-btn:hover {
+  border-color: var(--color-blue);
+  color: var(--color-blue);
+}
+
+.stats-type-btn.active {
+  background: var(--color-blue);
+  border-color: var(--color-blue);
+  color: var(--color-white);
+}
+
 /* ===== Stats ===== */
 .stats-section {
   margin-bottom: 24px;

+ 4 - 1
gpx-vis-frontend/js/api.js

@@ -60,7 +60,10 @@ const API = (() => {
     getShared: (code) => request('GET', `/api/share/${code}`),
 
     // Stats
-    getStats: () => request('GET', '/api/stats'),
+    getStats: (types) => {
+      const qs = types && types.length > 0 ? '?type=' + encodeURIComponent(types.join(',')) : '';
+      return request('GET', '/api/stats' + qs);
+    },
     getDirStats: (id) => request('GET', `/api/stats/directory/${id}`),
 
     // Admin

+ 29 - 4
gpx-vis-frontend/js/browser.js

@@ -121,6 +121,26 @@ const Browser = (() => {
     bindEvents(list);
   }
 
+  function getDirCommonType(dirId) {
+    const cached = dirContents[dirId];
+    if (!cached) return null;
+    const types = new Set();
+    for (const track of cached.tracks) {
+      if (!track.trackType) return null;
+      types.add(track.trackType);
+      if (types.size > 1) return null;
+    }
+    for (const subDir of cached.dirs) {
+      if (!dirContents[subDir.id]) return null; // uncached sub-dir → unknown
+      const subType = getDirCommonType(subDir.id);
+      if (!subType) return null;
+      types.add(subType);
+      if (types.size > 1) return null;
+    }
+    if (types.size === 0) return null;
+    return [...types][0];
+  }
+
   function buildTreeHtml(dirs, tracks, depth) {
     let html = '';
     const baseIndent = depth * 16;
@@ -128,12 +148,17 @@ const Browser = (() => {
     for (const dir of dirs) {
       const isExpanded = expandedDirs.has(dir.id);
       const isSelected = selectedDirId === dir.id;
+      const dirType = getDirCommonType(dir.id);
+      const dirTypeEmoji = dirType ? TRACK_TYPE_EMOJI[dirType] : null;
+      const dirTypeSuffix = dirTypeEmoji
+        ? ` <span class="type-emoji" title="${escAttr(dirType.charAt(0).toUpperCase() + dirType.slice(1))}">${dirTypeEmoji}</span>`
+        : '';
       html += `<div class="tree-item dir-item${isSelected ? ' selected' : ''}"
         data-id="${dir.id}" data-name="${escAttr(dir.name)}"
         style="padding-left:${12 + baseIndent}px" data-drop-target="true" draggable="true">
         <span class="expand-btn${isExpanded ? ' expanded' : ''}" data-expand="${dir.id}"></span>
         <span class="item-icon">📁</span>
-        <span class="item-name">${escHtml(dir.name)}</span>
+        <span class="item-name">${escHtml(dir.name)}${dirTypeSuffix}</span>
         <span class="item-date">${formatDate(dir.updatedAt)}</span>
         <span class="item-actions">
           <button class="item-btn rename-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Rename">✏️</button>
@@ -158,13 +183,13 @@ const Browser = (() => {
       const typeTitle = track.trackType
         ? track.trackType.charAt(0).toUpperCase() + track.trackType.slice(1)
         : '';
-      const emojiPrefix = typeEmoji
-        ? `<span class="type-emoji" title="${escAttr(typeTitle)}">${typeEmoji}</span> `
+      const emojiSuffix = typeEmoji
+        ? ` <span class="type-emoji" title="${escAttr(typeTitle)}">${typeEmoji}</span>`
         : '';
       html += `<div class="tree-item track-item${isSel ? ' multi-selected' : ''}" data-id="${track.id}" draggable="true"
         data-name="${escAttr(track.name)}" style="padding-left:${28 + baseIndent}px">
         <span class="item-icon">🗺️</span>
-        <span class="item-name">${emojiPrefix}${escHtml(track.name)}</span>
+        <span class="item-name">${escHtml(track.name)}${emojiSuffix}</span>
         <span class="item-meta">${dist}</span>
         <span class="item-date">${formatDate(track.trackDate || track.uploadDate)}</span>
         <span class="item-actions">

+ 46 - 4
gpx-vis-frontend/js/stats.js

@@ -1,4 +1,13 @@
 const Stats = (() => {
+  const ALL_TYPES = ['hiking', 'running', 'cycling', 'driving', 'train', 'other', 'none'];
+  const TYPE_LABELS = {
+    hiking: '🥾 Hiking', running: '🏃 Running', cycling: '🚴 Cycling',
+    driving: '🚗 Driving', train: '🚆 Train', other: '📍 Other', none: '— Untyped'
+  };
+
+  // null = all; otherwise Set of selected type strings
+  let selectedTypes = null;
+
   async function init() {
     await loadStats();
   }
@@ -8,16 +17,29 @@ const Stats = (() => {
     content.innerHTML = '<div class="loading">Loading stats...</div>';
 
     try {
-      const stats = await API.getStats();
+      const types = selectedTypes ? [...selectedTypes] : [];
+      const stats = await API.getStats(types);
       renderStats(stats);
     } catch (e) {
       content.innerHTML = '<div class="error-msg">Error loading stats: ' + escHtml(e.message) + '</div>';
     }
   }
 
+  function renderFilterBar() {
+    const allSelected = selectedTypes === null;
+    let html = '<div class="stats-filter">';
+    html += `<button class="stats-type-btn${allSelected ? ' active' : ''}" data-type="all">All</button>`;
+    for (const t of ALL_TYPES) {
+      const active = !allSelected && selectedTypes.has(t);
+      html += `<button class="stats-type-btn${active ? ' active' : ''}" data-type="${escAttr(t)}">${TYPE_LABELS[t]}</button>`;
+    }
+    html += '</div>';
+    return html;
+  }
+
   function renderStats(stats) {
     const content = document.getElementById('stats-content');
-    let html = '';
+    let html = renderFilterBar();
 
     // Summary totals
     if (stats.total !== undefined || stats.totalDistance !== undefined) {
@@ -87,11 +109,31 @@ const Stats = (() => {
       html += '</div>';
     }
 
-    if (!html) {
-      html = '<div class="empty-list">No tracks yet. Upload some GPX files to see stats.</div>';
+    if (!html.includes('stats-section')) {
+      html += '<div class="empty-list">No tracks match the current filter.</div>';
     }
 
     content.innerHTML = html;
+
+    // Bind filter button events
+    content.querySelectorAll('.stats-type-btn').forEach(btn => {
+      btn.addEventListener('click', () => {
+        const t = btn.dataset.type;
+        if (t === 'all') {
+          selectedTypes = null;
+        } else {
+          if (selectedTypes === null) {
+            selectedTypes = new Set([t]);
+          } else if (selectedTypes.has(t)) {
+            selectedTypes.delete(t);
+            if (selectedTypes.size === 0) selectedTypes = null;
+          } else {
+            selectedTypes.add(t);
+          }
+        }
+        loadStats();
+      });
+    });
   }
 
   return { init, loadStats };