* feat(model): add Rated At field - #4653 Signed-off-by: zacaj <zacaj@zacaj.com> * fix(ui): ignore empty dates in rating/love tooltips - #4653 * refactor(ui): add isDateSet util function Signed-off-by: zacaj <zacaj@zacaj.com> * feat: add tests for isDateSet and rated_at sort mappings Added comprehensive tests for isDateSet and urlValidate functions in ui/src/utils/validations.test.js covering falsy values, Go zero date handling, valid date strings, Date objects, and edge cases. Added rated_at sort mapping to album, artist, and mediafile repositories, following the same pattern as starred_at (sorting by rating first, then by timestamp). This enables proper sorting by rating date in the UI. --------- Signed-off-by: zacaj <zacaj@zacaj.com> Co-authored-by: zacaj <zacaj@zacaj.com> Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE annotation ADD COLUMN rated_at datetime;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ type Annotations struct {
|
|||||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||||
|
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
|
|||||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||||
"dateloved": {field: "annotation.starred_at"},
|
"dateloved": {field: "annotation.starred_at"},
|
||||||
"lastplayed": {field: "annotation.play_date"},
|
"lastplayed": {field: "annotation.play_date"},
|
||||||
|
"daterated": {field: "annotation.rated_at"},
|
||||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||||||
"random": "random",
|
"random": "random",
|
||||||
"recently_added": recentlyAddedSort(),
|
"recently_added": recentlyAddedSort(),
|
||||||
"starred_at": "starred, starred_at",
|
"starred_at": "starred, starred_at",
|
||||||
|
"rated_at": "rating, rated_at",
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||||||
r.setSortMappings(map[string]string{
|
r.setSortMappings(map[string]string{
|
||||||
"name": "order_artist_name",
|
"name": "order_artist_name",
|
||||||
"starred_at": "starred, starred_at",
|
"starred_at": "starred, starred_at",
|
||||||
|
"rated_at": "rating, rated_at",
|
||||||
"song_count": "stats->>'total'->>'m'",
|
"song_count": "stats->>'total'->>'m'",
|
||||||
"album_count": "stats->>'total'->>'a'",
|
"album_count": "stats->>'total'->>'a'",
|
||||||
"size": "stats->>'total'->>'s'",
|
"size": "stats->>'total'->>'s'",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
|||||||
"created_at": "media_file.created_at",
|
"created_at": "media_file.created_at",
|
||||||
"recently_added": mediaFileRecentlyAddedSort(),
|
"recently_added": mediaFileRecentlyAddedSort(),
|
||||||
"starred_at": "starred, starred_at",
|
"starred_at": "starred, starred_at",
|
||||||
|
"rated_at": "rating, rated_at",
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
|||||||
"coalesce(play_count, 0) as play_count",
|
"coalesce(play_count, 0) as play_count",
|
||||||
"play_date",
|
"play_date",
|
||||||
"coalesce(rating, 0) as rating",
|
"coalesce(rating, 0) as rating",
|
||||||
|
"rated_at",
|
||||||
"f.*",
|
"f.*",
|
||||||
"playlist_tracks.*",
|
"playlist_tracks.*",
|
||||||
"library.path as library_path",
|
"library.path as library_path",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
|||||||
"coalesce(rating, 0) as rating",
|
"coalesce(rating, 0) as rating",
|
||||||
"starred_at",
|
"starred_at",
|
||||||
"play_date",
|
"play_date",
|
||||||
|
"rated_at",
|
||||||
"f.*",
|
"f.*",
|
||||||
"playlist_tracks.*",
|
"playlist_tracks.*",
|
||||||
).
|
).
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
|
|||||||
"coalesce(rating, 0) as rating",
|
"coalesce(rating, 0) as rating",
|
||||||
"starred_at",
|
"starred_at",
|
||||||
"play_date",
|
"play_date",
|
||||||
|
"rated_at",
|
||||||
)
|
)
|
||||||
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
|
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
|
||||||
query = query.Columns(
|
query = query.Columns(
|
||||||
@@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||||
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
|
ratedAt := time.Now()
|
||||||
|
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { isDateSet } from '../utils/validations'
|
||||||
import { DateField as RADateField } from 'react-admin'
|
import { DateField as RADateField } from 'react-admin'
|
||||||
|
|
||||||
export const DateField = (props) => {
|
export const DateField = (props) => {
|
||||||
const { record, source } = props
|
const { record, source } = props
|
||||||
const value = record?.[source]
|
const value = record?.[source]
|
||||||
if (value === '0001-01-01T00:00:00Z' || value === null) return null
|
if (!isDateSet(value)) return null
|
||||||
return <RADateField {...props} />
|
return <RADateField {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
|
|||||||
import { useToggleLove } from './useToggleLove'
|
import { useToggleLove } from './useToggleLove'
|
||||||
import { useRecordContext } from 'react-admin'
|
import { useRecordContext } from 'react-admin'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
import { isDateSet } from '../utils/validations'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
love: {
|
love: {
|
||||||
@@ -46,8 +47,13 @@ export const LoveButton = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleToggleLove}
|
onClick={handleToggleLove}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
disabled={disabled || loading || record?.missing}
|
disabled={disabled || loading || record.missing}
|
||||||
className={classes.love}
|
className={classes.love}
|
||||||
|
title={
|
||||||
|
isDateSet(record.starredAt)
|
||||||
|
? new Date(record.starredAt).toLocaleString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{record.starred ? (
|
{record.starred ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import Rating from '@material-ui/lab/Rating'
|
import Rating from '@material-ui/lab/Rating'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { isDateSet } from '../utils/validations'
|
||||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useRating } from './useRating'
|
import { useRating } from './useRating'
|
||||||
@@ -45,7 +46,14 @@ export const RatingField = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span onClick={(e) => stopPropagation(e)}>
|
<span
|
||||||
|
onClick={(e) => stopPropagation(e)}
|
||||||
|
title={
|
||||||
|
isDateSet(record.ratedAt)
|
||||||
|
? new Date(record.ratedAt).toLocaleString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Rating
|
<Rating
|
||||||
name={record.mediaFileId || record.id}
|
name={record.mediaFileId || record.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -10,3 +10,16 @@ export const urlValidate = (value) => {
|
|||||||
return 'ra.validation.url'
|
return 'ra.validation.url'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDateSet(date) {
|
||||||
|
if (!date) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date !== '0001-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date.toISOString() !== '0001-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
return !!date
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { isDateSet, urlValidate } from './validations'
|
||||||
|
|
||||||
|
describe('urlValidate', () => {
|
||||||
|
it('returns undefined for valid URLs', () => {
|
||||||
|
expect(urlValidate('https://example.com')).toBeUndefined()
|
||||||
|
expect(urlValidate('http://localhost:3000')).toBeUndefined()
|
||||||
|
expect(urlValidate('ftp://files.example.com')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for empty values', () => {
|
||||||
|
expect(urlValidate('')).toBeUndefined()
|
||||||
|
expect(urlValidate(null)).toBeUndefined()
|
||||||
|
expect(urlValidate(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for invalid URLs', () => {
|
||||||
|
expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
|
||||||
|
expect(urlValidate('example.com')).toEqual('ra.validation.url')
|
||||||
|
expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isDateSet', () => {
|
||||||
|
describe('with falsy values', () => {
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(isDateSet(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for undefined', () => {
|
||||||
|
expect(isDateSet(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isDateSet('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with Go zero date string', () => {
|
||||||
|
it('returns false for Go zero date', () => {
|
||||||
|
expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with valid date strings', () => {
|
||||||
|
it('returns true for ISO date strings', () => {
|
||||||
|
expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
|
||||||
|
expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for other date formats', () => {
|
||||||
|
expect(isDateSet('2024-01-15')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with Date objects', () => {
|
||||||
|
it('returns true for valid Date objects', () => {
|
||||||
|
expect(isDateSet(new Date())).toBe(true)
|
||||||
|
expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Date objects representing Go zero date would return true because
|
||||||
|
// toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
|
||||||
|
// In practice, dates from the API come as strings, not Date objects,
|
||||||
|
// so this edge case doesn't occur.
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with other truthy values', () => {
|
||||||
|
it('returns true for non-date truthy values', () => {
|
||||||
|
expect(isDateSet(123)).toBe(true)
|
||||||
|
expect(isDateSet({})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user