docs(04): create phase plan
Phase 04: Tags & Organization - 3 plan(s) in 2 wave(s) - Wave 1: 04-01 (tags schema), 04-02 (pin/due date) - parallel - Wave 2: 04-03 (tag UI) - depends on 04-01 - Ready for execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,12 +88,12 @@ Plans:
|
|||||||
5. User can pin/favorite an entry for quick access
|
5. User can pin/favorite an entry for quick access
|
||||||
6. User can set a due date on a task
|
6. User can set a due date on a task
|
||||||
7. Pinned entries appear in a dedicated section at top of list
|
7. Pinned entries appear in a dedicated section at top of list
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 04-01: TBD
|
- [ ] 04-01-PLAN.md — Tags schema with case-insensitive index and tagRepository
|
||||||
- [ ] 04-02: TBD
|
- [ ] 04-02-PLAN.md — Pin/favorite and due date UI (uses existing schema columns)
|
||||||
- [ ] 04-03: TBD
|
- [ ] 04-03-PLAN.md — Tag input component with Svelecte autocomplete
|
||||||
|
|
||||||
### Phase 5: Search
|
### Phase 5: Search
|
||||||
**Goal**: Users can find entries through search and filtering
|
**Goal**: Users can find entries through search and filtering
|
||||||
|
|||||||
183
.planning/phases/04-tags/04-01-PLAN.md
Normal file
183
.planning/phases/04-tags/04-01-PLAN.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
phase: 04-tags
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/lib/server/db/schema.ts
|
||||||
|
- src/lib/server/db/repository.ts
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Tags table exists with case-insensitive unique constraint"
|
||||||
|
- "Entry-tag associations stored in junction table"
|
||||||
|
- "Finding tag by name is case-insensitive"
|
||||||
|
- "Updating entry tags replaces entire tag set atomically"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/lib/server/db/schema.ts"
|
||||||
|
provides: "tags and entryTags table definitions with relations"
|
||||||
|
contains: "tags = sqliteTable"
|
||||||
|
- path: "src/lib/server/db/repository.ts"
|
||||||
|
provides: "tagRepository singleton with CRUD operations"
|
||||||
|
contains: "tagRepository"
|
||||||
|
key_links:
|
||||||
|
- from: "entryTags.entryId"
|
||||||
|
to: "entries.id"
|
||||||
|
via: "foreign key with cascade delete"
|
||||||
|
pattern: "onDelete.*cascade"
|
||||||
|
- from: "entryTags.tagId"
|
||||||
|
to: "tags.id"
|
||||||
|
via: "foreign key with cascade delete"
|
||||||
|
pattern: "onDelete.*cascade"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add database schema and repository layer for many-to-many tag relationships between entries and tags.
|
||||||
|
|
||||||
|
Purpose: Foundation for TAG-01, TAG-02, TAG-03, TAG-04 requirements - enables tag storage and querying
|
||||||
|
Output: tags table, entry_tags junction table, tagRepository with findOrCreate/getAll/updateEntryTags methods
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/tho/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/04-tags/04-RESEARCH.md
|
||||||
|
|
||||||
|
@src/lib/server/db/schema.ts
|
||||||
|
@src/lib/server/db/repository.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add tags schema with case-insensitive unique index</name>
|
||||||
|
<files>src/lib/server/db/schema.ts</files>
|
||||||
|
<action>
|
||||||
|
Add tags table and entry_tags junction table to schema.ts:
|
||||||
|
|
||||||
|
1. Create `lower()` SQL helper function for case-insensitive indexing:
|
||||||
|
```typescript
|
||||||
|
import { sql, SQL } from 'drizzle-orm';
|
||||||
|
import { AnySQLiteColumn } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
export function lower(column: AnySQLiteColumn): SQL {
|
||||||
|
return sql`lower(${column})`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add tags table:
|
||||||
|
```typescript
|
||||||
|
export const tags = sqliteTable('tags', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex('tagNameUniqueIndex').on(lower(table.name)),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add entry_tags junction table with composite primary key:
|
||||||
|
```typescript
|
||||||
|
export const entryTags = sqliteTable('entry_tags', {
|
||||||
|
entryId: text('entry_id').notNull().references(() => entries.id, { onDelete: 'cascade' }),
|
||||||
|
tagId: text('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
|
||||||
|
}, (t) => [
|
||||||
|
primaryKey({ columns: [t.entryId, t.tagId] }),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add type exports:
|
||||||
|
```typescript
|
||||||
|
export type Tag = typeof tags.$inferSelect;
|
||||||
|
export type NewTag = typeof tags.$inferInsert;
|
||||||
|
export type EntryTag = typeof entryTags.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
Import requirements: Add `uniqueIndex`, `primaryKey` to drizzle-orm/sqlite-core imports.
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run check` - TypeScript compiles without errors</verify>
|
||||||
|
<done>Schema file contains tags table with case-insensitive unique index and entry_tags junction table with cascade deletes</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create tagRepository with tag operations</name>
|
||||||
|
<files>src/lib/server/db/repository.ts</files>
|
||||||
|
<action>
|
||||||
|
Add TagRepository interface and implementation after ImageRepository:
|
||||||
|
|
||||||
|
1. Import new schema tables:
|
||||||
|
```typescript
|
||||||
|
import { entries, images, tags, entryTags, lower, ... } from './schema';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Define TagRepository interface:
|
||||||
|
```typescript
|
||||||
|
export interface TagRepository {
|
||||||
|
findOrCreate(name: string): Tag;
|
||||||
|
getAll(): Tag[];
|
||||||
|
getById(id: string): Tag | undefined;
|
||||||
|
getByEntryId(entryId: string): Tag[];
|
||||||
|
updateEntryTags(entryId: string, tagNames: string[]): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Implement SQLiteTagRepository:
|
||||||
|
- `findOrCreate(name)`:
|
||||||
|
- Normalize: `name.trim()`
|
||||||
|
- Query with `sql\`lower(${tags.name}) = lower(${normalizedName})\``
|
||||||
|
- If exists, return it
|
||||||
|
- If not, insert with nanoid() and return
|
||||||
|
|
||||||
|
- `getAll()`:
|
||||||
|
- Select all from tags ordered by name ASC
|
||||||
|
|
||||||
|
- `getById(id)`:
|
||||||
|
- Simple select where eq(tags.id, id)
|
||||||
|
|
||||||
|
- `getByEntryId(entryId)`:
|
||||||
|
- Join entry_tags with tags where entryId matches
|
||||||
|
- Return Tag[] (the tag objects, not junction records)
|
||||||
|
|
||||||
|
- `updateEntryTags(entryId, tagNames)`:
|
||||||
|
- Delete all from entry_tags where entryId matches
|
||||||
|
- For each tagName: findOrCreate tag, then insert into entry_tags
|
||||||
|
|
||||||
|
4. Export singleton:
|
||||||
|
```typescript
|
||||||
|
export const tagRepository: TagRepository = new SQLiteTagRepository();
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `sql` from 'drizzle-orm' for case-insensitive queries.
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run check` - TypeScript compiles. Manually test in dev console or add temporary test route if needed.</verify>
|
||||||
|
<done>tagRepository exported with findOrCreate, getAll, getById, getByEntryId, and updateEntryTags methods working correctly</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npm run check` passes
|
||||||
|
2. Schema includes tags table with unique case-insensitive index on name
|
||||||
|
3. Schema includes entry_tags with composite PK and cascade deletes
|
||||||
|
4. tagRepository singleton exported with all five methods
|
||||||
|
5. Run `npm run db:push` to sync schema to database
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Tags table created with case-insensitive unique constraint
|
||||||
|
- Entry-tag junction table links entries to tags with cascade delete
|
||||||
|
- tagRepository.findOrCreate returns same tag for "work", "Work", "WORK"
|
||||||
|
- tagRepository.updateEntryTags atomically replaces all tags for an entry
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-tags/04-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
284
.planning/phases/04-tags/04-02-PLAN.md
Normal file
284
.planning/phases/04-tags/04-02-PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
---
|
||||||
|
phase: 04-tags
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/routes/+page.server.ts
|
||||||
|
- src/routes/+page.svelte
|
||||||
|
- src/lib/components/EntryCard.svelte
|
||||||
|
- src/lib/components/EntryList.svelte
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can click a pin button to toggle pinned state"
|
||||||
|
- "User can set a due date on any entry"
|
||||||
|
- "Pinned entries appear in a dedicated section at top of list"
|
||||||
|
- "Due date shows on entry card when set"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/routes/+page.server.ts"
|
||||||
|
provides: "togglePin and updateDueDate form actions"
|
||||||
|
contains: "togglePin"
|
||||||
|
- path: "src/lib/components/EntryCard.svelte"
|
||||||
|
provides: "Pin button and due date picker in expanded view"
|
||||||
|
contains: "togglePin"
|
||||||
|
- path: "src/lib/components/EntryList.svelte"
|
||||||
|
provides: "Pinned section above regular entries"
|
||||||
|
contains: "pinned"
|
||||||
|
key_links:
|
||||||
|
- from: "EntryCard pin button"
|
||||||
|
to: "?/togglePin action"
|
||||||
|
via: "form submit or fetch"
|
||||||
|
pattern: "togglePin"
|
||||||
|
- from: "EntryCard due date input"
|
||||||
|
to: "?/updateDueDate action"
|
||||||
|
via: "form submit or fetch"
|
||||||
|
pattern: "updateDueDate"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add pin/favorite and due date features using existing schema columns (pinned, dueDate already in entries table).
|
||||||
|
|
||||||
|
Purpose: Enables ORG-01, ORG-02, ORG-03 requirements - quick access to important entries and task scheduling
|
||||||
|
Output: Toggle pin button, due date picker, pinned entries section at top of list
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/tho/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
|
||||||
|
@src/routes/+page.server.ts
|
||||||
|
@src/routes/+page.svelte
|
||||||
|
@src/lib/components/EntryCard.svelte
|
||||||
|
@src/lib/components/EntryList.svelte
|
||||||
|
@src/lib/server/db/schema.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add togglePin and updateDueDate form actions</name>
|
||||||
|
<files>src/routes/+page.server.ts</files>
|
||||||
|
<action>
|
||||||
|
Add two new form actions to the existing actions object:
|
||||||
|
|
||||||
|
1. `togglePin` action:
|
||||||
|
```typescript
|
||||||
|
togglePin: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id')?.toString();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Entry ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = entryRepository.getById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return fail(404, { error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle pinned state
|
||||||
|
entryRepository.update(id, { pinned: !existing.pinned });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `updateDueDate` action:
|
||||||
|
```typescript
|
||||||
|
updateDueDate: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id')?.toString();
|
||||||
|
const dueDate = formData.get('dueDate')?.toString() || null;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Entry ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = entryRepository.getById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return fail(404, { error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update due date (empty string becomes null)
|
||||||
|
entryRepository.update(id, { dueDate: dueDate || null });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The dueDate is stored as ISO date string (YYYY-MM-DD format from HTML5 date input).
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run check` - TypeScript compiles. Start dev server and test actions via form submission.</verify>
|
||||||
|
<done>togglePin and updateDueDate form actions work correctly, entries.pinned toggles and entries.dueDate updates</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add pin button, due date picker, and pinned section UI</name>
|
||||||
|
<files>src/lib/components/EntryCard.svelte, src/lib/components/EntryList.svelte</files>
|
||||||
|
<action>
|
||||||
|
**EntryCard.svelte changes:**
|
||||||
|
|
||||||
|
1. Add state for due date editing:
|
||||||
|
```typescript
|
||||||
|
let editDueDate = $state(entry.dueDate || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Sync dueDate with entry on collapse (add to existing $effect):
|
||||||
|
```typescript
|
||||||
|
$effect(() => {
|
||||||
|
if (!expanded) {
|
||||||
|
// ... existing syncs
|
||||||
|
editDueDate = entry.dueDate || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add async function for pin toggle (similar to debouncedSave pattern):
|
||||||
|
```typescript
|
||||||
|
async function handleTogglePin() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', entry.id);
|
||||||
|
|
||||||
|
await fetch('?/togglePin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add async function for due date change:
|
||||||
|
```typescript
|
||||||
|
async function handleDueDateChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
editDueDate = input.value;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', entry.id);
|
||||||
|
formData.append('dueDate', input.value);
|
||||||
|
|
||||||
|
await fetch('?/updateDueDate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In collapsed view header (after the expand arrow), add pin indicator:
|
||||||
|
```svelte
|
||||||
|
{#if entry.pinned}
|
||||||
|
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. In collapsed view, show due date if set (near type badge):
|
||||||
|
```svelte
|
||||||
|
{#if entry.dueDate}
|
||||||
|
<span class="text-xs text-gray-500">{entry.dueDate}</span>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. In expanded view, add pin button and due date picker (in the flex row with type and delete):
|
||||||
|
```svelte
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleTogglePin}
|
||||||
|
class="p-2 rounded-lg hover:bg-gray-100 {entry.pinned ? 'text-yellow-500' : 'text-gray-400'}"
|
||||||
|
aria-label={entry.pinned ? 'Unpin entry' : 'Pin entry'}
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="due-date-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
|
||||||
|
<input
|
||||||
|
id="due-date-{entry.id}"
|
||||||
|
type="date"
|
||||||
|
value={editDueDate}
|
||||||
|
onchange={handleDueDateChange}
|
||||||
|
class="px-3 py-2 border border-gray-200 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**EntryList.svelte changes:**
|
||||||
|
|
||||||
|
1. Separate entries into pinned and unpinned:
|
||||||
|
```typescript
|
||||||
|
let pinnedEntries = $derived(entries.filter(e => e.pinned));
|
||||||
|
let unpinnedEntries = $derived(entries.filter(e => !e.pinned));
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Render pinned section first if there are any:
|
||||||
|
```svelte
|
||||||
|
{#if pinnedEntries.length > 0}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2 px-4 md:px-0">Pinned</h2>
|
||||||
|
<div class="space-y-0 md:space-y-3">
|
||||||
|
{#each pinnedEntries as entry (entry.id)}
|
||||||
|
<EntryCard {entry} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if unpinnedEntries.length > 0}
|
||||||
|
<div class="space-y-0 md:space-y-3">
|
||||||
|
{#each unpinnedEntries as entry (entry.id)}
|
||||||
|
<EntryCard {entry} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if entries.length === 0}
|
||||||
|
<!-- existing empty state -->
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run dev`, test:
|
||||||
|
1. Click pin button in expanded view - entry moves to pinned section
|
||||||
|
2. Click again - entry moves back to regular list
|
||||||
|
3. Set due date - shows on collapsed card
|
||||||
|
4. Clear due date - disappears from card</verify>
|
||||||
|
<done>Pin toggle works, due date picker works, pinned entries appear in dedicated section at top</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npm run check` passes
|
||||||
|
2. Pin button toggles entry.pinned state
|
||||||
|
3. Due date picker updates entry.dueDate
|
||||||
|
4. Pinned section appears at top of list with pinned entries
|
||||||
|
5. Pin icon visible on collapsed pinned entries
|
||||||
|
6. Due date visible on collapsed entries when set
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- User can toggle pin status with visual feedback (ORG-01)
|
||||||
|
- User can set/clear due date using native date picker (ORG-02)
|
||||||
|
- Pinned entries appear in labeled section at top of list (ORG-03)
|
||||||
|
- Due date displays on entry card in collapsed view
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-tags/04-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
347
.planning/phases/04-tags/04-03-PLAN.md
Normal file
347
.planning/phases/04-tags/04-03-PLAN.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
---
|
||||||
|
phase: 04-tags
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["04-01"]
|
||||||
|
files_modified:
|
||||||
|
- package.json
|
||||||
|
- src/lib/components/TagInput.svelte
|
||||||
|
- src/lib/components/EntryCard.svelte
|
||||||
|
- src/routes/+page.server.ts
|
||||||
|
- src/routes/+page.svelte
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can add multiple tags to an entry"
|
||||||
|
- "User can remove tags from an entry"
|
||||||
|
- "Tag input shows autocomplete suggestions from existing tags"
|
||||||
|
- "Tags are case-insensitive (work = Work = WORK)"
|
||||||
|
- "New tags can be created inline during entry editing"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/lib/components/TagInput.svelte"
|
||||||
|
provides: "Multi-select tag input with autocomplete and creation"
|
||||||
|
contains: "Svelecte"
|
||||||
|
- path: "src/lib/components/EntryCard.svelte"
|
||||||
|
provides: "Tag display in collapsed view, TagInput in expanded view"
|
||||||
|
contains: "TagInput"
|
||||||
|
- path: "src/routes/+page.server.ts"
|
||||||
|
provides: "updateTags form action and tags in load data"
|
||||||
|
contains: "updateTags"
|
||||||
|
key_links:
|
||||||
|
- from: "TagInput component"
|
||||||
|
to: "availableTags prop"
|
||||||
|
via: "passed from page data"
|
||||||
|
pattern: "availableTags"
|
||||||
|
- from: "EntryCard save"
|
||||||
|
to: "?/updateTags action"
|
||||||
|
via: "fetch call"
|
||||||
|
pattern: "updateTags"
|
||||||
|
- from: "+page.server.ts load"
|
||||||
|
to: "tagRepository.getByEntryId"
|
||||||
|
via: "attach tags to entries"
|
||||||
|
pattern: "getByEntryId"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add tag input UI with Svelecte for autocomplete and inline tag creation, integrated into EntryCard.
|
||||||
|
|
||||||
|
Purpose: Enables TAG-01, TAG-02, TAG-03, TAG-04 requirements - full tag management with user-friendly input
|
||||||
|
Output: TagInput component with Svelecte, tag display on entries, updateTags form action
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/tho/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/04-tags/04-RESEARCH.md
|
||||||
|
@.planning/phases/04-tags/04-01-SUMMARY.md
|
||||||
|
|
||||||
|
@src/lib/server/db/schema.ts
|
||||||
|
@src/lib/server/db/repository.ts
|
||||||
|
@src/lib/components/EntryCard.svelte
|
||||||
|
@src/routes/+page.server.ts
|
||||||
|
@src/routes/+page.svelte
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Install Svelecte and update load/actions for tags</name>
|
||||||
|
<files>package.json, src/routes/+page.server.ts</files>
|
||||||
|
<action>
|
||||||
|
**Install Svelecte:**
|
||||||
|
```bash
|
||||||
|
npm install svelecte
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update +page.server.ts:**
|
||||||
|
|
||||||
|
1. Import tagRepository:
|
||||||
|
```typescript
|
||||||
|
import { entryRepository, imageRepository, tagRepository } from '$lib/server/db/repository';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update load function to include tags on entries and all available tags:
|
||||||
|
```typescript
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const showCompleted = url.searchParams.get('showCompleted') === 'true';
|
||||||
|
const entries = entryRepository.getOrdered({ showCompleted });
|
||||||
|
|
||||||
|
// Attach images AND tags to each entry
|
||||||
|
const entriesWithData = entries.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
images: imageRepository.getByEntryId(entry.id),
|
||||||
|
tags: tagRepository.getByEntryId(entry.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get all tags for autocomplete
|
||||||
|
const allTags = tagRepository.getAll();
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entriesWithData,
|
||||||
|
allTags,
|
||||||
|
showCompleted
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add updateTags action:
|
||||||
|
```typescript
|
||||||
|
updateTags: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id')?.toString();
|
||||||
|
const tagsJson = formData.get('tags')?.toString() || '[]';
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Entry ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = entryRepository.getById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return fail(404, { error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tagNames = JSON.parse(tagsJson) as string[];
|
||||||
|
tagRepository.updateEntryTags(id, tagNames);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, { error: 'Invalid tags format' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run check` - TypeScript compiles. Run `npm run dev` and check console for load data including allTags.</verify>
|
||||||
|
<done>Svelecte installed, load returns entries with tags and allTags array, updateTags action processes tag changes</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create TagInput component and integrate into EntryCard</name>
|
||||||
|
<files>src/lib/components/TagInput.svelte, src/lib/components/EntryCard.svelte, src/routes/+page.svelte</files>
|
||||||
|
<action>
|
||||||
|
**Create TagInput.svelte:**
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import Svelecte from 'svelecte';
|
||||||
|
import type { Tag } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
availableTags: Tag[];
|
||||||
|
selectedTags: Tag[];
|
||||||
|
onchange: (tags: Tag[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { availableTags, selectedTags, onchange }: Props = $props();
|
||||||
|
|
||||||
|
// Transform tags to Svelecte format
|
||||||
|
let options = $derived(availableTags.map(t => ({ value: t.id, label: t.name })));
|
||||||
|
let value = $derived(selectedTags.map(t => ({ value: t.id, label: t.name })));
|
||||||
|
|
||||||
|
function handleChange(event: CustomEvent) {
|
||||||
|
// event.detail is array of {value, label} objects
|
||||||
|
// For new tags (creatable), value === label (the text)
|
||||||
|
const selected = event.detail || [];
|
||||||
|
|
||||||
|
// Map back to Tag format, handling new tags (where value === label means new)
|
||||||
|
const tags: Tag[] = selected.map((item: { value: string; label: string }) => {
|
||||||
|
// If value matches an existing tag id, use that tag
|
||||||
|
const existing = availableTags.find(t => t.id === item.value);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Otherwise it's a new tag - create placeholder (will be created on save)
|
||||||
|
return {
|
||||||
|
id: item.label, // Use label as temp id for new tags
|
||||||
|
name: item.label,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onchange(tags);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Svelecte
|
||||||
|
{options}
|
||||||
|
{value}
|
||||||
|
multiple
|
||||||
|
creatable
|
||||||
|
valueField="value"
|
||||||
|
labelField="label"
|
||||||
|
placeholder="Add tags..."
|
||||||
|
on:change={handleChange}
|
||||||
|
class="tag-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.tag-input) {
|
||||||
|
--sv-bg: white;
|
||||||
|
--sv-border: 1px solid #e5e7eb;
|
||||||
|
--sv-border-radius: 0.5rem;
|
||||||
|
--sv-min-height: 42px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update EntryCard.svelte:**
|
||||||
|
|
||||||
|
1. Import TagInput and Tag type:
|
||||||
|
```typescript
|
||||||
|
import TagInput from './TagInput.svelte';
|
||||||
|
import type { Tag } from '$lib/server/db/schema';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add availableTags prop to Props interface:
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
entry: EntryWithImages & { tags: Tag[] };
|
||||||
|
availableTags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entry, availableTags }: Props = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add state for tag editing:
|
||||||
|
```typescript
|
||||||
|
let editTags = $state<Tag[]>(entry.tags || []);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Sync tags with entry on collapse:
|
||||||
|
```typescript
|
||||||
|
$effect(() => {
|
||||||
|
if (!expanded) {
|
||||||
|
// ... existing syncs
|
||||||
|
editTags = entry.tags || [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add function to handle tag changes:
|
||||||
|
```typescript
|
||||||
|
async function handleTagsChange(newTags: Tag[]) {
|
||||||
|
editTags = newTags;
|
||||||
|
|
||||||
|
// Save immediately (no debounce for tags)
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', entry.id);
|
||||||
|
formData.append('tags', JSON.stringify(newTags.map(t => t.name)));
|
||||||
|
|
||||||
|
await fetch('?/updateTags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Display tags in collapsed view (after type badge):
|
||||||
|
```svelte
|
||||||
|
{#if entry.tags?.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each entry.tags.slice(0, 3) as tag}
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full">{tag.name}</span>
|
||||||
|
{/each}
|
||||||
|
{#if entry.tags.length > 3}
|
||||||
|
<span class="text-xs text-gray-400">+{entry.tags.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Add TagInput in expanded view (after type selector, before delete button):
|
||||||
|
```svelte
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Tags</label>
|
||||||
|
<TagInput
|
||||||
|
{availableTags}
|
||||||
|
selectedTags={editTags}
|
||||||
|
onchange={handleTagsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update +page.svelte:**
|
||||||
|
|
||||||
|
Pass allTags to EntryList, then from EntryList to EntryCard. Or simpler - pass directly:
|
||||||
|
|
||||||
|
1. Update EntryList.svelte to accept and pass availableTags:
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
entries: EntryWithImages[];
|
||||||
|
availableTags: Tag[];
|
||||||
|
}
|
||||||
|
let { entries, availableTags }: Props = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
And pass to each EntryCard:
|
||||||
|
```svelte
|
||||||
|
<EntryCard {entry} {availableTags} />
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update +page.svelte to pass allTags:
|
||||||
|
```svelte
|
||||||
|
<EntryList entries={data.entries} availableTags={data.allTags} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Import Tag type if needed for type checking.
|
||||||
|
</action>
|
||||||
|
<verify>Run `npm run dev`, test:
|
||||||
|
1. Expand an entry - TagInput appears
|
||||||
|
2. Type a tag name - autocomplete shows matching existing tags
|
||||||
|
3. Select existing tag - appears as chip
|
||||||
|
4. Type new tag and press Enter - tag created
|
||||||
|
5. Click X on tag chip - tag removed
|
||||||
|
6. Collapse and expand - tags persist</verify>
|
||||||
|
<done>TagInput component with Svelecte works, shows autocomplete, allows creation, displays tags on collapsed entries</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npm run check` passes
|
||||||
|
2. Svelecte package installed and loads correctly
|
||||||
|
3. Tag autocomplete shows all existing tags
|
||||||
|
4. User can add tags (existing or new) to entries
|
||||||
|
5. User can remove tags by clicking X
|
||||||
|
6. Tags display on collapsed entry cards
|
||||||
|
7. Case-insensitive: typing "Work" matches existing "work" tag
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- User can add multiple tags to entry via Svelecte input (TAG-01)
|
||||||
|
- User can remove tags by clicking X on chips (TAG-02)
|
||||||
|
- Autocomplete shows matching existing tags as user types (TAG-03)
|
||||||
|
- Existing tags matched case-insensitively (TAG-04)
|
||||||
|
- Tags visible on collapsed entry cards (max 3 with +N indicator)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-tags/04-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user