From 3af2fb0ba48727b94cf60984b53874a4aed15f1a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 19 Feb 2026 20:52:21 -0500 Subject: [PATCH] schema(dashboard): implement Phase 1 unified collections architecture Add support for Carousel-style dashboard with unified collections architecture: Database Schema Changes: - Add user_dashboard_preferences table: - hidden_collections: TEXT[] for managing section visibility - collection_order: TEXT[] for custom ordering - items_per_section: INT for limiting items per section - Update collections table: - user_id: Make nullable to support system-owned collections (NULL = system) - show_on_dashboard: BOOLEAN for controlling visibility - query_type: TEXT for different query types (continue-reading, recently-added, etc.) - priority: INT for display order (lower = higher priority) - is_system_collection: BOOLEAN for flagging system defaults - Update collection_items table: - Add excluded BOOLEAN for user overrides of auto-assigned items Indexes: - idx_collections_dashboard: (user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true - idx_dashboard_prefs_user_library: (user_id, library_id) - idx_collection_items_excluded: (collection_id, excluded) WHERE excluded = true System Collections (pre-seeded defaults): - continue-reading: Books with 0 < progress < 1 - recently-added: Newly added items to library - recently-read: Books with progress >= 1 - not-started: Books with progress = 0 or no record This implements Phase 1 of the Carousel-style dashboard redesign plan. --- database/schema/schema.sql | 40 ++++++++++++++++++- internal/database/models.go | 34 +++++++++++----- internal/database/queries.sql.go | 67 +++++++++++++++++++++++--------- 3 files changed, 113 insertions(+), 28 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 0be1978..ca5fe23 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -847,19 +847,43 @@ CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases -- Create collections table (device-neutral collections) CREATE TABLE IF NOT EXISTS collections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, color VARCHAR(7), icon VARCHAR(50), auto_assign_rules JSONB, view_settings JSONB, + show_on_dashboard BOOLEAN DEFAULT false, + query_type TEXT DEFAULT 'filter', + priority INT DEFAULT 100, + is_system_collection BOOLEAN DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(user_id, name) ); CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); +-- Index for dashboard queries +CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) + WHERE show_on_dashboard = true; + +-- Create user_dashboard_preferences table +CREATE TABLE IF NOT EXISTS user_dashboard_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, + hidden_collections TEXT[] DEFAULT '{}', + collection_order TEXT[] DEFAULT '{}', + items_per_section INT DEFAULT 20, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, library_id) +); + +-- Index for fast lookups +CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); + -- Create collection_items table (which books belong to each collection) CREATE TABLE IF NOT EXISTS collection_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -867,12 +891,26 @@ CREATE TABLE IF NOT EXISTS collection_items ( media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + excluded BOOLEAN DEFAULT false, UNIQUE(collection_id, media_item_id) ); CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id); CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id); +-- Index for excluding auto-assigned items +CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) + WHERE excluded = true; + +-- Insert 4 system collections (pre-seeded defaults) +-- These are user_id NULL to indicate system ownership +INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES +(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'), +(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'), +(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'), +(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null') +ON CONFLICT (user_id, name) DO NOTHING; + -- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names) CREATE TABLE IF NOT EXISTS device_shelf_mappings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/database/models.go b/internal/database/models.go index 340c69f..516ff6c 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -14,18 +14,23 @@ type CollectionItems struct { MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` + Excluded pgtype.Bool `db:"excluded" json:"excluded"` } type Collections struct { - ID pgtype.UUID `db:"id" json:"id"` - UserID pgtype.UUID `db:"user_id" json:"user_id"` - Name string `db:"name" json:"name"` - Description pgtype.Text `db:"description" json:"description"` - Color pgtype.Text `db:"color" json:"color"` - Icon pgtype.Text `db:"icon" json:"icon"` - AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` - ViewSettings []byte `db:"view_settings" json:"view_settings"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` + ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` + QueryType pgtype.Text `db:"query_type" json:"query_type"` + Priority pgtype.Int4 `db:"priority" json:"priority"` + IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` } type DeviceCatalogs struct { @@ -373,6 +378,17 @@ type UnlinkedBooks struct { CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` } +type UserDashboardPreferences struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"` + CollectionOrder []string `db:"collection_order" json:"collection_order"` + ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` +} + type Users struct { ID pgtype.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 9366dcc..e353cba 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -16,7 +16,7 @@ const AddBookToCollection = `-- name: AddBookToCollection :one INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id) VALUES ($1, $2, $3) ON CONFLICT (collection_id, media_item_id) DO NOTHING -RETURNING id, collection_id, media_item_id, added_at, added_by_user_id +RETURNING id, collection_id, media_item_id, added_at, added_by_user_id, excluded ` type AddBookToCollectionParams struct { @@ -36,6 +36,7 @@ func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollecti &i.MediaItemID, &i.AddedAt, &i.AddedByUserID, + &i.Excluded, ) return i, err } @@ -232,7 +233,7 @@ const CreateCollection = `-- name: CreateCollection :one INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at +RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at ` type CreateCollectionParams struct { @@ -267,6 +268,10 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, ) return i, err @@ -1542,7 +1547,7 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF } const GetCollection = `-- name: GetCollection :one -SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at FROM collections WHERE id = $1 +SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE id = $1 ` // Get collection @@ -1558,13 +1563,17 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, ) return i, err } const GetCollectionItems = `-- name: GetCollectionItems :many -SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, mi.title, mi.author, mi.cover_image_path +SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, mi.title, mi.author, mi.cover_image_path FROM collection_items ci JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 @@ -1577,6 +1586,7 @@ type GetCollectionItemsRow struct { MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` + Excluded pgtype.Bool `db:"excluded" json:"excluded"` Title string `db:"title" json:"title"` Author pgtype.Text `db:"author" json:"author"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` @@ -1598,6 +1608,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU &i.MediaItemID, &i.AddedAt, &i.AddedByUserID, + &i.Excluded, &i.Title, &i.Author, &i.CoverImagePath, @@ -1614,7 +1625,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one SELECT - c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.created_at, + c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, COUNT(ci.id) as book_count FROM collections c LEFT JOIN collection_items ci ON c.id = ci.collection_id @@ -1623,16 +1634,20 @@ GROUP BY c.id ` type GetCollectionWithBookCountRow struct { - ID pgtype.UUID `db:"id" json:"id"` - UserID pgtype.UUID `db:"user_id" json:"user_id"` - Name string `db:"name" json:"name"` - Description pgtype.Text `db:"description" json:"description"` - Color pgtype.Text `db:"color" json:"color"` - Icon pgtype.Text `db:"icon" json:"icon"` - AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` - ViewSettings []byte `db:"view_settings" json:"view_settings"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - BookCount int64 `db:"book_count" json:"book_count"` + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` + ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` + QueryType pgtype.Text `db:"query_type" json:"query_type"` + Priority pgtype.Int4 `db:"priority" json:"priority"` + IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + BookCount int64 `db:"book_count" json:"book_count"` } // Get collection with book count @@ -1648,6 +1663,10 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, &i.BookCount, ) @@ -1655,7 +1674,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID } const GetCollectionsByUser = `-- name: GetCollectionsByUser :many -SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC +SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC ` // Get collections by user @@ -1677,6 +1696,10 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, ); err != nil { return nil, err @@ -1690,7 +1713,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) } const GetCollectionsForBook = `-- name: GetCollectionsForBook :many -SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.created_at +SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at FROM collections c JOIN collection_items ci ON c.id = ci.collection_id WHERE ci.media_item_id = $1 @@ -1715,6 +1738,10 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype. &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, ); err != nil { return nil, err @@ -6483,7 +6510,7 @@ SET auto_assign_rules = $6, view_settings = $7 WHERE id = $1 -RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at +RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at ` type UpdateCollectionParams struct { @@ -6517,6 +6544,10 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara &i.Icon, &i.AutoAssignRules, &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, &i.CreatedAt, ) return i, err