add top artists display to profile

This commit is contained in:
2026-03-02 21:58:41 -08:00
parent 24fb1331b4
commit 6e0e53eb64
8 changed files with 847 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v5"
)
type Artist struct {
@@ -504,6 +505,80 @@ func GetArtistStats(userId, artistId int) (int, error) {
return count, err
}
type TopArtist struct {
Artist Artist
ListenCount int
}
func GetTopArtists(userId int, limit int, startDate, endDate *time.Time) ([]TopArtist, error) {
var err error
var rows pgx.Rows
if startDate == nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $2`,
userId, limit)
} else if startDate != nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp >= $2
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $3`,
userId, startDate, limit)
} else if startDate == nil && endDate != nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp <= $2
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $3`,
userId, endDate, limit)
} else {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.timestamp <= $3
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $4`,
userId, startDate, endDate, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var topArtists []TopArtist
for rows.Next() {
var a Artist
var count int
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg, &count)
if err != nil {
return nil, err
}
a.ImageUrl = imageUrlPg.String
a.Bio = bioPg.String
a.SpotifyId = spotifyIdPg.String
a.MusicbrainzId = musicbrainzIdPg.String
topArtists = append(topArtists, TopArtist{Artist: a, ListenCount: count})
}
return topArtists, nil
}
func GetSongStats(userId, songId int) (int, error) {
var count int
err := Pool.QueryRow(context.Background(),

60
static/profile.js Normal file
View File

@@ -0,0 +1,60 @@
function updateTopArtists() {
const period = document.getElementById('period-select').value;
const limit = document.getElementById('limit-select').value;
const view = document.getElementById('view-select').value;
const customDates = document.getElementById('custom-dates');
if (period === 'custom') {
customDates.style.display = 'inline-block';
} else {
customDates.style.display = 'none';
}
const params = new URLSearchParams(window.location.search);
params.set('period', period);
params.set('limit', limit);
params.set('view', view);
if (period === 'custom') {
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
if (startDate) params.set('start', startDate);
if (endDate) params.set('end', endDate);
}
window.location.search = params.toString();
}
function updateLimitOptions() {
const view = document.getElementById('view-select').value;
const limitSelect = document.getElementById('limit-select');
const maxLimit = view === 'grid' ? 8 : 30;
for (let option of limitSelect.options) {
const value = parseInt(option.value);
if (value > maxLimit) {
option.style.display = 'none';
} else {
option.style.display = '';
}
}
if (parseInt(limitSelect.value) > maxLimit) {
limitSelect.value = maxLimit;
}
}
function syncGridHeights() {}
document.addEventListener('DOMContentLoaded', function() {
const customDates = document.getElementById('custom-dates');
const periodSelect = document.getElementById('period-select');
if (periodSelect && customDates) {
if (periodSelect.value === 'custom') {
customDates.style.display = 'inline-block';
}
}
updateLimitOptions();
});

View File

@@ -760,3 +760,450 @@ a.button:hover {
margin: 0;
line-height: 1.6;
}
/* Top Artists Section */
.top-artists {
width: 50%;
margin: 15px 0;
padding: 10px;
background: #1a1a1a;
border-radius: 10px;
}
.top-artists-controls {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
#top-artists-display {
min-height: 150px;
}
.top-artists-controls h3 {
margin: 0;
color: #fff;
}
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
max-width: 100%;
}
.controls-row label {
display: flex;
align-items: center;
gap: 8px;
color: #888;
font-size: 14px;
}
.controls-row select {
padding: 6px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #AFA;
font-size: 14px;
cursor: pointer;
}
.controls-row select:hover {
border-color: #AFA;
}
.controls-row input[type="date"] {
padding: 6px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #AFA;
font-size: 14px;
}
/* Artist Grid - New layout */
.artist-grid {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.artist-grid-odd {
flex-direction: row;
}
.artist-grid-odd .artist-cell-first {
aspect-ratio: 1;
flex: 1;
}
.artist-grid-odd .artist-right-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.artist-row {
display: flex;
gap: 8px;
flex: 1;
}
.artist-grid-odd .artist-row {
flex: 1;
}
.artist-cell {
flex: 1;
display: block;
background: #2a2a2a;
transition: background 0.2s;
transition: transform 0.1s ease;
border-radius: 8px;
min-width: 0;
}
.artist-cell-first {
display: block;
transition: background 0.2s;
transition: transform 0.1s ease;
border-radius: 8px;
}
.artist-cell a,
.artist-cell-first a {
display: block;
text-decoration: none;
}
.artist-cell,
.artist-cell-first {
position: relative;
aspect-ratio: 1;
}
.artist-cell .grid-items-cover-image,
.artist-cell-first .grid-items-cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.artist-cell .grid-items-cover-image-image,
.artist-cell-first .grid-items-cover-image-image {
position: relative;
width: 100%;
height: 100%;
}
.artist-cell .grid-items-cover-image-image img,
.artist-cell-first .grid-items-cover-image-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 10px;
}
.artist-cell .artist-placeholder,
.artist-cell-first .artist-placeholder {
width: 100%;
height: 100%;
background: #333;
border-radius: 10px;
}
.artist-cell:hover .grid-items-cover-image-image img,
.artist-cell-first:hover .grid-items-cover-image-image img,
.artist-cell:hover .artist-placeholder,
.artist-cell-first:hover .artist-placeholder {
opacity: 0.9;
}
.artist-cell .grid-items-item-details,
.artist-cell-first .grid-items-item-details {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 8px;
background: linear-gradient(#0000008c, rgba(0,0,0,0.8));
box-sizing: border-box;
border-radius: 0 0 10px 10px;
}
.artist-cell .grid-items-item-main-text,
.artist-cell-first .grid-items-item-main-text {
color: #fff;
font-weight: bold;
font-size: 14px;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-cell .grid-items-item-aux-text,
.artist-cell-first .grid-items-item-aux-text {
color: #aaa;
font-size: 12px;
margin: 0;
}
/* List View */
.artist-list {
display: flex;
flex-direction: column;
gap: 6px;
}
overflow: hidden;
text-decoration: none;
}
.grid-large .artist-card-large img,
.grid-large .artist-card-large .artist-placeholder-large {
width: 100%;
height: 100%;
object-fit: cover;
}
.grid-large .small-group {
display: contents;
}
.grid-large .small-row {
display: contents;
}
.grid-large .artist-card-small {
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.grid-large .artist-card-small img,
.grid-large .artist-card-small .artist-placeholder-small {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
}
.artist-placeholder-large {
width: 100%;
height: 100%;
min-height: 300px;
background: #333;
border-radius: 8px;
}
.grid-small-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.artist-card-large,
.artist-card-small {
display: block;
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.artist-card-large img,
.artist-card-small img {
width: 100%;
height: 100px;
object-fit: cover;
transition: transform 0.2s;
}
.artist-card-large:hover img,
.artist-card-small:hover img {
transform: scale(1.05);
}
.artist-card-large img,
.artist-card-small img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.2s;
}
.artist-card-large:hover img,
.artist-card-small:hover img {
transform: scale(1.05);
}
.artist-placeholder-small {
width: 100%;
height: 150px;
background: #333;
border-radius: 8px;
}
.artist-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px 6px 4px;
background: linear-gradient(transparent, rgba(0,0,0,0.9));
display: flex;
flex-direction: column;
gap: 1px;
}
.artist-name {
color: #fff;
font-weight: bold;
font-size: 9px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-count {
color: #AFA;
font-size: 8px;
}
/* Grid View - Even */
.grid-even {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.grid-even .small-row {
display: contents;
}
.grid-even .artist-card-small {
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.grid-even .artist-card-small img,
.grid-even .artist-card-small .artist-placeholder-small {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.grid-even .artist-card-small img,
.grid-even .artist-card-small .artist-placeholder-small {
width: 100%;
height: 100px;
object-fit: cover;
}
/* List View */
.artist-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.artist-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px;
border-radius: 8px;
text-decoration: none;
}
.artist-cell:hover {
background: #333;
transform: scale(0.98);
}
.artist-cell-first:hover {
background: #333;
transform: scale(0.98);
}
.artist-row img {
width: 35px;
height: 35px;
object-fit: cover;
border-radius: 50%;
flex-shrink: 0;
}
.artist-placeholder-row {
width: 35px;
height: 35px;
background: #444;
border-radius: 50%;
flex-shrink: 0;
}
.artist-row .artist-name {
flex: 1;
text-align: left;
font-size: 12px;
}
.artist-row .artist-count {
text-align: right;
font-size: 11px;
}
/* Responsive Artist Grid */
@media (max-width: 600px) {
.artist-grid {
max-width: 100%;
}
.artist-grid-odd {
flex-direction: column;
}
.artist-grid-odd .artist-cell-first {
width: 100%;
max-width: 300px;
margin: 0 auto;
}
.artist-grid-odd .artist-right-col {
flex-direction: row;
}
.artist-row {
flex-wrap: wrap;
}
.artist-cell {
min-width: calc(50% - 4px);
}
}
@media (max-width: 400px) {
.artist-grid-odd .artist-right-col {
flex-direction: column;
}
.artist-cell {
min-width: 100%;
}
}

View File

@@ -55,6 +55,9 @@
{{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}}
<script src="/files/menu.js"></script>
{{if eq .TemplateName "profile"}}
<script src="/files/profile.js"></script>
{{end}}
</body>
</html>
{{end}}

View File

@@ -13,6 +13,150 @@
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
</div>
<div class="top-artists">
<div class="top-artists-controls">
<h3>Top Artists</h3>
<div class="controls-row">
<label>
Period:
<select id="period-select" onchange="updateTopArtists()">
<option value="all_time" {{if eq .TopArtistsPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopArtistsPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopArtistsPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopArtistsPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopArtistsPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="custom-dates" style="display: {{if eq .TopArtistsPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="start-date" onchange="updateTopArtists()">
<input type="date" id="end-date" onchange="updateTopArtists()">
</div>
<label>
Count:
<select id="limit-select" onchange="updateTopArtists()">
<option value="5" {{if eq .TopArtistsLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopArtistsLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopArtistsLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopArtistsLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopArtistsLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopArtistsLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopArtistsLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopArtistsLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopArtistsLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopArtistsLimit 30}}selected{{end}}>30</option>
</select>
</label>
<label>
View:
<select id="view-select" onchange="updateLimitOptions(); updateTopArtists()">
<option value="grid" {{if eq .TopArtistsView "grid"}}selected{{end}}>Grid</option>
<option value="list" {{if eq .TopArtistsView "list"}}selected{{end}}>List</option>
</select>
</label>
</div>
</div>
{{if .TopArtists}}
<div id="top-artists-display" class="top-artists-{{.TopArtistsView}}">
{{$view := .TopArtistsView}}
{{$artists := .TopArtists}}
{{$len := len $artists}}
{{if eq $view "grid"}}
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
{{if mod $len 2}}
<div class="artist-cell-first">
<a href="/profile/{{$.Username}}/artist/{{urlquery (index $artists 0).Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if (index $artists 0).Artist.ImageUrl}}
<img src="{{(index $artists 0).Artist.ImageUrl}}" alt="{{(index $artists 0).Artist.Name}}">
{{else}}
<div class="artist-placeholder"></div>
{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{(index $artists 0).Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt (index $artists 0).ListenCount}} plays</p>
</div>
</a>
</div>
<div class="artist-right-col">
<div class="artist-row">
{{range $i, $a := slice $artists 1 (add 1 (div (sub $len 1) 2))}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := slice $artists (add 1 (div (sub $len 1) 2)) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="artist-row">
{{range $i, $a := slice $artists 0 (div $len 2)}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := slice $artists (div $len 2) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="artist-list">
{{range $a := $artists}}
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="artist-row">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
<span class="artist-name">{{$a.Artist.Name}}</span>
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="history">
<h3>Listening History</h3>
<table>
@@ -39,7 +183,7 @@
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</td>
<td><a href="/profile/{{$username}}/song/{{urlquery (index $.Artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
<td><span title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</span></td>
</tr>
{{end}}
</table>

View File

@@ -34,6 +34,10 @@ type ProfileData struct {
TemplateName string
NowPlayingArtist string
NowPlayingTitle string
TopArtists []db.TopArtist
TopArtistsPeriod string
TopArtistsLimit int
TopArtistsView string
}
// Render a page of the profile in the URL
@@ -85,6 +89,72 @@ func profilePageHandler() http.HandlerFunc {
return
}
period := r.URL.Query().Get("period")
if period == "" {
period = "all_time"
}
view := r.URL.Query().Get("view")
if view == "" {
view = "grid"
}
maxLimit := 30
if view == "grid" {
maxLimit = 8
}
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
limit, err = strconv.Atoi(limitStr)
if err != nil || limit < 5 {
limit = 10
}
if limit > maxLimit {
limit = maxLimit
}
}
profileData.TopArtistsPeriod = period
profileData.TopArtistsLimit = limit
profileData.TopArtistsView = view
var startDate, endDate *time.Time
now := time.Now()
switch period {
case "week":
start := now.AddDate(0, 0, -7)
startDate = &start
case "month":
start := now.AddDate(0, -1, 0)
startDate = &start
case "year":
start := now.AddDate(-1, 0, 0)
startDate = &start
case "custom":
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" {
if t, err := time.Parse("2006-01-02", startStr); err == nil {
startDate = &t
}
}
if endStr != "" {
if t, err := time.Parse("2006-01-02", endStr); err == nil {
t = t.AddDate(0, 0, 1)
endDate = &t
}
}
}
topArtists, err := db.GetTopArtists(userId, limit, startDate, endDate)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get top artists: %v\n", err)
} else {
profileData.TopArtists = topArtists
}
if pageInt == 1 {
if np, ok := scrobble.GetNowPlaying(userId); ok {
profileData.NowPlayingArtist = np.Artist

View File

@@ -19,6 +19,49 @@ func add(a int, b int) int {
return a + b
}
// Divides two integers (integer division)
func div(a int, b int) int {
if b == 0 {
return 0
}
return a / b
}
// Returns a % b
func mod(a int, b int) int {
return a % b
}
// Returns a slice of a slice from start to end
func slice(a []db.TopArtist, start int, end int) []db.TopArtist {
if start >= len(a) {
return []db.TopArtist{}
}
if end > len(a) {
end = len(a)
}
return a[start:end]
}
func gridReorder(artists []db.TopArtist) []db.TopArtist {
if len(artists) < 2 {
return artists
}
if len(artists)%2 == 0 {
return artists
}
remaining := len(artists) - 1
perRow := remaining / 2
rest := artists[1:]
firstRow := rest[:perRow]
secondRow := rest[perRow:]
var reordered []db.TopArtist
reordered = append(reordered, artists[0])
reordered = append(reordered, secondRow...)
reordered = append(reordered, firstRow...)
return reordered
}
// Put a comma in the thousands place, ten-thousands place etc.
func formatInt(n int) string {
if n < 1000 {

View File

@@ -33,6 +33,10 @@ func init() {
funcMap := template.FuncMap{
"sub": sub,
"add": add,
"div": div,
"mod": mod,
"slice": slice,
"gridReorder": gridReorder,
"formatInt": formatInt,
"formatTimestamp": formatTimestamp,
"formatTimestampFull": formatTimestampFull,