feat: Add listening activity chart to profile page

- New API endpoint /api/asteroid/user/activity for daily aggregation
- Bar chart showing tracks played per day (last 30 days)
- Hover tooltips show exact date and count
- Total tracks summary below chart
- Green gradient bars matching site theme
This commit is contained in:
glenneth 2025-12-21 08:43:36 +03:00
parent 7600ea6bed
commit a2ebc415f2
5 changed files with 204 additions and 12 deletions

View File

@ -232,6 +232,54 @@
(defun load-more-favorites ()
(show-message "Loading more favorites..." "info"))
(defun load-activity-chart ()
(ps:chain
(fetch "/api/asteroid/user/activity?days=30")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "activity-chart")))
(total-el (ps:chain document (get-element-by-id "activity-total"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data activity)
(> (ps:@ data activity length) 0))
(let ((activity (ps:@ data activity))
(max-count 1)
(total 0))
;; Find max for scaling
(ps:chain activity (for-each (lambda (day)
(let ((count (or (ps:@ day track_count) 0)))
(setf total (+ total count))
(when (> count max-count)
(setf max-count count))))))
;; Build chart HTML
(let ((html "<div class=\"chart-bars\">"))
(ps:chain activity (for-each (lambda (day)
(let* ((count (or (ps:@ day track_count) 0))
(height (ps:chain -math (round (* (/ count max-count) 100))))
(date-str (ps:@ day day))
(date-parts (ps:chain date-str (split "-")))
(day-label (if (> (ps:@ date-parts length) 2)
(ps:getprop date-parts 2)
"")))
(setf html (+ html "<div class=\"chart-bar-wrapper\">"
"<div class=\"chart-bar\" style=\"height: " height "%\" title=\"" date-str ": " count " tracks\"></div>"
"<span class=\"chart-day\">" day-label "</span>"
"</div>"))))))
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))
;; Update total
(when total-el
(setf (ps:@ total-el text-content) (+ "Total: " total " tracks in the last 30 days"))))
;; No data
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No listening activity yet. Start listening to build your history!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading activity:" error))
(let ((container (ps:chain document (get-element-by-id "activity-chart"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class=\"error\">Failed to load activity data</p>")))))))
(defun load-profile-data ()
(ps:chain console (log "Loading profile data..."))
@ -254,7 +302,8 @@
(load-listening-stats)
(load-recent-tracks)
(load-favorites)
(load-top-artists))
(load-top-artists)
(load-activity-chart))
;; Action functions
(defun load-more-recent-tracks ()

View File

@ -1702,3 +1702,74 @@ body.popout-body .status-mini{
padding: 4px 8px;
font-size: 0.8em;
}
.activity-chart{
padding: 15px;
}
.activity-chart .chart-container{
min-height: 120px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.activity-chart .chart-bars{
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 100px;
width: 100%;
max-width: 600px;
}
.activity-chart .chart-bar-wrapper{
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 20px;
}
.activity-chart .chart-bar{
width: 100%;
min-height: 2px;
background: linear-gradient(to top, #006600, #00cc00);
border-radius: 2px 2px 0 0;
-moz-transition: height 0.3s ease;
-o-transition: height 0.3s ease;
-webkit-transition: height 0.3s ease;
-ms-transition: height 0.3s ease;
transition: height 0.3s ease;
}
.activity-chart .chart-bar:hover{
background: linear-gradient(to top, #009900, #00ff00);
}
.activity-chart .chart-day{
font-size: 0.6em;
color: #666;
margin-top: 4px;
}
.activity-chart .chart-note{
text-align: center;
color: #888;
font-size: 0.9em;
margin-top: 10px;
}
.activity-chart .loading-message{
color: #666;
font-style: italic;
text-align: center;
}
.activity-chart .no-data{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}

View File

@ -1359,4 +1359,62 @@
(.btn-small
:padding "4px 8px"
:font-size "0.8em"))
;; Activity chart styling
(.activity-chart
:padding "15px"
(.chart-container
:min-height "120px"
:display "flex"
:align-items "flex-end"
:justify-content "center")
(.chart-bars
:display "flex"
:align-items "flex-end"
:justify-content "center"
:gap "4px"
:height "100px"
:width "100%"
:max-width "600px")
(.chart-bar-wrapper
:display "flex"
:flex-direction "column"
:align-items "center"
:flex "1"
:max-width "20px")
(.chart-bar
:width "100%"
:min-height "2px"
:background "linear-gradient(to top, #006600, #00cc00)"
:border-radius "2px 2px 0 0"
:transition "height 0.3s ease")
((:and .chart-bar :hover)
:background "linear-gradient(to top, #009900, #00ff00)")
(.chart-day
:font-size "0.6em"
:color "#666"
:margin-top "4px")
(.chart-note
:text-align "center"
:color "#888"
:font-size "0.9em"
:margin-top "10px")
(.loading-message
:color "#666"
:font-style "italic"
:text-align "center")
(.no-data
:color "#666"
:font-style "italic"
:text-align "center"
:padding "20px"))
) ;; End of let block

View File

@ -146,18 +146,11 @@
<div class="admin-section">
<h2>📈 Listening Activity</h2>
<div class="activity-chart">
<p>Activity over the last 30 days</p>
<div class="chart-placeholder">
<div class="chart-bar" style="height: 20%" data-day="1"></div>
<div class="chart-bar" style="height: 45%" data-day="2"></div>
<div class="chart-bar" style="height: 30%" data-day="3"></div>
<div class="chart-bar" style="height: 60%" data-day="4"></div>
<div class="chart-bar" style="height: 80%" data-day="5"></div>
<div class="chart-bar" style="height: 25%" data-day="6"></div>
<div class="chart-bar" style="height: 40%" data-day="7"></div>
<!-- More bars would be generated dynamically -->
<p>Tracks played over the last 30 days</p>
<div class="chart-container" id="activity-chart">
<p class="loading-message">Loading activity data...</p>
</div>
<p class="chart-note">Listening hours per day</p>
<p class="chart-note" id="activity-total">Total: 0 tracks</p>
</div>
</div>

View File

@ -119,6 +119,14 @@
(postmodern:query
(:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id)))))
(defun get-listening-activity (user-id &key (days 30))
"Get listening activity aggregated by day for the last N days"
(with-db
(postmodern:query
(:raw (format nil "SELECT DATE(\"listened-at\") as day, COUNT(*) as track_count FROM listening_history WHERE \"user-id\" = ~a AND \"listened-at\" >= NOW() - INTERVAL '~a days' GROUP BY DATE(\"listened-at\") ORDER BY day ASC"
user-id days))
:alists)))
;;; ==========================================================================
;;; API Endpoints for User Favorites
;;; ==========================================================================
@ -235,3 +243,16 @@
(clear-listening-history user-id)
(api-output `(("status" . "success")
("message" . "Listening history cleared"))))))
(define-api asteroid/user/activity (&optional (days "30")) ()
"Get listening activity by day for the last N days"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(days-int (or (parse-integer days :junk-allowed t) 30))
(activity (get-listening-activity user-id :days days-int)))
(api-output `(("status" . "success")
("activity" . ,(mapcar (lambda (a)
`(("day" . ,(cdr (assoc :day a)))
("track_count" . ,(cdr (assoc :track-count a)))))
activity)))))))