docs(quick-5): plan sort dropdown for playlist view

This commit is contained in:
2026-03-01 08:36:16 -05:00
parent 85412e2eaa
commit ea3648f3f8
@@ -0,0 +1,352 @@
---
phase: quick-5
plan: 1
type: execute
wave: 1
depends_on: []
files_modified:
- backend/playlist/playlist.go
- frontend/wailsjs/go/models.ts
- frontend/src/components/playlist-view/playlist-view.ts
autonomous: true
requirements: [QUICK-5]
must_haves:
truths:
- "User sees a sort dropdown in the playlist view header"
- "User can sort playlists by name (A-Z / Z-A)"
- "User can sort playlists by date created"
- "User can sort playlists by last modified (recent)"
- "User can sort playlists by number of tracks"
- "User can toggle ascending/descending direction"
- "Sort preference persists across view switches"
- "Default sort is 'Recent' (updated_at DESC) matching current DB order"
artifacts:
- path: "backend/playlist/playlist.go"
provides: "Summary struct with CreatedAt and UpdatedAt fields"
- path: "frontend/wailsjs/go/models.ts"
provides: "TypeScript Summary class with CreatedAt and UpdatedAt"
- path: "frontend/src/components/playlist-view/playlist-view.ts"
provides: "Sort dropdown UI and client-side sorting logic"
key_links:
- from: "backend/playlist/playlist.go"
to: "frontend/wailsjs/go/models.ts"
via: "Wails bindings generation"
pattern: "Summary.*CreatedAt.*UpdatedAt"
- from: "frontend/src/components/playlist-view/playlist-view.ts"
to: "playlist.Summary"
via: "client-side sort using CreatedAt/UpdatedAt/Name/tracks.length"
pattern: "sortEntries|sortField"
---
<objective>
Add a "sort" dropdown to the playlist view allowing users to sort playlists by name, date created, last modified, and number of tracks.
Purpose: Currently playlists are ordered by `updated_at DESC` from the database with no user control. Users need to organize playlists by different criteria.
Output: Sort dropdown in playlist header, client-side sorting with direction toggle, persisted preference via localStorage.
</objective>
<execution_context>
@/home/caleb/.config/Claude/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/Claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@frontend/src/components/playlist-view/playlist-view.ts — The main playlist view component (2798 lines). Sort dropdown goes in the header area.
@frontend/src/components/track-list/track-list.ts — Has an existing sort toolbar pattern to replicate (lines 729-830 for CSS, 1599-1696 for render methods, 1358-1510 for sort logic).
@frontend/src/store/playlist-store.ts — Playlist data store, provides `playlist.WithTracks[]`.
@frontend/src/store/controllers/playlist-controller.ts — Controller bridging store to component.
@backend/playlist/playlist.go — Go service; `Summary` struct (lines 43-47) needs `CreatedAt`/`UpdatedAt`. `GetAllPlaylistsWithTracks` (line 173) and `GetAllPlaylists` (line 147) construct Summary objects that need updating.
@backend/database/sql/sqlcgen/models.go — Sqlc model: `Playlist` struct already has `CreatedAt`/`UpdatedAt` fields (lines 67-72).
@frontend/wailsjs/go/models.ts — Auto-generated TypeScript models; `playlist.Summary` class (lines 305-318) will need `CreatedAt`/`UpdatedAt`.
@backend/database/sql/schemas/playlists.sql — Schema: `created_at` and `updated_at` columns already exist.
<interfaces>
<!-- Backend types the executor needs -->
From backend/playlist/playlist.go:
```go
type Summary struct {
ID int64 `json:"ID"`
Name string `json:"Name"`
}
type WithTracks struct {
Summary Summary `json:"Summary"`
Tracks []Track `json:"Tracks"`
}
```
From backend/database/sql/sqlcgen/models.go:
```go
type Playlist struct {
ID int64
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
```
<!-- Frontend types -->
From frontend/wailsjs/go/models.ts:
```typescript
export class Summary {
ID: number;
Name: string;
// CreatedAt and UpdatedAt NOT present yet — must be added
}
```
<!-- Existing sort UI pattern from track-list.ts -->
Sort toolbar CSS classes: .sort-toolbar, .sort-anchor, .sort-label, .sort-dir-btn, .sort-dropdown-panel, .active-sort, #sort-dropdown
Sort state: sortField (string|null), sortDirection ('asc'|'desc'), sortDropdownOpen (boolean)
localStorage keys pattern: 'track-list-sort-field', 'track-list-sort-direction'
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add CreatedAt/UpdatedAt to playlist Summary struct and regenerate bindings</name>
<files>
backend/playlist/playlist.go
backend/playlist/favorites.go
frontend/wailsjs/go/models.ts
</files>
<action>
1. In `backend/playlist/playlist.go`, add `CreatedAt` and `UpdatedAt` fields to the `Summary` struct:
```go
type Summary struct {
ID int64 `json:"ID"`
Name string `json:"Name"`
CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"UpdatedAt"`
}
```
Use `string` type (not `time.Time`) since Wails serializes time values as strings and the frontend only needs them for comparison sorting. Format as RFC3339 using `p.CreatedAt.Format(time.RFC3339)` and `p.UpdatedAt.Format(time.RFC3339)`.
2. Update ALL locations where `Summary{}` is constructed to include the new fields. Search the file for `Summary{` — there are ~16 occurrences across playlist.go and favorites.go. The main patterns:
- `GetAllPlaylists` (line 162): Has access to `p.CreatedAt` and `p.UpdatedAt` from the sqlc `Playlist` struct
- `GetAllPlaylistsWithTracks` (line 239): Same — `p` is `sqlcgen.Playlist`
- `CreatePlaylist` / `CreatePlaylistWithTracks` / `ImportSingle`: After creating, the sqlc `CreatePlaylist` returns `*` (RETURNING *) so the result has `CreatedAt`/`UpdatedAt`
- `RenamePlaylist` (line 677): Doesn't have access to full row — use empty strings or re-query. Since this is an event payload (not display), empty strings are fine.
- `GetOrCreateFavoritesPlaylist` in favorites.go (line 141): Has `pl` from `GetPlaylist` which returns full row
For Summary constructions in event emission contexts (where CreatedAt/UpdatedAt aren't critical): populate with empty strings `""` — the frontend ignores timestamps on event payloads.
For Summary constructions returned to the frontend for display: populate with formatted time strings.
3. Run `wails generate` to regenerate the TypeScript bindings in `frontend/wailsjs/go/models.ts`. The `Summary` class should now have `CreatedAt: string` and `UpdatedAt: string`.
4. If `wails generate` isn't available or fails, manually add the fields to `frontend/wailsjs/go/models.ts` in the `Summary` class:
- Add `CreatedAt: string;` and `UpdatedAt: string;` as properties
- Add them to the constructor: `this.CreatedAt = source["CreatedAt"];` and `this.UpdatedAt = source["UpdatedAt"];`
</action>
<verify>
<automated>cd backend && go build ./... && go vet ./...</automated>
</verify>
<done>Summary struct includes CreatedAt/UpdatedAt strings, all construction sites updated, TypeScript bindings have the new fields, backend compiles cleanly.</done>
</task>
<task type="auto">
<name>Task 2: Add sort dropdown UI and client-side sorting to playlist-view</name>
<files>frontend/src/components/playlist-view/playlist-view.ts</files>
<action>
Add a sort dropdown to the playlist view, replicating the existing sort toolbar pattern from track-list.ts but adapted for playlist-level sorting.
**1. Add sort state and constants:**
Before the class definition, add:
```typescript
type PlaylistSortField = 'name' | 'created' | 'modified' | 'tracks';
type SortDirection = 'asc' | 'desc';
const PLAYLIST_SORT_KEY = 'playlist-view-sort-field';
const PLAYLIST_SORT_DIR_KEY = 'playlist-view-sort-direction';
const SORT_OPTIONS: { id: PlaylistSortField; label: string }[] = [
{ id: 'modified', label: 'Recent' },
{ id: 'name', label: 'Name' },
{ id: 'created', label: 'Date Created' },
{ id: 'tracks', label: 'Track Count' },
];
```
Inside the class, add state properties:
```typescript
@state() private sortField: PlaylistSortField = 'modified';
@state() private sortDirection: SortDirection = 'desc';
@state() private sortDropdownOpen = false;
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
```
**2. Add sort CSS (inside the static styles array):**
Copy the sort toolbar styles from track-list.ts (`.sort-toolbar`, `.sort-anchor`, `.sort-anchor:hover`, `.sort-anchor .sort-label`, `.sort-dir-btn`, `.sort-dir-btn:hover`, `.sort-dropdown-panel`, `.sort-dropdown-panel wa-dropdown-item`, `.sort-dropdown-panel wa-dropdown-item:hover`, `.sort-dropdown-panel wa-dropdown-item.active-sort`, `#sort-dropdown`). These are lines 731-830 of track-list.ts. Copy them verbatim — same CSS custom properties are used.
**3. Add sort logic methods:**
```typescript
private restoreSortPreferences() {
try {
const field = localStorage.getItem(PLAYLIST_SORT_KEY);
if (field && SORT_OPTIONS.some(o => o.id === field)) {
this.sortField = field as PlaylistSortField;
}
const dir = localStorage.getItem(PLAYLIST_SORT_DIR_KEY);
if (dir === 'asc' || dir === 'desc') {
this.sortDirection = dir;
}
} catch { /* localStorage unavailable */ }
}
private saveSortPreferences() {
try {
localStorage.setItem(PLAYLIST_SORT_KEY, this.sortField);
localStorage.setItem(PLAYLIST_SORT_DIR_KEY, this.sortDirection);
} catch { /* localStorage unavailable */ }
}
private get sortedEntries(): PlaylistEntry[] {
const entries = this.filteredEntries;
const dir = this.sortDirection === 'asc' ? 1 : -1;
return [...entries].sort((a, b) => {
let cmp = 0;
switch (this.sortField) {
case 'name':
cmp = a.summary.Name.localeCompare(b.summary.Name);
break;
case 'created':
cmp = (a.summary.CreatedAt || '').localeCompare(b.summary.CreatedAt || '');
break;
case 'modified':
cmp = (a.summary.UpdatedAt || '').localeCompare(b.summary.UpdatedAt || '');
break;
case 'tracks':
cmp = a.tracks.length - b.tracks.length;
break;
}
return cmp * dir;
});
}
```
**4. Add dropdown open/close/select methods** (same pattern as track-list.ts):
- `toggleSortDropdown()`, `openSortDropdown()`, `closeSortDropdown()` — same pattern as track-list.ts lines 1456-1490
- `onSortDropdownSelect(field: PlaylistSortField)` — sets `this.sortField = field`, calls `saveSortPreferences()`, `closeSortDropdown()`
- `toggleSortDirection()` — flips direction, saves
- `sortDropdownCloseHandler` — mousedown listener to close when clicking outside (same pattern as track-list.ts lines 1492-1510)
**5. Register/unregister the mousedown close handler** in `connectedCallback` and `disconnectedCallback`:
- In `connectedCallback()`: add `document.addEventListener('mousedown', this.sortDropdownCloseHandler);`
- Also call `this.restoreSortPreferences();` in `connectedCallback()`
- In `disconnectedCallback()`: add `document.removeEventListener('mousedown', this.sortDropdownCloseHandler);`
**6. Add sort toolbar rendering** as a private method `renderSortToolbar()`:
```typescript
private renderSortToolbar() {
const activeOption = SORT_OPTIONS.find(o => o.id === this.sortField);
const label = activeOption?.label ?? 'Recent';
const dirIcon = this.sortDirection === 'asc'
? 'arrow-up-short-wide'
: 'arrow-down-wide-short';
return html`
<div class="sort-toolbar">
<span>Sort:</span>
<button class="sort-anchor"
@click=${() => this.toggleSortDropdown()}
>
<span class="sort-label">${label}</span>
<wa-icon name="chevron-down"></wa-icon>
</button>
<button class="sort-dir-btn"
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
@click=${() => this.toggleSortDirection()}
>
<wa-icon name=${dirIcon}></wa-icon>
</button>
</div>
${this.renderSortDropdownPopup()}
`;
}
private renderSortDropdownPopup() {
return html`
<wa-popup id="sort-dropdown"
placement="bottom-start" flip shift
.active=${this.sortDropdownOpen}
>
${this.sortDropdownOpen ? html`
<div class="sort-dropdown-panel">
${SORT_OPTIONS.map(opt => html`
<wa-dropdown-item
class=${this.sortField === opt.id ? 'active-sort' : ''}
@click=${() => this.onSortDropdownSelect(opt.id)}
>
${opt.label}
</wa-dropdown-item>
`)}
</div>
` : nothing}
</wa-popup>
`;
}
```
**7. Wire sort toolbar into the render method:**
In the `render()` method, insert the sort toolbar between the header `</div>` and the search indicator / create form. Specifically, after the `importError` block (after line 2124), add:
```typescript
${this.renderSortToolbar()}
```
**8. Replace `filteredEntries` with `sortedEntries` in the rendering path:**
In `renderPlaylistList()`, change line 2459 from:
```typescript
const visible = this.filteredEntries;
```
to:
```typescript
const visible = this.sortedEntries;
```
Also update the `originalIndex` lookup on line 2488-2489. Since `sortedEntries` may reorder entries, `this.entries.indexOf(entry)` still works correctly since it finds the entry in the original `this.entries` array — the reference identity is preserved because `sortedEntries` spreads `filteredEntries` which filters `this.entries`. VERIFY this is the case. If `filteredEntries` creates new objects (it does NOT — it just filters), then `indexOf` will still work.
**IMPORTANT:** The direction button should ALWAYS be visible (unlike track-list which hides it when no sort is active), since playlist sort always has an active field (no "Default" option — "Recent" is the default).
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit</automated>
</verify>
<done>Playlist view has a sort toolbar below the header with four options (Recent, Name, Date Created, Track Count), a direction toggle button, dropdown opens/closes correctly, sort preference saved to localStorage, playlists reorder when sort changes. Default is "Recent" descending (matching current behavior).</done>
</task>
</tasks>
<verification>
1. `cd backend && go build ./... && go vet ./...` — backend compiles
2. `cd frontend && npx tsc --noEmit` — frontend type-checks
3. Manual: Open playlist view, verify sort dropdown appears, try each sort option, toggle direction, verify playlists reorder correctly
4. Manual: Switch away from playlist view and back — sort preference persists
</verification>
<success_criteria>
- Sort dropdown visible in playlist view header area
- Four sort options: Recent (default), Name, Date Created, Track Count
- Ascending/descending toggle works
- Playlists visually reorder when sort or direction changes
- Sort preference persists in localStorage across view switches
- Backend compiles, frontend type-checks
- Default sort (Recent, desc) matches the existing behavior (updated_at DESC from DB)
</success_criteria>
<output>
After completion, create `.planning/quick/5-add-sort-dropdown-to-playlist-view/5-SUMMARY.md`
</output>