Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions plans/sc-39971-browse-playlists-dashboard.execplan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# SC-39971: Add the public playlist browse dashboard

This ExecPlan is a living document maintained in accordance with `PLANS.md`.

## Purpose / Big Picture

Add a public playlist dashboard at `/playlists`. Visitors can browse every public playlist, open a playlist detail page, and follow its available learning objects to their existing CLARK detail pages. The user profile remains unchanged and contributions-only.

## Progress

- [x] (2026-09-10) Added the typed playlist read service and route builder.
- [x] (2026-09-10) Added lazy-loaded browse and detail routes with responsive cards and loading, empty, and failure states.
- [x] (2026-09-10) Added Browse Playlists to the secondary navigation and linked learning objects through the existing card component.
- [x] (2026-09-11) Removed the profile playlist tabs, profile-specific component/tests, and unused playlist mutation client methods.
- [x] (2026-09-11) Re-ran formatting, lint, build, focused TypeScript spec compilation, and Jest; documented the unchanged Jest infrastructure blocker.

## Surprises & Discoveries

- Observation: The hydrated playlist response has CUID/version card data but not the author username required by the existing learning-object URL. The detail page resolves each available entry through `LearningObjectService` before rendering the established linked card.
- Observation: Jest is currently blocked during global setup by `TypeError: configSet.processWithEsbuild is not a function`; focused TypeScript compilation is the available spec-validation fallback.

## Decision Log

- Decision: Keep `/playlists` as the canonical URL and redirect `/playlits` to it.
Rationale: This supports the originally requested misspelling without making it the permanent route contract.
Date/Author: 2026-09-10 / Codex
- Decision: Keep playlist browsing independent from user profiles.
Rationale: The revised branch scope is the public browse dashboard only.
Date/Author: 2026-09-11 / Codex
- Decision: Retain only `getPlaylists()` and `getPlaylist()` in the client service.
Rationale: Creation, mutation, membership, and profile filtering are not used by this read-only dashboard.
Date/Author: 2026-09-11 / Codex

## Outcomes & Retrospective

The branch now contains only the public playlist browse/detail experience. The user profile matches `origin/main` and has no playlist tab, playlist request, or profile-only test/component. The playlist client boundary contains only the two GET operations consumed by the dashboard. The Angular build, lint, and focused TypeScript spec compilation pass; Jest remains blocked before test discovery by the repository's transformer mismatch.

## Context and Orientation

`src/app/cube/cube.routing.ts` lazy-loads `src/app/cube/playlists/playlists.module.ts`. The feature router owns the index and `:playlistId` detail routes. `src/app/core/playlist-module` owns the backend read contract. The existing learning-object service and card component supply canonical learning-object navigation.

## Plan of Work

Keep the public playlist grid, playlist detail page, shared playlist card, secondary Browse link, read-only service, and related tests. Restore `src/app/cube/user-profile/user-profile.component.*` to its original contributions-only behavior and remove all profile playlist files and tests.

## Concrete Steps

From the `clark-client` root:

npx prettier --write <changed files>
npx ng lint clark
npx ng build clark
npx jest --runInBand <focused playlist specs>

## Validation and Acceptance

- `/users/:username` has no playlist tab and behaves as it did before this branch.
- `/playlists` requests the unfiltered public playlist collection.
- `/playlists/:playlistId` displays the selected playlist.
- Available learning-object cards navigate to their real detail pages; stale references render safely.
- Browse Playlists remains between Browse Curriculum and Browse Resources.
- No profile playlist component, state, service call, or test remains.

## Idempotence and Recovery

The cleanup restores profile files to their base-branch content and deletes only branch-added profile files. No persisted data or backend behavior changes.

## Artifacts and Notes

- `npx prettier --write <changed files>`: passed.
- `npx ng lint clark`: passed with 0 errors and 259 existing warnings.
- `npx ng build clark`: passed.
- Focused `npx tsc` spec configuration: passed.
- Focused `npx jest --runInBand ...`: blocked before test discovery by `TypeError: configSet.processWithEsbuild is not a function`.
- `git diff --check`: passed.

## Interfaces and Dependencies

The dashboard uses Angular Router, `HttpClient`, RxJS, the existing learning-object service/card, and the backend `GET /playlists` query contract. It adds no package or environment dependency.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<div #contextMenu>
<ul>
<li [routerLink]="'/browse'">Browse Curriculum</li>
<li [routerLink]="'/playlists'">Browse Playlists</li>
<li (click)="utilityService.openCard()">Browse Resources</li>
</ul>
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/app/core/playlist-module/playlist.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { environment } from "@env/environment";
import { GetPlaylistsQuery } from "./playlist.types";

const playlistsPath = `${environment.apiURL}/playlists`;

export const PLAYLIST_ROUTES = {
GET_PLAYLISTS(query: GetPlaylistsQuery = {}): string {
const params = new URLSearchParams();
if (query.playlistId) {
params.set("playlistId", query.playlistId);
}
const queryString = params.toString();
return queryString ? `${playlistsPath}?${queryString}` : playlistsPath;
},
};
55 changes: 55 additions & 0 deletions src/app/core/playlist-module/playlist.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from "@angular/common/http/testing";
import { TestBed } from "@angular/core/testing";
import { environment } from "@env/environment";
import { PlaylistService } from "./playlist.service";
import { Playlist } from "./playlist.types";

describe("PlaylistService", () => {
let service: PlaylistService;
let httpMock: HttpTestingController;

const playlist: Playlist = {
_id: "playlist/id",
userId: "user/id",
learningObjectCuids: [],
name: "Security playlist",
description: "A playlist description",
visibility: "public",
};

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [PlaylistService],
});
service = TestBed.inject(PlaylistService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => httpMock.verify());

it("gets all public playlists without a user filter", () => {
service.getPlaylists().subscribe((result) => {
expect(result).toEqual([playlist]);
});

const request = httpMock.expectOne(`${environment.apiURL}/playlists`);
expect(request.request.method).toBe("GET");
expect(request.request.withCredentials).toBe(true);
request.flush([playlist]);
});

it("gets one hydrated playlist", () => {
service.getPlaylist(playlist._id).subscribe();

const request = httpMock.expectOne(
`${environment.apiURL}/playlists?playlistId=playlist%2Fid`,
);
expect(request.request.method).toBe("GET");
expect(request.request.withCredentials).toBe(true);
request.flush({ ...playlist, learningObjects: [] });
});
});
23 changes: 23 additions & 0 deletions src/app/core/playlist-module/playlist.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { PLAYLIST_ROUTES } from "./playlist.routes";
import { Playlist, PlaylistDetails } from "./playlist.types";

@Injectable({ providedIn: "root" })
export class PlaylistService {
constructor(private readonly http: HttpClient) {}

getPlaylists(): Observable<Playlist[]> {
return this.http.get<Playlist[]>(PLAYLIST_ROUTES.GET_PLAYLISTS(), {
withCredentials: true,
});
}

getPlaylist(playlistId: string): Observable<PlaylistDetails> {
return this.http.get<PlaylistDetails>(
PLAYLIST_ROUTES.GET_PLAYLISTS({ playlistId }),
{ withCredentials: true },
);
}
}
36 changes: 36 additions & 0 deletions src/app/core/playlist-module/playlist.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type PlaylistVisibility = "public" | "private";

export interface Playlist {
_id: string;
userId: string;
learningObjectCuids: string[];
name: string;
description: string;
visibility: PlaylistVisibility;
createdAt?: string;
updatedAt?: string;
}

export interface PlaylistLearningObjectCard {
cuid: string;
name: string;
description: string;
objectCollection: string;
length: string;
levels: string[];
version: number;
status: string;
}

export interface PlaylistLearningObject {
cuid: string;
object: PlaylistLearningObjectCard | null;
}

export interface PlaylistDetails extends Playlist {
learningObjects: PlaylistLearningObject[];
}

export interface GetPlaylistsQuery {
playlistId?: string;
}
13 changes: 13 additions & 0 deletions src/app/cube/cube.routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ const cube_routes: Routes = [
component: BrowseComponent,
data: { title: "Browse Learning Objects" },
},
{
path: "playlists",
loadChildren: () =>
import("./playlists/playlists.module").then(
(m) => m.PlaylistsModule,
),
data: { title: "Browse Playlists" },
},
{
path: "playlits",
redirectTo: "playlists",
pathMatch: "full",
},
{
path: "press",
component: PressComponent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<a
class="playlist-card"
[routerLink]="['/playlists', playlist._id]"
[attr.aria-label]="
playlist.name +
', ' +
objectCount +
(objectCount === 1 ? ' learning object' : ' learning objects')
">
<div class="playlist-card__image" aria-hidden="true">
<i class="fas fa-list"></i>
</div>

<div class="playlist-card__content">
<div class="playlist-card__labels">
<span class="playlist-label">Playlist</span>
<span
*ngIf="showVisibility"
class="visibility"
[class.visibility--private]="playlist.visibility === 'private'">
{{ playlist.visibility }}
</span>
</div>
<h2>{{ playlist.name }}</h2>
<p>{{ playlist.description }}</p>
<span class="object-count">
{{ objectCount }}
{{ objectCount === 1 ? "learning object" : "learning objects" }}
</span>
</div>
</a>
Loading