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 ()
|
||||
(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 ()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
|
|||
Loading…
Reference in New Issue