diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 52b1e8ae..c06f2e45 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -7,6 +7,7 @@ import ( "testing" _ "github.com/mattn/go-sqlite3" + "github.com/pressly/goose/v3" ) func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) { @@ -45,6 +46,116 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) { } } +func TestWatchlistCompletionDateFollowsCompletedStatus(t *testing.T) { + sqlDB := newMigratedTestDB(t) + defer closeTestDB(t, sqlDB) + + ctx := context.Background() + mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`) + mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Anime', 'image.jpg')`) + + queries := db.New(sqlDB) + upsertTestWatchlistStatus(ctx, t, queries, "completed") + completedAt, estimated := testWatchlistCompletion(ctx, t, sqlDB) + if !completedAt.Valid { + t.Fatalf("completed status should record completed_at") + } + if estimated { + t.Fatalf("new completion date should be exact") + } + + upsertTestWatchlistStatus(ctx, t, queries, "watching") + completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB) + if completedAt.Valid || estimated { + t.Fatalf("watching status completion = %v estimated=%v, want empty", completedAt, estimated) + } + + upsertTestWatchlistStatus(ctx, t, queries, "completed") + completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB) + if !completedAt.Valid || estimated { + t.Fatalf("re-completed status completion = %v estimated=%v, want exact date", completedAt, estimated) + } +} + +func TestCompletionDateMigrationMarksHistoricalEstimates(t *testing.T) { + sqlDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer closeTestDB(t, sqlDB) + sqlDB.SetMaxOpenConns(1) + + goose.SetBaseFS(migrationsFS) + goose.SetLogger(goose.NopLogger()) + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatalf("set goose dialect: %v", err) + } + if err := goose.UpTo(sqlDB, "migrations", 25); err != nil { + t.Fatalf("migrate to version 25: %v", err) + } + + ctx := context.Background() + mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`) + mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Audited', '1.jpg'), (2, 'Estimated', '2.jpg')`) + mustExecTestSQL(t, sqlDB, ` +INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at) +VALUES + ('entry-1', 'user-1', 1, 'completed', '2026-06-20 20:00:00'), + ('entry-2', 'user-1', 2, 'completed', '2026-06-21 21:00:00')`) + mustExecTestSQL(t, sqlDB, ` +INSERT INTO audit_log (id, occurred_at, user_id, action, resource_type, resource_id) +VALUES ('audit-1', '2026-06-19 19:00:00', 'user-1', 'watch_completed', 'anime', '1')`) + + if err := goose.UpTo(sqlDB, "migrations", 26); err != nil { + t.Fatalf("migrate to version 26: %v", err) + } + + assertHistoricalCompletion(ctx, t, sqlDB, 1, "2026-06-19T19:00:00Z", false) + assertHistoricalCompletion(ctx, t, sqlDB, 2, "2026-06-21T21:00:00Z", true) +} + +func mustExecTestSQL(t *testing.T, sqlDB *sql.DB, query string) { + t.Helper() + if _, err := sqlDB.ExecContext(context.Background(), query); err != nil { + t.Fatalf("execute test SQL: %v", err) + } +} + +func upsertTestWatchlistStatus(ctx context.Context, t *testing.T, queries *db.Queries, status string) { + t.Helper() + _, err := queries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + ID: "entry-1", + UserID: "user-1", + AnimeID: 1, + Status: status, + }) + if err != nil { + t.Fatalf("upsert %s watchlist entry: %v", status, err) + } +} + +func testWatchlistCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB) (sql.NullTime, bool) { + t.Helper() + var completedAt sql.NullTime + var estimated bool + if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = 'user-1' AND anime_id = 1`).Scan(&completedAt, &estimated); err != nil { + t.Fatalf("query completion date: %v", err) + } + return completedAt, estimated +} + +func assertHistoricalCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, wantTime string, wantEstimated bool) { + t.Helper() + var completedAt string + var estimated bool + if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE anime_id = ?`, animeID).Scan(&completedAt, &estimated); err != nil { + t.Fatalf("query completion for anime %d: %v", animeID, err) + } + if completedAt != wantTime || estimated != wantEstimated { + t.Fatalf("anime %d completion = %q estimated=%v, want %q estimated=%v", animeID, completedAt, estimated, wantTime, wantEstimated) + } +} + func TestCleanupExpiredJikanCache(t *testing.T) { sqlDB := newMigratedTestDB(t) defer closeTestDB(t, sqlDB) diff --git a/internal/database/migrations/026_add_watchlist_completion_dates.sql b/internal/database/migrations/026_add_watchlist_completion_dates.sql new file mode 100644 index 00000000..d3c9ac70 --- /dev/null +++ b/internal/database/migrations/026_add_watchlist_completion_dates.sql @@ -0,0 +1,32 @@ +-- +goose Up +ALTER TABLE watch_list_entry ADD COLUMN completed_at DATETIME; +ALTER TABLE watch_list_entry ADD COLUMN completed_at_estimated BOOLEAN NOT NULL DEFAULT 0; + +UPDATE watch_list_entry +SET completed_at = COALESCE( + ( + SELECT MAX(a.occurred_at) + FROM audit_log a + WHERE a.user_id = watch_list_entry.user_id + AND a.action = 'watch_completed' + AND a.resource_type = 'anime' + AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT) + ), + watch_list_entry.updated_at + ), + completed_at_estimated = CASE + WHEN EXISTS ( + SELECT 1 + FROM audit_log a + WHERE a.user_id = watch_list_entry.user_id + AND a.action = 'watch_completed' + AND a.resource_type = 'anime' + AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT) + ) THEN 0 + ELSE 1 + END +WHERE status = 'completed'; + +-- +goose Down +ALTER TABLE watch_list_entry DROP COLUMN completed_at_estimated; +ALTER TABLE watch_list_entry DROP COLUMN completed_at; diff --git a/internal/db/models.go b/internal/db/models.go index 1672321d..fbecd3ff 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -103,6 +103,38 @@ type JikanCache struct { CreatedAt time.Time `json:"created_at"` } +type RecommendationEvent struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID sql.NullInt64 `json:"anime_id"` + EventType string `json:"event_type"` + Source sql.NullString `json:"source"` + MetadataJson sql.NullString `json:"metadata_json"` + OccurredAt time.Time `json:"occurred_at"` + CreatedAt time.Time `json:"created_at"` +} + +type RecommendationImpression struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Rail string `json:"rail"` + Position int64 `json:"position"` + RequestID sql.NullString `json:"request_id"` + MetadataJson sql.NullString `json:"metadata_json"` + OccurredAt time.Time `json:"occurred_at"` + CreatedAt time.Time `json:"created_at"` +} + +type RecommendationProfileSnapshot struct { + UserID string `json:"user_id"` + ProfileJson string `json:"profile_json"` + SourceWindowStart sql.NullTime `json:"source_window_start"` + SourceWindowEnd sql.NullTime `json:"source_window_end"` + ComputedAt time.Time `json:"computed_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Session struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -131,13 +163,15 @@ type User struct { } type WatchListEntry struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + CompletedAt sql.NullTime `json:"completed_at"` + CompletedAtEstimated bool `json:"completed_at_estimated"` } diff --git a/internal/db/querier.go b/internal/db/querier.go index 515f3c9d..28600690 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -32,8 +32,8 @@ type Querier interface { GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error) GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error) GetJikanCache(ctx context.Context, key string) (string, error) - GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error) GetJikanCacheStale(ctx context.Context, key string) (string, error) + GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error) GetSession(ctx context.Context, id string) (Session, error) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error) diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 95f39cef..306186bf 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -68,12 +68,32 @@ RETURNING *; SELECT * FROM anime WHERE id = ? LIMIT 1; -- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) -VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at) +VALUES ( + sqlc.arg(id), + sqlc.arg(user_id), + sqlc.arg(anime_id), + sqlc.arg(status), + sqlc.arg(current_episode), + sqlc.arg(current_time_seconds), + CASE WHEN sqlc.arg(status) = 'completed' THEN CURRENT_TIMESTAMP END, + 0, + CURRENT_TIMESTAMP +) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, current_episode = excluded.current_episode, current_time_seconds = excluded.current_time_seconds, + completed_at = CASE + WHEN excluded.status != 'completed' THEN NULL + WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP) + ELSE CURRENT_TIMESTAMP + END, + completed_at_estimated = CASE + WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed' + THEN watch_list_entry.completed_at_estimated + ELSE 0 + END, updated_at = CURRENT_TIMESTAMP RETURNING *; @@ -148,7 +168,20 @@ WHERE user_id = ? AND anime_id = ? LIMIT 1; -- name: GetUserWatchList :many SELECT - e.*, + e.id, + e.user_id, + e.anime_id, + e.status, + e.created_at, + e.updated_at, + e.current_episode, + e.last_episode_at, + e.current_time_seconds, + e.completed_at, + e.completed_at_estimated, + c.current_episode AS playback_current_episode, + c.current_time_seconds AS playback_current_time_seconds, + c.updated_at AS playback_updated_at, a.title_original, a.title_english, a.title_japanese, @@ -156,6 +189,7 @@ SELECT a.airing FROM watch_list_entry e JOIN anime a ON e.anime_id = a.id +LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id WHERE e.user_id = ? ORDER BY e.updated_at DESC; diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index 68788458..30b2248d 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -246,6 +246,7 @@ func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) { if err != nil { return nil, err } + defer rows.Close() var items []string for rows.Next() { var data string @@ -318,6 +319,7 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe if err != nil { return nil, err } + defer rows.Close() var items []GetAnimeNeedingRelationSyncRow for rows.Next() { var i GetAnimeNeedingRelationSyncRow @@ -353,6 +355,7 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs if err != nil { return nil, err } + defer rows.Close() var items []AuditLog for rows.Next() { var i AuditLog @@ -380,6 +383,70 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs return items, nil } +const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many +SELECT + c.id, + c.user_id, + c.anime_id, + c.current_episode, + c.current_time_seconds, + c.duration_seconds, + c.created_at, + c.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.duration_seconds as anime_duration_seconds +FROM continue_watching_entry c +JOIN anime a ON c.anime_id = a.id +WHERE c.user_id = ? +ORDER BY c.updated_at DESC +LIMIT ? +` + +type GetContinueWatchingCarouselEntriesParams struct { + UserID string `json:"user_id"` + Limit int64 `json:"limit"` +} + +func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) { + rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetContinueWatchingEntriesRow + for rows.Next() { + var i GetContinueWatchingEntriesRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.CurrentEpisode, + &i.CurrentTimeSeconds, + &i.DurationSeconds, + &i.CreatedAt, + &i.UpdatedAt, + &i.TitleOriginal, + &i.TitleEnglish, + &i.TitleJapanese, + &i.ImageUrl, + &i.AnimeDurationSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many SELECT c.id, @@ -422,69 +489,7 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string) if err != nil { return nil, err } - var items []GetContinueWatchingEntriesRow - for rows.Next() { - var i GetContinueWatchingEntriesRow - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.AnimeID, - &i.CurrentEpisode, - &i.CurrentTimeSeconds, - &i.DurationSeconds, - &i.CreatedAt, - &i.UpdatedAt, - &i.TitleOriginal, - &i.TitleEnglish, - &i.TitleJapanese, - &i.ImageUrl, - &i.AnimeDurationSeconds, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many -SELECT - c.id, - c.user_id, - c.anime_id, - c.current_episode, - c.current_time_seconds, - c.duration_seconds, - c.created_at, - c.updated_at, - a.title_original, - a.title_english, - a.title_japanese, - a.image_url, - a.duration_seconds as anime_duration_seconds -FROM continue_watching_entry c -JOIN anime a ON c.anime_id = a.id -WHERE c.user_id = ? -ORDER BY c.updated_at DESC -LIMIT ? -` - -type GetContinueWatchingCarouselEntriesParams struct { - UserID string `json:"user_id"` - Limit int64 `json:"limit"` -} - -func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) { - rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit) - if err != nil { - return nil, err - } + defer rows.Close() var items []GetContinueWatchingEntriesRow for rows.Next() { var i GetContinueWatchingEntriesRow @@ -555,6 +560,7 @@ func (q *Queries) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]A if err != nil { return nil, err } + defer rows.Close() var items []AnimeFetchRetry for rows.Next() { var i AnimeFetchRetry @@ -639,6 +645,18 @@ func (q *Queries) GetJikanCache(ctx context.Context, key string) (string, error) return data, err } +const getJikanCacheStale = `-- name: GetJikanCacheStale :one +SELECT data FROM jikan_cache +WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1 +` + +func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) { + row := q.db.QueryRowContext(ctx, getJikanCacheStale, key) + var data string + err := row.Scan(&data) + return data, err +} + const getJikanCacheStats = `-- name: GetJikanCacheStats :one SELECT COUNT(*) AS total_rows, @@ -660,18 +678,6 @@ func (q *Queries) GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow return i, err } -const getJikanCacheStale = `-- name: GetJikanCacheStale :one -SELECT data FROM jikan_cache -WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1 -` - -func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) { - row := q.db.QueryRowContext(ctx, getJikanCacheStale, key) - var data string - err := row.Scan(&data) - return data, err -} - const getSession = `-- name: GetSession :one SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1 ` @@ -716,6 +722,7 @@ func (q *Queries) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Conte if err != nil { return nil, err } + defer rows.Close() var items []int64 for rows.Next() { var anime_id int64 @@ -792,6 +799,7 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU if err != nil { return nil, err } + defer rows.Close() var items []GetUpcomingSeasonsRow for rows.Next() { var i GetUpcomingSeasonsRow @@ -857,7 +865,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, const getUserWatchList = `-- name: GetUserWatchList :many SELECT - e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, + e.id, + e.user_id, + e.anime_id, + e.status, + e.created_at, + e.updated_at, + e.current_episode, + e.last_episode_at, + e.current_time_seconds, + e.completed_at, + e.completed_at_estimated, + c.current_episode AS playback_current_episode, + c.current_time_seconds AS playback_current_time_seconds, + c.updated_at AS playback_updated_at, a.title_original, a.title_english, a.title_japanese, @@ -865,25 +886,31 @@ SELECT a.airing FROM watch_list_entry e JOIN anime a ON e.anime_id = a.id +LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id WHERE e.user_id = ? ORDER BY e.updated_at DESC ` type GetUserWatchListRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + CompletedAt sql.NullTime `json:"completed_at"` + CompletedAtEstimated bool `json:"completed_at_estimated"` + PlaybackCurrentEpisode sql.NullInt64 `json:"playback_current_episode"` + PlaybackCurrentTimeSeconds sql.NullFloat64 `json:"playback_current_time_seconds"` + PlaybackUpdatedAt sql.NullTime `json:"playback_updated_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` } func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) { @@ -891,6 +918,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse if err != nil { return nil, err } + defer rows.Close() var items []GetUserWatchListRow for rows.Next() { var i GetUserWatchListRow @@ -904,6 +932,11 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse &i.CurrentEpisode, &i.LastEpisodeAt, &i.CurrentTimeSeconds, + &i.CompletedAt, + &i.CompletedAtEstimated, + &i.PlaybackCurrentEpisode, + &i.PlaybackCurrentTimeSeconds, + &i.PlaybackUpdatedAt, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, @@ -924,7 +957,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse } const getWatchListEntry = `-- name: GetWatchListEntry :one -SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry +SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1 ` @@ -946,13 +979,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa &i.CurrentEpisode, &i.LastEpisodeAt, &i.CurrentTimeSeconds, + &i.CompletedAt, + &i.CompletedAtEstimated, ) return i, err } const getWatchingAnime = `-- name: GetWatchingAnime :many SELECT - e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.completed_at, e.completed_at_estimated, a.title_original, a.title_english, a.title_japanese, @@ -965,20 +1000,22 @@ ORDER BY e.updated_at DESC ` type GetWatchingAnimeRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` - CurrentTimeSeconds float64 `json:"current_time_seconds"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + CompletedAt sql.NullTime `json:"completed_at"` + CompletedAtEstimated bool `json:"completed_at_estimated"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` } func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) { @@ -986,6 +1023,7 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat if err != nil { return nil, err } + defer rows.Close() var items []GetWatchingAnimeRow for rows.Next() { var i GetWatchingAnimeRow @@ -999,6 +1037,8 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat &i.CurrentEpisode, &i.LastEpisodeAt, &i.CurrentTimeSeconds, + &i.CompletedAt, + &i.CompletedAtEstimated, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, @@ -1362,14 +1402,34 @@ func (q *Queries) UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEp } const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) -VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at) +VALUES ( + ?1, + ?2, + ?3, + ?4, + ?5, + ?6, + CASE WHEN ?4 = 'completed' THEN CURRENT_TIMESTAMP END, + 0, + CURRENT_TIMESTAMP +) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, current_episode = excluded.current_episode, current_time_seconds = excluded.current_time_seconds, + completed_at = CASE + WHEN excluded.status != 'completed' THEN NULL + WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP) + ELSE CURRENT_TIMESTAMP + END, + completed_at_estimated = CASE + WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed' + THEN watch_list_entry.completed_at_estimated + ELSE 0 + END, updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds +RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated ` type UpsertWatchListEntryParams struct { @@ -1401,6 +1461,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE &i.CurrentEpisode, &i.LastEpisodeAt, &i.CurrentTimeSeconds, + &i.CompletedAt, + &i.CompletedAtEstimated, ) return i, err }