feat(playlists): #417 — "Refreshed …" subtitle on system tiles

Closes the last buildable item of the #411 system-playlists-v2
umbrella. System playlists atomic-replace on rebuild, so created_at
(already on the wire — no server change) is the last-rotated time.
Surface it as a small tile subtitle so users see how fresh a mix
is: "Refreshed just now / today / yesterday / N days ago / Mon D".

- web PlaylistCard: refreshedLabel() + a muted footer line, shown
  only when system_variant != null. Unparseable/empty timestamp →
  suppressed (web test fixtures use created_at:'' so no test churn).
- flutter PlaylistCard: mirrored _refreshedLabel() + subtitle under
  the system badge for isSystem playlists.

Friendly wording deliberately distinct from HistoryRow's "m/h ago";
per-surface helper per the project's existing relative-time
convention. CI-pending; closes with the umbrella on device-check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 23:03:41 -04:00
parent 2d9775244c
commit ce36760819
2 changed files with 55 additions and 0 deletions
@@ -122,6 +122,16 @@ class PlaylistCard extends ConsumerWidget {
),
),
),
if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '')
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_refreshedLabel(playlist.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
]),
),
),
@@ -131,6 +141,27 @@ class PlaylistCard extends ConsumerWidget {
String get _refreshLabel => 'Refresh ${playlist.name}';
/// #417: system playlists atomic-replace on rebuild, so createdAt
/// is the last-rotated time. Friendly wording mirrors the web
/// PlaylistCard. Empty string when the timestamp is unparseable.
String _refreshedLabel(String iso) {
final t = DateTime.tryParse(iso);
if (t == null) return '';
final now = DateTime.now();
final mins = now.difference(t).inMinutes;
if (mins < 5) return 'Refreshed just now';
final startOfToday = DateTime(now.year, now.month, now.day);
if (!t.isBefore(startOfToday)) return 'Refreshed today';
final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays;
if (days <= 1) return 'Refreshed yesterday';
if (days < 7) return 'Refreshed $days days ago';
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return 'Refreshed ${months[t.month - 1]} ${t.day}';
}
/// Forces a server-side rebuild of this system playlist, then
/// invalidates the aggregate list so the rotated UUID + new tracks
/// land on the next read. ScaffoldMessenger is captured before the
@@ -17,6 +17,27 @@
// (#411 R2) — no hardcoded kind list here.
const refreshable = $derived(playlist.refreshable === true);
// #417: system playlists atomic-replace on rebuild, so created_at
// is the last-rotated time. Surface it so users know how fresh the
// mix is. Friendly wording, not the HistoryRow "m/h ago" style.
function refreshedLabel(iso: string): string {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return '';
const now = new Date();
const mins = Math.floor((now.getTime() - t.getTime()) / 60000);
if (mins < 5) return 'Refreshed just now';
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const days = Math.floor((startOfToday.getTime() - t.getTime()) / 86400000) + 1;
if (t.getTime() >= startOfToday.getTime()) return 'Refreshed today';
if (days <= 1) return 'Refreshed yesterday';
if (days < 7) return `Refreshed ${days} days ago`;
return `Refreshed ${t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
}
const refreshed = $derived(
playlist.system_variant != null ? refreshedLabel(playlist.created_at) : ''
);
const queryClient = useQueryClient();
let starting = $state(false);
@@ -153,6 +174,9 @@
· by {playlist.owner_username}
{/if}
</div>
{#if refreshed}
<div class="truncate text-xs text-text-muted">{refreshed}</div>
{/if}
</div>
</a>
</div>