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.
This commit is contained in:
2026-02-19 20:52:21 -05:00
parent ceca81f098
commit 3af2fb0ba4
3 changed files with 113 additions and 28 deletions
+39 -1
View File
@@ -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 collections table (device-neutral collections)
CREATE TABLE IF NOT EXISTS collections ( CREATE TABLE IF NOT EXISTS collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
color VARCHAR(7), color VARCHAR(7),
icon VARCHAR(50), icon VARCHAR(50),
auto_assign_rules JSONB, auto_assign_rules JSONB,
view_settings 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(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, name) UNIQUE(user_id, name)
); );
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); 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 collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items ( CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
excluded BOOLEAN DEFAULT false,
UNIQUE(collection_id, media_item_id) 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_collection ON collection_items(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_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 device_shelf_mappings table (map Bookhoard collections to device-specific shelf names)
CREATE TABLE IF NOT EXISTS device_shelf_mappings ( CREATE TABLE IF NOT EXISTS device_shelf_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+25 -9
View File
@@ -14,18 +14,23 @@ type CollectionItems struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
} }
type Collections struct { type Collections struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Description pgtype.Text `db:"description" json:"description"` Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"` Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"` Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` 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 { type DeviceCatalogs struct {
@@ -373,6 +378,17 @@ type UnlinkedBooks struct {
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` 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 { type Users struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
+49 -18
View File
@@ -16,7 +16,7 @@ const AddBookToCollection = `-- name: AddBookToCollection :one
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id) INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (collection_id, media_item_id) DO NOTHING 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 { type AddBookToCollectionParams struct {
@@ -36,6 +36,7 @@ func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollecti
&i.MediaItemID, &i.MediaItemID,
&i.AddedAt, &i.AddedAt,
&i.AddedByUserID, &i.AddedByUserID,
&i.Excluded,
) )
return i, err 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) INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7) 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 { type CreateCollectionParams struct {
@@ -267,6 +268,10 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -1542,7 +1547,7 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
} }
const GetCollection = `-- name: GetCollection :one 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 // Get collection
@@ -1558,13 +1563,17 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
} }
const GetCollectionItems = `-- name: GetCollectionItems :many 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 FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1 WHERE ci.collection_id = $1
@@ -1577,6 +1586,7 @@ type GetCollectionItemsRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` 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"` Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"` Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` 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.MediaItemID,
&i.AddedAt, &i.AddedAt,
&i.AddedByUserID, &i.AddedByUserID,
&i.Excluded,
&i.Title, &i.Title,
&i.Author, &i.Author,
&i.CoverImagePath, &i.CoverImagePath,
@@ -1614,7 +1625,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT 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 COUNT(ci.id) as book_count
FROM collections c FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id LEFT JOIN collection_items ci ON c.id = ci.collection_id
@@ -1623,16 +1634,20 @@ GROUP BY c.id
` `
type GetCollectionWithBookCountRow struct { type GetCollectionWithBookCountRow struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Description pgtype.Text `db:"description" json:"description"` Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"` Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"` Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
BookCount int64 `db:"book_count" json:"book_count"` 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 // Get collection with book count
@@ -1648,6 +1663,10 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
&i.BookCount, &i.BookCount,
) )
@@ -1655,7 +1674,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
} }
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many 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 // Get collections by user
@@ -1677,6 +1696,10 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -1690,7 +1713,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
} }
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many 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 FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1 WHERE ci.media_item_id = $1
@@ -1715,6 +1738,10 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -6483,7 +6510,7 @@ SET
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7 view_settings = $7
WHERE id = $1 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 { type UpdateCollectionParams struct {
@@ -6517,6 +6544,10 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err