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:
parent
254106de75
commit
00ec59014d
|
|
@ -232,6 +232,54 @@
|
||||||
(defun load-more-favorites ()
|
(defun load-more-favorites ()
|
||||||
(show-message "Loading more favorites..." "info"))
|
(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 ()
|
(defun load-profile-data ()
|
||||||
(ps:chain console (log "Loading profile data..."))
|
(ps:chain console (log "Loading profile data..."))
|
||||||
|
|
||||||
|
|
@ -254,7 +302,8 @@
|
||||||
(load-listening-stats)
|
(load-listening-stats)
|
||||||
(load-recent-tracks)
|
(load-recent-tracks)
|
||||||
(load-favorites)
|
(load-favorites)
|
||||||
(load-top-artists))
|
(load-top-artists)
|
||||||
|
(load-activity-chart))
|
||||||
|
|
||||||
;; Action functions
|
;; Action functions
|
||||||
(defun load-more-recent-tracks ()
|
(defun load-more-recent-tracks ()
|
||||||
|
|
|
||||||
|
|
@ -1702,3 +1702,74 @@ body.popout-body .status-mini{
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.8em;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1359,4 +1359,62 @@
|
||||||
(.btn-small
|
(.btn-small
|
||||||
:padding "4px 8px"
|
:padding "4px 8px"
|
||||||
:font-size "0.8em"))
|
: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
|
) ;; End of let block
|
||||||
|
|
|
||||||
|
|
@ -146,18 +146,11 @@
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>📈 Listening Activity</h2>
|
<h2>📈 Listening Activity</h2>
|
||||||
<div class="activity-chart">
|
<div class="activity-chart">
|
||||||
<p>Activity over the last 30 days</p>
|
<p>Tracks played over the last 30 days</p>
|
||||||
<div class="chart-placeholder">
|
<div class="chart-container" id="activity-chart">
|
||||||
<div class="chart-bar" style="height: 20%" data-day="1"></div>
|
<p class="loading-message">Loading activity data...</p>
|
||||||
<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 -->
|
|
||||||
</div>
|
</div>
|
||||||
<p class="chart-note">Listening hours per day</p>
|
<p class="chart-note" id="activity-total">Total: 0 tracks</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,14 @@
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
(:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id)))))
|
(: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
|
;;; API Endpoints for User Favorites
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
|
|
@ -235,3 +243,16 @@
|
||||||
(clear-listening-history user-id)
|
(clear-listening-history user-id)
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Listening history cleared"))))))
|
("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)))))))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue