docs(10): create phase plan

Phase 10: Core Plugins
- 4 plans in 2 waves
- Wave 1: 10-01 (User auth), 10-03 (Blog posts) - parallel
- Wave 2: 10-02 (User profiles), 10-04 (Blog categories/tags) - sequential
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 16:07:32 +01:00
parent 85dc7c7464
commit 4d8d5719d5
5 changed files with 1539 additions and 6 deletions

View File

@@ -0,0 +1,512 @@
---
phase: 10-core-plugins
plan: 04
type: execute
wave: 2
depends_on: ["10-03"]
files_modified:
- plugins/golem15/blog/models/Category.scala
- plugins/golem15/blog/models/Tag.scala
- plugins/golem15/blog/repositories/CategoryRepository.scala
- plugins/golem15/blog/repositories/TagRepository.scala
- plugins/golem15/blog/services/CategoryService.scala
- plugins/golem15/blog/services/TagService.scala
- plugins/golem15/blog/services/BlogPostService.scala
- plugins/golem15/blog/controllers/Categories.scala
- plugins/golem15/blog/components/Posts.scala
- plugins/golem15/blog/components/Post.scala
- plugins/golem15/blog/components/Categories.scala
- plugins/golem15/blog/components/RelatedPosts.scala
- plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
- plugins/golem15/blog/resources/controllers/categories/fields.yaml
- plugins/golem15/blog/resources/views/posts/default.peb
- plugins/golem15/blog/resources/views/posts/item.peb
- plugins/golem15/blog/resources/views/post/default.peb
autonomous: true
must_haves:
truths:
- "Blog posts can be assigned to categories"
- "Categories are hierarchical (parent/child)"
- "Blog posts can have multiple tags"
- "Frontend displays post listings with pagination"
- "Frontend displays individual post pages"
- "Related posts shown based on shared categories/tags"
artifacts:
- path: "plugins/golem15/blog/models/Category.scala"
provides: "Hierarchical category model with nested set"
contains: "case class Category"
- path: "plugins/golem15/blog/models/Tag.scala"
provides: "Tag model for free-form labeling"
contains: "case class Tag"
- path: "plugins/golem15/blog/components/Posts.scala"
provides: "Frontend post listing component"
exports: ["PostsComponent", "onLoadMore"]
- path: "plugins/golem15/blog/components/Post.scala"
provides: "Single post display component"
exports: ["PostComponent"]
- path: "plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql"
provides: "Categories, tags, and pivot tables"
contains: "CREATE TABLE blog_categories"
key_links:
- from: "plugins/golem15/blog/components/Posts.scala"
to: "BlogPostService"
via: "post listing"
pattern: "postService\\.listFrontend"
- from: "plugins/golem15/blog/services/BlogPostService.scala"
to: "CategoryService"
via: "category filtering"
pattern: "categoryService\\.getAllChildrenAndSelf"
- from: "plugins/golem15/blog/services/CategoryService.scala"
to: "NestedTree"
via: "tree operations"
pattern: "NestedTree\\."
---
<objective>
Complete the Blog plugin with categories, tags, and frontend listing components.
Purpose: Enable content organization and frontend display, demonstrating hierarchical data structures and frontend component patterns.
Output: Complete Blog plugin with:
- Hierarchical categories using nested set model
- Tags for free-form labeling
- Frontend Posts component with pagination/infinite scroll
- Frontend Post component for single post display
- Related posts based on shared categories/tags
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Prior plan in this phase
@.planning/phases/10-core-plugins/10-03-SUMMARY.md
# Reference: Phase 3 component patterns for HTMX
</context>
<tasks>
<task type="auto">
<name>Task 1: Category and tag models with services</name>
<files>
plugins/golem15/blog/models/Category.scala
plugins/golem15/blog/models/Tag.scala
plugins/golem15/blog/models/PostCategory.scala
plugins/golem15/blog/models/PostTag.scala
plugins/golem15/blog/repositories/CategoryRepository.scala
plugins/golem15/blog/repositories/TagRepository.scala
plugins/golem15/blog/services/CategoryService.scala
plugins/golem15/blog/services/TagService.scala
plugins/golem15/blog/services/NestedTree.scala
plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
</files>
<action>
Create category and tag models with nested tree support:
**Category.scala (research Pattern 6):**
- Case class with fields:
id, name, slug (unique), description (Option), parentId (Option),
nestLeft (Int), nestRight (Int), nestDepth (Int),
published, publishedAt, createdAt, updatedAt
- Quill table mapping to `blog_categories`
- CategoryTreeNode case class: category, children (List), postCount
**Tag.scala (research Pattern 7):**
- Case class: id, name, slug (unique), createdAt, updatedAt
- Quill table mapping to `blog_tags`
- TagWithCount case class: tag, postCount
**PostCategory.scala & PostTag.scala:**
- Pivot case classes for many-to-many relationships
- PostCategory: postId, categoryId
- PostTag: postId, tagId
**NestedTree.scala (utility object):**
- Functions for nested set operations:
- rebuild(categories: List[Category]): List[Category] - Recalculate left/right
- descendants(category, all): List[Category] - Get all children
- ancestors(category, all): List[Category] - Get all parents
- moveNode(category, newParent, all): List[Category] - Move category in tree
- insertNode(category, parent, all): List[Category] - Add new category
- deleteNode(category, all): List[Category] - Remove category and adjust tree
**CategoryRepository.scala:**
- Trait:
- findById(id): Option[Category]
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- create(category): Category
- update(category): Category
- delete(id): Unit
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int (includes children)
- Live implementation using QuillContext
**TagRepository.scala:**
- Trait:
- findById(id): Option[Tag]
- findBySlug(slug): Option[Tag]
- findByName(name): Option[Tag]
- listAll: List[Tag]
- create(tag): Tag
- listForPost(postId): List[Tag]
- getPopular(limit): List[TagWithCount]
- Live implementation
**CategoryService.scala:**
- Trait:
- create(data): Category (auto-generate slug, rebuild tree)
- update(id, data): Category (rebuild tree if parent changed)
- delete(id): Unit (rebuild tree after)
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- getTree: List[CategoryTreeNode]
- getChildren(parentId): List[Category]
- getAllChildrenAndSelf(categoryId): List[Category] (for nested queries)
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int
- Live implementation with NestedTree for tree operations
**TagService.scala:**
- Trait:
- findOrCreate(name): Tag (create with auto-slug if not exists)
- findBySlug(slug): Option[Tag]
- listAll: List[Tag]
- listForPost(postId): List[Tag]
- getPopularTags(limit): List[TagWithCount]
- syncPostTags(postId, tagNames): List[Tag] (update pivot table)
- Live implementation
**V10_2_2__blog_categories_tags.sql:**
- CREATE TABLE blog_categories (all fields, nested set indexes)
- CREATE TABLE blog_tags (id, name, slug unique, timestamps)
- CREATE TABLE blog_posts_categories (post_id, category_id, composite PK, FKs)
- CREATE TABLE blog_posts_tags (post_id, tag_id, composite PK, FKs)
- ALTER TABLE blog_posts ADD CONSTRAINT fk_main_category REFERENCES blog_categories
- Indexes on slugs, nested set columns, pivot table lookups
</action>
<verify>
./mill summercms.compile succeeds
NestedTree rebuild produces valid left/right sequence
SQL migration syntax valid
</verify>
<done>
Category model with nested set tree support.
Tag model for free-form labeling.
Services handle CRUD with automatic tree maintenance.
Pivot tables link posts to categories and tags.
</done>
</task>
<task type="auto">
<name>Task 2: Update BlogPostService for relations</name>
<files>
plugins/golem15/blog/services/BlogPostService.scala (modify)
plugins/golem15/blog/repositories/BlogPostRepository.scala (modify)
plugins/golem15/blog/controllers/Posts.scala (modify)
plugins/golem15/blog/controllers/Categories.scala
plugins/golem15/blog/resources/controllers/posts/fields.yaml (modify)
plugins/golem15/blog/resources/controllers/categories/fields.yaml
plugins/golem15/blog/resources/controllers/categories/columns.yaml
</files>
<action>
Integrate categories and tags with blog posts:
**BlogPostRepository.scala additions:**
- findBySlugWithRelations(slug): Option[BlogPostWithRelations]
- listFrontendWithRelations(options): PaginatedResult[BlogPostWithRelations]
- listByCategory(categoryId, options): PaginatedResult[BlogPostWithRelations]
- listByTag(tagSlug, options): PaginatedResult[BlogPostWithRelations]
- getRelatedPosts(postId, limit): List[BlogPostWithRelations]
- Join queries to load categories, tags, author in single query
**BlogPostService.scala modifications:**
- BlogPostWithRelations case class:
post: BlogPost, categories: List[Category], tags: List[Tag],
featuredImages: List[MediaItem], author: Option[BackendUser],
readingTime: Int
- Update create/update to handle categories and tags:
- PostCreateData/UpdateData add: categoryIds (List[Long]), tagNames (List[String])
- On save: Sync pivot tables via services
- Set mainCategoryId if categories provided
- Update listFrontend to use listFrontendWithRelations
- Add listByCategory(categoryId, options)
- Add listByTag(tagSlug, options)
- Add getRelatedPosts(postId, limit):
- Score = 2 * shared_categories + 1 * shared_tags
- Exclude current post
- Return top N by score
**Posts.scala controller modifications:**
- Update store/update actions to handle categories and tags from form
- Add category/tag selection to form context
**Categories.scala controller (new):**
- Admin controller for category management
- Actions: index, create, store, edit, update, delete, reorder (tree)
- HTMX handler for drag-drop tree reordering
**fields.yaml modifications (Posts):**
- Add categories tab fields:
```yaml
_categories:
type: relation
label: Categories
nameFrom: name
list: true
tree: true
tab: Categories
_tags:
type: taglist
label: Tags
mode: array
separator: ','
tab: Tags
```
**categories/fields.yaml:**
```yaml
fields:
name:
type: text
label: Name
required: true
slug:
type: text
label: Slug
preset:
field: name
type: slug
parent_id:
type: relation
label: Parent Category
emptyOption: -- None --
nameFrom: name
description:
type: textarea
label: Description
size: small
published:
type: checkbox
label: Published
```
**categories/columns.yaml:**
```yaml
columns:
name:
label: Name
sortable: true
tree: true
slug:
label: Slug
published:
label: Published
type: switch
post_count:
label: Posts
type: number
```
**Register Categories controller in Plugin.scala**
</action>
<verify>
./mill summercms.compile succeeds
Related posts algorithm returns posts with shared categories/tags
Category tree drag-drop works in admin
</verify>
<done>
Posts can be assigned categories and tags.
Admin can manage categories in tree view.
Related posts calculated by shared categories/tags.
Post queries include relations efficiently.
</done>
</task>
<task type="auto">
<name>Task 3: Frontend listing components</name>
<files>
plugins/golem15/blog/components/Posts.scala
plugins/golem15/blog/components/Post.scala
plugins/golem15/blog/components/Categories.scala
plugins/golem15/blog/components/RelatedPosts.scala
plugins/golem15/blog/resources/views/posts/default.peb
plugins/golem15/blog/resources/views/posts/item.peb
plugins/golem15/blog/resources/views/post/default.peb
plugins/golem15/blog/resources/views/categories/default.peb
plugins/golem15/blog/resources/views/relatedposts/default.peb
plugins/golem15/blog/Plugin.scala (modify)
</files>
<action>
Create frontend components for blog display:
**Posts.scala (research Pattern 8):**
- Extend SummerComponent trait
- ComponentDetails: name "Posts", description "Blog post listing"
- Property schema:
- pageNumber: String, URL param for pagination, default "{{ :page }}"
- categoryFilter: String, filter by category slug
- postsPerPage: String, default "10", validation 1-100
- noPostsMessage: String, default "No posts found."
- sortOrder: Dropdown with BlogPost.allowedSortingOptions
- throwNotFound: Checkbox, 404 on empty results
- Page variables: posts (PaginatedResult), category (Option), noPostsMessage
- onRun lifecycle:
- Load category if categoryFilter set
- Build PostListOptions from properties
- Call postService.listFrontend or listByCategory
- Handle empty results per throwNotFound
- Set page context variables
- onLoadMore HTMX handler:
- Parse page number from form
- Load next page of posts
- Return items partial
- Trigger "postsExhausted" when no more
**Post.scala component:**
- ComponentDetails: name "Post", description "Single post display"
- Property schema:
- slug: String, URL param for post slug, default "{{ :slug }}"
- notFoundMessage: String
- Page variables: post (BlogPostWithRelations)
- onRun lifecycle:
- Get slug from URL
- Call postService.findBySlug
- 404 if not found
- Set page context: post, author, categories, tags, readingTime
- Set OpenGraph meta tags for social sharing
**Categories.scala component:**
- ComponentDetails: name "Categories", description "Category listing/tree"
- Property schema:
- displayMode: Dropdown (tree/flat), default "tree"
- showPostCount: Checkbox, default true
- includeEmpty: Checkbox, show categories with no posts
- Page variables: categories (tree or flat list)
- onRun: Load category tree, filter empty if needed
**RelatedPosts.scala component:**
- ComponentDetails: name "RelatedPosts", description "Related posts by category/tag"
- Property schema:
- limit: String, max posts to show, default "3"
- postSlug: String, current post slug (from context)
- Page variables: relatedPosts (List)
- onRun: Call postService.getRelatedPosts, set context
**Templates:**
**posts/default.peb (research code example):**
```html
<div class="blog-posts" id="posts-{{ __SELF__ }}">
{% if posts.items is empty %}
<div class="no-posts">{{ noPostsMessage }}</div>
{% else %}
<div class="posts-list">
{% for post in posts.items %}
{% include "posts/item" %}
{% endfor %}
</div>
{% if posts.hasNext %}
<div class="load-more">
<button
hx-post="{{ componentHandler('onLoadMore') }}"
hx-vals='{"page": {{ posts.currentPage + 1 }}}'
hx-target="#posts-{{ __SELF__ }} .posts-list"
hx-swap="beforeend"
hx-indicator=".htmx-indicator">
Load More
<span class="htmx-indicator">Loading...</span>
</button>
</div>
{% endif %}
{% endif %}
</div>
```
**posts/item.peb:**
- Article element with post data
- Featured image (lazy load)
- Title as link to post page
- Excerpt or auto-generated summary
- Author name, published date, reading time
- Category and tag links
**post/default.peb:**
- Full post display
- OpenGraph meta tags in head
- Title, featured image, author, date
- Content HTML
- Categories and tags
- Share buttons (copy link, Twitter, Facebook)
- Include RelatedPosts component placeholder
**categories/default.peb:**
- Tree or flat list based on displayMode
- Category name as link
- Post count if showPostCount
**relatedposts/default.peb:**
- Grid of related post cards
- Image, title, excerpt snippet
**Register all components in Plugin.scala boot method**
</action>
<verify>
./mill summercms.compile succeeds
Components registered in Plugin.scala
Templates have valid Pebble syntax
HTMX attributes properly formatted
</verify>
<done>
Posts component displays paginated listings with infinite scroll.
Post component displays single post with meta tags.
Categories component shows category tree/list.
RelatedPosts component shows related content.
All templates render with HTMX interactions.
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. Blog plugin compiles: `./mill summercms.compile`
2. Migration creates all tables: blog_categories, blog_tags, pivot tables
3. Category nested tree rebuilds correctly on insert/delete
4. Posts component loads and paginates
5. Post component displays with OpenGraph meta
6. Related posts algorithm returns relevant content
</verification>
<success_criteria>
- Categories form hierarchical tree structure
- Nested set left/right values correct after operations
- Tags can be created on-the-fly when typing
- Post admin form shows category tree selector and tag input
- Frontend /blog shows post listing with load more
- Frontend /blog/:slug shows full post with related posts
- Category pages filter posts by category and children
- Tag pages filter posts by tag
</success_criteria>
<output>
After completion, create `.planning/phases/10-core-plugins/10-04-SUMMARY.md`
</output>