Feature/oss 312 notebook item counts (#175)

* fix: small issue where users cant change podcast segments

* feat: display source and note counts on notebook cards (OSS-312)

Add item counters to notebook listing page showing the number of sources
and notes in each notebook. Counts are displayed in a footer section with
FileText and StickyNote icons for visual consistency with ContextIndicator.

Backend changes:
- Add source_count and note_count to NotebookResponse model
- Update /notebooks endpoint to use SurrealDB graph traversal query
- Query: count(<-reference.in) for sources, count(<-artifact.in) for notes
- Update all notebook endpoints to include counts

Frontend changes:
- Add source_count and note_count to TypeScript NotebookResponse interface
- Add footer section to NotebookCard component
- Display counts with FileText and StickyNote icons (h-3 w-3)
- Use border-top separator and muted-foreground styling

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* style: use colorful badges for notebook counts matching ContextIndicator

Update notebook card counts to use Badge components with primary color
styling instead of plain text, matching the visual style of the
ContextIndicator component in the chat window.

Changes:
- Replace plain text divs with Badge components
- Apply text-primary and border-primary/50 styling
- Use same spacing (gap-1.5, px-1.5, py-0.5) as ContextIndicator
- Remove bullet separator (not needed with badge layout)

Visual result matches the colorful badges shown in chat context.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Luis Novo 2025-10-19 16:03:36 -03:00 committed by GitHub
parent 1a67f1f912
commit df0986cee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 85 additions and 20 deletions

View file

@ -24,6 +24,8 @@ class NotebookResponse(BaseModel):
archived: bool archived: bool
created: str created: str
updated: str updated: str
source_count: int
note_count: int
# Search models # Search models

View file

@ -18,22 +18,33 @@ async def get_notebooks(
): ):
"""Get all notebooks with optional filtering and ordering.""" """Get all notebooks with optional filtering and ordering."""
try: try:
notebooks = await Notebook.get_all(order_by=order_by) # Build the query with counts
query = f"""
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM notebook
ORDER BY {order_by}
"""
result = await repo_query(query)
# Filter by archived status if specified # Filter by archived status if specified
if archived is not None: if archived is not None:
notebooks = [nb for nb in notebooks if nb.archived == archived] result = [nb for nb in result if nb.get("archived") == archived]
return [ return [
NotebookResponse( NotebookResponse(
id=nb.id or "", id=str(nb.get("id", "")),
name=nb.name, name=nb.get("name", ""),
description=nb.description, description=nb.get("description", ""),
archived=nb.archived or False, archived=nb.get("archived", False),
created=str(nb.created), created=str(nb.get("created", "")),
updated=str(nb.updated), updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
) )
for nb in notebooks for nb in result
] ]
except Exception as e: except Exception as e:
logger.error(f"Error fetching notebooks: {str(e)}") logger.error(f"Error fetching notebooks: {str(e)}")
@ -59,6 +70,8 @@ async def create_notebook(notebook: NotebookCreate):
archived=new_notebook.archived or False, archived=new_notebook.archived or False,
created=str(new_notebook.created), created=str(new_notebook.created),
updated=str(new_notebook.updated), updated=str(new_notebook.updated),
source_count=0, # New notebook has no sources
note_count=0, # New notebook has no notes
) )
except InvalidInputError as e: except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@ -73,17 +86,28 @@ async def create_notebook(notebook: NotebookCreate):
async def get_notebook(notebook_id: str): async def get_notebook(notebook_id: str):
"""Get a specific notebook by ID.""" """Get a specific notebook by ID."""
try: try:
notebook = await Notebook.get(notebook_id) # Query with counts for single notebook
if not notebook: query = """
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM $notebook_id
"""
result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
if not result:
raise HTTPException(status_code=404, detail="Notebook not found") raise HTTPException(status_code=404, detail="Notebook not found")
nb = result[0]
return NotebookResponse( return NotebookResponse(
id=notebook.id or "", id=str(nb.get("id", "")),
name=notebook.name, name=nb.get("name", ""),
description=notebook.description, description=nb.get("description", ""),
archived=notebook.archived or False, archived=nb.get("archived", False),
created=str(notebook.created), created=str(nb.get("created", "")),
updated=str(notebook.updated), updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
) )
except HTTPException: except HTTPException:
raise raise
@ -112,6 +136,29 @@ async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):
await notebook.save() await notebook.save()
# Query with counts after update
query = """
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM $notebook_id
"""
result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
if result:
nb = result[0]
return NotebookResponse(
id=str(nb.get("id", "")),
name=nb.get("name", ""),
description=nb.get("description", ""),
archived=nb.get("archived", False),
created=str(nb.get("created", "")),
updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
)
# Fallback if query fails
return NotebookResponse( return NotebookResponse(
id=notebook.id or "", id=notebook.id or "",
name=notebook.name, name=notebook.name,
@ -119,6 +166,8 @@ async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):
archived=notebook.archived or False, archived=notebook.archived or False,
created=str(notebook.created), created=str(notebook.created),
updated=str(notebook.updated), updated=str(notebook.updated),
source_count=0,
note_count=0,
) )
except HTTPException: except HTTPException:
raise raise

View file

@ -5,7 +5,7 @@ import { NotebookResponse } from '@/lib/types/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MoreHorizontal, Archive, ArchiveRestore, Trash2 } from 'lucide-react' import { MoreHorizontal, Archive, ArchiveRestore, Trash2, FileText, StickyNote } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { import {
DropdownMenu, DropdownMenu,
@ -112,6 +112,18 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })} Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })}
</div> </div>
{/* Item counts footer */}
<div className="mt-3 flex items-center gap-1.5 border-t pt-3">
<Badge variant="outline" className="text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50">
<FileText className="h-3 w-3" />
<span>{notebook.source_count}</span>
</Badge>
<Badge variant="outline" className="text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50">
<StickyNote className="h-3 w-3" />
<span>{notebook.note_count}</span>
</Badge>
</div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -229,7 +229,7 @@ export function EpisodeProfileFormDialog({
type="number" type="number"
min={3} min={3}
max={20} max={20}
{...register('num_segments')} {...register('num_segments', { valueAsNumber: true })}
/> />
{errors.num_segments ? ( {errors.num_segments ? (
<p className="text-xs text-red-600">{errors.num_segments.message}</p> <p className="text-xs text-red-600">{errors.num_segments.message}</p>

View file

@ -5,6 +5,8 @@ export interface NotebookResponse {
archived: boolean archived: boolean
created: string created: string
updated: string updated: string
source_count: number
note_count: number
} }
export interface NoteResponse { export interface NoteResponse {