Skip to content

feat(favorites): server-persist cycle/module favorites with folders and ordering#305

Merged
martian56 merged 2 commits into
Devlaner:mainfrom
cavidelizade:feat/favorites-cycles-modules
Jul 13, 2026
Merged

feat(favorites): server-persist cycle/module favorites with folders and ordering#305
martian56 merged 2 commits into
Devlaner:mainfrom
cavidelizade:feat/favorites-cycles-modules

Conversation

@cavidelizade

@cavidelizade cavidelizade commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Feature summary

Cycle and module favorites lived only in localStorage — per-device, with no folders and no ordering. They are now persisted server-side in a favorites tree that can be grouped into folders and reordered.

Linked issues / discussion

Closes #205

User-facing behavior

  • Star a cycle or module and it's saved to your account (shows up on any device), listed under Favorites in the sidebar.
  • Create folders in the Favorites section and drag favorites into/out of them.
  • Drag to reorder favorites. Deleting a folder keeps the favorites inside it (they move back to the top level).

What changed

API (apps/api/)

  • Migration 000011: is_folder + sort_order on user_favorites (parent_id already existed).
  • model.UserFavorite: folder/ordering fields + cycle/module/folder entity-type constants.
  • store.UserFavoriteStore: list-by-workspace, generic add/remove entity, create folder, owned get/update/delete (deleting a folder re-parents its children to the top level, so nothing is lost).
  • service.FavoriteService: workspace-membership + project-access checks; list, favorite a cycle/module, create folder, rename/move/reorder, delete. A move validates the parent is one of the user's own folders.
  • handler + router: GET/POST /workspaces/:slug/favorites/, PATCH/DELETE /workspaces/:slug/favorites/:favId/. Existing project/view/page favorite endpoints are untouched.

UI (apps/web/)

  • workspaceFavoriteService + a shared useWorkspaceFavorites hook (state via FavoritesContext).
  • WorkspaceFavoritesTree in the sidebar: folders, ordering, create-folder, native drag-to-reorder / drag-into-folder, remove.
  • CyclesPage / ModulesPage favorite toggles now write to the server. The two localStorage favorite hooks are removed.

Database

  • One additive migration (with a matching down migration).

Test plan

  • go vet ./... and full go test ./... green (testcontainers).
  • New tests: TestFavorites_CycleFolderOrdering (favorite a cycle, create a folder, move it in + reorder, delete the folder and keep the favorite, then unfavorite) and TestFavorites_NonMemberForbidden.
  • npm run typecheck, npm run lint, npm run build green.
  • Manual E2E against the running stack: starred a cycle on the Cycles page → it appeared under sidebar Favorites and the star flipped to "Remove from favorites"; created a folder from the sidebar; moved the cycle into the folder → the sidebar rendered the folder with a count and the cycle nested inside; verified each step via the favorites API.

Out of scope (follow-ups)

  • Project/view/page favorites keep their existing endpoints and rendering; only cycle/module favorites move into the new tree (that's what the issue asked for). They could be unified later.

AI assistance

  • AI tools were used — tool(s): Claude Code — and AI-assisted commits include a Co-Authored-By: trailer

Checklist

  • PR title follows Conventional Commits and is <= 100 chars
  • Added 000011_*.up.sql AND matching .down.sql
  • Trailing slashes on new routes match neighboring routes
  • No --no-verify bypass on the commit (full suite ran on pre-commit)

Summary by CodeRabbit

  • New Features
    • Added workspace-level favorites management (cycles and modules) with a hierarchical favorites tree.
    • Support creating, renaming, moving, reordering, and deleting favorites and folders via workspace API.
    • Added drag-and-drop folder nesting and ordering in the sidebar, with syncing across devices.
  • Bug Fixes
    • Enforced workspace access controls for favorites (non-members blocked).
    • Rejects invalid folder names (e.g., empty/whitespace).
    • Deleting a folder preserves its contents by moving them back to the top level.

…nd ordering

Cycle and module favorites lived only in localStorage (per-device, no folders,
no ordering). They are now persisted server-side in a favorites tree that can
be grouped into folders and reordered.

Backend: extend user_favorites with is_folder + sort_order (parent_id already
existed); FavoriteService + endpoints to list the tree, favorite a
cycle/module, create folders, and move/rename/reorder/delete. Deleting a folder
keeps the favorites inside it (they move to the top level).

Frontend: a shared workspace-favorites hook + a sidebar favorites tree with
create-folder, drag-to-reorder, drag-into/out-of folders, and remove; the
cycle/module favorite toggles now write to the server. Removes the two
localStorage favorite hooks.

Closes Devlaner#205

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cavidelizade cavidelizade requested a review from a team as a code owner July 13, 2026 08:33
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c82ed32c-ce8f-4890-91d7-95107bb25f6d

📥 Commits

Reviewing files that changed from the base of the PR and between b20e64e and 5ae453e.

📒 Files selected for processing (4)
  • apps/api/internal/handler/favorite_tree.go
  • apps/api/internal/handler/favorite_tree_test.go
  • apps/api/internal/service/favorite.go
  • apps/api/internal/store/user_favorite.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/api/internal/handler/favorite_tree_test.go
  • apps/api/internal/service/favorite.go
  • apps/api/internal/handler/favorite_tree.go
  • apps/api/internal/store/user_favorite.go

📝 Walkthrough

Walkthrough

Workspace favorites now support server-backed cycle and module favorites, folders, nesting, ordering, authenticated CRUD APIs, and a hierarchical sidebar tree. Local-storage cycle/module favorite hooks are replaced with workspace-scoped API state and drag-and-drop controls.

Changes

Workspace Favorites

Layer / File(s) Summary
Favorite persistence and schema
apps/api/internal/model/user_favorite.go, apps/api/internal/store/user_favorite.go, apps/api/migrations/*
Favorites gain folder, parent, and ordering fields, entity-type constants, CRUD methods, and transactional child re-parenting.
Favorite service and API
apps/api/internal/service/favorite.go, apps/api/internal/handler/favorite*.go, apps/api/internal/router/router.go, apps/api/internal/handler/favorite_tree_test.go
Workspace access checks, entity and folder operations, HTTP endpoints, error mapping, dependency wiring, and API tests are added.
Web API state and page integration
apps/web/src/api/types.ts, apps/web/src/services/workspaceFavoriteService.ts, apps/web/src/contexts/FavoritesContext.tsx, apps/web/src/hooks/useWorkspaceFavorites.ts, apps/web/src/pages/{CyclesPage,ModulesPage}.tsx
The client adds workspace favorite state and API mutations, and cycle/module pages use the shared workspace favorites hook.
Sidebar favorites tree
apps/web/src/components/layout/Sidebar.tsx, apps/web/src/components/layout/WorkspaceFavoritesTree.tsx
The sidebar replaces local-storage lists with a hierarchical tree supporting folders, navigation, deletion, movement, and reordering.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Sidebar
  participant useWorkspaceFavorites
  participant workspaceFavoriteService
  participant FavoritesAPI
  User->>Sidebar: Drag or toggle a favorite
  Sidebar->>useWorkspaceFavorites: Update favorite state
  useWorkspaceFavorites->>workspaceFavoriteService: PATCH, POST, or DELETE favorite
  workspaceFavoriteService->>FavoritesAPI: Send workspace favorite request
  FavoritesAPI-->>workspaceFavoriteService: Return updated result
  workspaceFavoriteService-->>useWorkspaceFavorites: Resolve mutation
  useWorkspaceFavorites->>workspaceFavoriteService: Reload workspace tree
  workspaceFavoriteService-->>Sidebar: Return favorites
  Sidebar-->>User: Render updated tree
Loading

Possibly related PRs

Suggested labels: enhancement, API, UI

Poem

I’m a rabbit with folders to hop through the view,
With cycles and modules arranged just so true.
I drag one beside me, or tuck it below,
The server remembers wherever I go.
A tidy tree blooms in the sidebar bright—
Favorites now burrow in order and right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is conventional, concise, and accurately summarizes the main change: server-persisted favorites with folders and ordering.
Description check ✅ Passed The PR description covers summary, linked issue, changed areas, testing, and checklist items, so it mostly matches the template.
Linked Issues check ✅ Passed The changes implement #205: server-persisted cycle/module favorites, folder/sort fields, CRUD/reorder behavior, and a sidebar tree.
Out of Scope Changes check ✅ Passed I don't see unrelated code changes; the additions and removals all support the new favorites tree and server-backed cycle/module favorites.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/web/src/components/layout/WorkspaceFavoritesTree.tsx (1)

76-172: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Hoist Row out of the component body.

Row is declared inside WorkspaceFavoritesTree and rendered as a JSX element (<Row … />). React sees a new component type on every parent render, so the entire rows subtree unmounts/remounts each render, which wastes work and can interrupt an in-progress drag. Extract it to a module-level component (passing favorites/expanded/drag state and callbacks as props) or render it as a plain function call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/layout/WorkspaceFavoritesTree.tsx` around lines 76 -
172, Move the Row component definition out of WorkspaceFavoritesTree to module
scope so its component identity remains stable across parent renders. Pass the
required favorites state, expansion state, drag state, and callbacks through
props, or invoke the row renderer as a plain function instead of JSX; preserve
the existing folder expansion, deletion, navigation, and drag-and-drop behavior.
apps/api/migrations/000011_favorites_folders_ordering.up.sql (1)

1-3: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider adding indexes for the new query patterns.

ListByUserAndWorkspace filters by (user_id, workspace_id) and DeleteOwned's reparent query filters by (user_id, parent_id). The existing unique index idx_user_fav_entity leads with entity_type, so it can't accelerate these lookups. For a user_favorites table spanning all users, these are full table scans.

📊 Suggested index additions
 -- Extend user_favorites for folders and ordering. parent_id already exists.
 ALTER TABLE user_favorites ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT FALSE;
 ALTER TABLE user_favorites ADD COLUMN IF NOT EXISTS sort_order DOUBLE PRECISION NOT NULL DEFAULT 65535;
+
+CREATE INDEX IF NOT EXISTS idx_user_fav_user_workspace ON user_favorites (user_id, workspace_id);
+CREATE INDEX IF NOT EXISTS idx_user_fav_user_parent ON user_favorites (user_id, parent_id);

And in the down migration:

 ALTER TABLE user_favorites DROP COLUMN IF EXISTS is_folder;
 ALTER TABLE user_favorites DROP COLUMN IF EXISTS sort_order;
+
+DROP INDEX IF EXISTS idx_user_fav_user_workspace;
+DROP INDEX IF EXISTS idx_user_fav_user_parent;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/migrations/000011_favorites_folders_ordering.up.sql` around lines 1
- 3, Add indexes in the up migration for the `(user_id, workspace_id)` lookup
used by `ListByUserAndWorkspace` and the `(user_id, parent_id)` reparent lookup
used by `DeleteOwned`; add matching index drops in the down migration, using the
project’s established migration naming conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/internal/handler/favorite_tree.go`:
- Around line 59-67: The CreateFavorite handler must reject empty or
whitespace-only names for both folders and entity favorites before calling the
service or parsing UUIDs. Use strings.TrimSpace on body.Name, return the
existing validation error response for blank names, and add the strings import;
preserve normal folder and favorite creation for non-empty names.

In `@apps/api/internal/service/favorite.go`:
- Around line 36-46: Update FavoriteService.workspace and AddEntity to preserve
infrastructure failures: capture and inspect errors from IsMember and GetByID,
propagate unexpected errors unchanged so they reach the 500 path, and map only
the established not-found or membership sentinel errors to ErrFavoriteForbidden.
Keep the existing forbidden behavior for genuine authorization or missing-entity
cases.

In `@apps/api/internal/store/user_favorite.go`:
- Around line 52-69: Update UserFavoriteStore.AddEntity to handle a concurrent
duplicate-key failure from Create: retry fetching the row by the same user_id,
entity_type, and entity_identifier keys, and return that existing row to
preserve idempotency; return the original error only when the retry cannot find
the conflicting row or encounters another database error.

---

Nitpick comments:
In `@apps/api/migrations/000011_favorites_folders_ordering.up.sql`:
- Around line 1-3: Add indexes in the up migration for the `(user_id,
workspace_id)` lookup used by `ListByUserAndWorkspace` and the `(user_id,
parent_id)` reparent lookup used by `DeleteOwned`; add matching index drops in
the down migration, using the project’s established migration naming
conventions.

In `@apps/web/src/components/layout/WorkspaceFavoritesTree.tsx`:
- Around line 76-172: Move the Row component definition out of
WorkspaceFavoritesTree to module scope so its component identity remains stable
across parent renders. Pass the required favorites state, expansion state, drag
state, and callbacks through props, or invoke the row renderer as a plain
function instead of JSX; preserve the existing folder expansion, deletion,
navigation, and drag-and-drop behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6339baac-be83-4d83-9b81-79af325eb9ee

📥 Commits

Reviewing files that changed from the base of the PR and between 5abb69e and b20e64e.

📒 Files selected for processing (19)
  • apps/api/internal/handler/favorite.go
  • apps/api/internal/handler/favorite_tree.go
  • apps/api/internal/handler/favorite_tree_test.go
  • apps/api/internal/model/user_favorite.go
  • apps/api/internal/router/router.go
  • apps/api/internal/service/favorite.go
  • apps/api/internal/store/user_favorite.go
  • apps/api/migrations/000011_favorites_folders_ordering.down.sql
  • apps/api/migrations/000011_favorites_folders_ordering.up.sql
  • apps/web/src/api/types.ts
  • apps/web/src/components/layout/Sidebar.tsx
  • apps/web/src/components/layout/WorkspaceFavoritesTree.tsx
  • apps/web/src/contexts/FavoritesContext.tsx
  • apps/web/src/hooks/useCycleFavorites.ts
  • apps/web/src/hooks/useModuleFavorites.ts
  • apps/web/src/hooks/useWorkspaceFavorites.ts
  • apps/web/src/pages/CyclesPage.tsx
  • apps/web/src/pages/ModulesPage.tsx
  • apps/web/src/services/workspaceFavoriteService.ts
💤 Files with no reviewable changes (2)
  • apps/web/src/hooks/useCycleFavorites.ts
  • apps/web/src/hooks/useModuleFavorites.ts

Comment thread apps/api/internal/handler/favorite_tree.go
Comment thread apps/api/internal/service/favorite.go
Comment thread apps/api/internal/store/user_favorite.go Outdated
… names

Addresses review feedback:
- AddEntity is now an atomic OnConflict insert + re-read, so a concurrent
  favorite of the same entity stays idempotent instead of failing on the unique
  index.
- workspace() and AddEntity surface non-sentinel (infrastructure) errors as
  500s instead of masking them as 404 forbidden.
- Folder and entity favorite names must be non-empty (400 otherwise), so a
  blank label can't be persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cavidelizade

Copy link
Copy Markdown
Contributor Author

@martian56 this one's green and all of CodeRabbit's notes are resolved. Good to merge when you get a chance? It moves cycle/module favorites off localStorage onto the server with folders + drag ordering - no data migration needed for existing localStorage favorites, they'll just re-star. Happy to walk through anything.

@martian56 martian56 merged commit 247d342 into Devlaner:main Jul 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[IMPROVEMENT] Extend favorites to cycles/modules with folders and ordering

2 participants