From a2ebc415f26b3560f2d128932e74c791bb617be6 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 08:43:36 +0300 Subject: [PATCH] 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 --- parenscript/profile.lisp | 51 ++++++++++++++++++++++++++++- static/asteroid.css | 71 ++++++++++++++++++++++++++++++++++++++++ static/asteroid.lass | 58 ++++++++++++++++++++++++++++++++ template/profile.ctml | 15 +++------ user-profile.lisp | 21 ++++++++++++ 5 files changed, 204 insertions(+), 12 deletions(-) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index eb12b92..cbcc146 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -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 "
")) + (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 "
" + "
" + "" day-label "" + "
")))))) + (setf html (+ html "
")) + (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) "

No listening activity yet. Start listening to build your history!

")))))) + (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) "

Failed to load activity data

"))))))) + (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 () diff --git a/static/asteroid.css b/static/asteroid.css index cef3ae1..fcda789 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1701,4 +1701,75 @@ body.popout-body .status-mini{ .favorites-list .btn-small{ 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; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index c811de7..6460b89 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -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 diff --git a/template/profile.ctml b/template/profile.ctml index 3a6635e..f829f43 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -146,18 +146,11 @@

📈 Listening Activity

-

Activity over the last 30 days

-
-
-
-
-
-
-
-
- +

Tracks played over the last 30 days

+
+

Loading activity data...

-

Listening hours per day

+

Total: 0 tracks

diff --git a/user-profile.lisp b/user-profile.lisp index 5e4515a..1ddca88 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -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)))))))