From 06134da6df063192c9b0ccfbd62d4359139a9512 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Feb 2026 04:01:49 +0000 Subject: [PATCH 1/2] fix(migrations): register orphaned migrations in journal and fix user_watch_entries table creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: The drizzle-orm migrator tracks applied migrations by timestamp. If a database had a migration applied from another branch (feat/improve-subscription-workflow) with a higher timestamp (idx 55, when=1769741240595), all migrations with lower timestamps get permanently skipped — including the tearful_vertigo migration (idx 54, when=1768684257805) that creates the user_watch_entries table. The migrator would report 'migrations applied successfully!' but create zero new tables. Fix: - Register the previously orphaned add_user_stats_indexes migration in the journal at idx 55 with when=1770120000000 (higher than any stale entry) - Add CREATE TABLE IF NOT EXISTS user_watch_entries as a safety net in this migration, so even if tearful_vertigo (idx 54) was skipped, the table is still created - Remove orphaned SQL files not tracked in the journal: - 20260117160000_create_user_watch_entries_table.sql (manual duplicate) - 20241110183817_watchlist_item_media_type.sql (old orphaned file) - Fix CREATE INDEX CONCURRENTLY to CREATE INDEX IF NOT EXISTS (CONCURRENTLY cannot run inside a transaction, which drizzle-orm uses for migrations) Tested scenarios: - Fresh database: all migrations apply correctly - Stale entry from other branch: user_watch_entries is created by idx 55 Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- ...241110183817_watchlist_item_media_type.sql | 1 - ...160000_create_user_watch_entries_table.sql | 10 - .../20260203120000_add_user_stats_indexes.sql | 53 +- .../meta/20260203120000_snapshot.json | 1644 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + 5 files changed, 1683 insertions(+), 32 deletions(-) delete mode 100644 apps/backend/src/db/migrations/20241110183817_watchlist_item_media_type.sql delete mode 100644 apps/backend/src/db/migrations/20260117160000_create_user_watch_entries_table.sql create mode 100644 apps/backend/src/db/migrations/meta/20260203120000_snapshot.json diff --git a/apps/backend/src/db/migrations/20241110183817_watchlist_item_media_type.sql b/apps/backend/src/db/migrations/20241110183817_watchlist_item_media_type.sql deleted file mode 100644 index 67156804..00000000 --- a/apps/backend/src/db/migrations/20241110183817_watchlist_item_media_type.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "watchlist_items" ADD COLUMN "media_type" "media_type" NOT NULL; \ No newline at end of file diff --git a/apps/backend/src/db/migrations/20260117160000_create_user_watch_entries_table.sql b/apps/backend/src/db/migrations/20260117160000_create_user_watch_entries_table.sql deleted file mode 100644 index 85d57f83..00000000 --- a/apps/backend/src/db/migrations/20260117160000_create_user_watch_entries_table.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Create user_watch_entries table for tracking rewatch history -CREATE TABLE IF NOT EXISTS "user_watch_entries" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "user_item_id" uuid NOT NULL REFERENCES "user_items"("id") ON DELETE CASCADE, - "watched_at" timestamp DEFAULT now() NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); - --- Create index for faster lookups by user_item_id -CREATE INDEX IF NOT EXISTS "user_watch_entries_user_item_idx" ON "user_watch_entries" ("user_item_id"); diff --git a/apps/backend/src/db/migrations/20260203120000_add_user_stats_indexes.sql b/apps/backend/src/db/migrations/20260203120000_add_user_stats_indexes.sql index 48f3e576..a9cd017c 100644 --- a/apps/backend/src/db/migrations/20260203120000_add_user_stats_indexes.sql +++ b/apps/backend/src/db/migrations/20260203120000_add_user_stats_indexes.sql @@ -1,29 +1,40 @@ --- Performance indexes for user statistics queries --- These indexes significantly improve the performance of stats aggregations +-- Ensure user_watch_entries table exists (safety net in case previous migration was skipped) +CREATE TABLE IF NOT EXISTS "user_watch_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_item_id" uuid NOT NULL, + "watched_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +);--> statement-breakpoint + +DO $$ BEGIN + ALTER TABLE "user_watch_entries" + ADD CONSTRAINT "user_watch_entries_user_item_id_user_items_id_fk" + FOREIGN KEY ("user_item_id") REFERENCES "public"."user_items"("id") + ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint --- Index for user_items: covers queries filtering by user_id + status (most common pattern) -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_user_items_user_status" - ON "user_items" ("user_id", "status"); +CREATE INDEX IF NOT EXISTS "user_watch_entries_user_item_idx" ON "user_watch_entries" USING btree ("user_item_id");--> statement-breakpoint + +-- Performance indexes for user statistics queries +CREATE INDEX IF NOT EXISTS "idx_user_items_user_status" + ON "user_items" ("user_id", "status");--> statement-breakpoint --- Index for user_items: covers queries filtering by user_id + media_type + status -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_user_items_user_media_status" - ON "user_items" ("user_id", "media_type", "status"); +CREATE INDEX IF NOT EXISTS "idx_user_items_user_media_status" + ON "user_items" ("user_id", "media_type", "status");--> statement-breakpoint --- Index for user_episodes: covers most-watched series query (GROUP BY tmdb_id WHERE user_id = ?) -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_user_episodes_user_id" - ON "user_episodes" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_user_episodes_user_id" + ON "user_episodes" ("user_id");--> statement-breakpoint --- Index for user_episodes: covers runtime aggregations -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_user_episodes_user_tmdb" - ON "user_episodes" ("user_id", "tmdb_id"); +CREATE INDEX IF NOT EXISTS "idx_user_episodes_user_tmdb" + ON "user_episodes" ("user_id", "tmdb_id");--> statement-breakpoint --- Index for reviews: covers best reviews query (rating = 5 for a user) -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_reviews_user_rating" - ON "reviews" ("user_id", "rating"); +CREATE INDEX IF NOT EXISTS "idx_reviews_user_rating" + ON "reviews" ("user_id", "rating");--> statement-breakpoint --- Index for followers: covers follower/following count queries -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_followers_follower_id" - ON "followers" ("follower_id"); +CREATE INDEX IF NOT EXISTS "idx_followers_follower_id" + ON "followers" ("follower_id");--> statement-breakpoint -CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_followers_followed_id" +CREATE INDEX IF NOT EXISTS "idx_followers_followed_id" ON "followers" ("followed_id"); diff --git a/apps/backend/src/db/migrations/meta/20260203120000_snapshot.json b/apps/backend/src/db/migrations/meta/20260203120000_snapshot.json new file mode 100644 index 00000000..e2daf7eb --- /dev/null +++ b/apps/backend/src/db/migrations/meta/20260203120000_snapshot.json @@ -0,0 +1,1644 @@ +{ + "id": "ebdd1a9d-adf7-4575-8f2d-51ff1a1c7123", + "prevId": "cae7945f-8a8c-44db-a3fe-3309df133a97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.followers": { + "name": "followers", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followed_id": { + "name": "followed_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "followers_follower_id_users_id_fk": { + "name": "followers_follower_id_users_id_fk", + "tableFrom": "followers", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "followers_followed_id_users_id_fk": { + "name": "followers_followed_id_users_id_fk", + "tableFrom": "followers", + "tableTo": "users", + "columnsFrom": [ + "followed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "followers_followed_id_follower_id_pk": { + "name": "followers_followed_id_follower_id_pk", + "columns": [ + "followed_id", + "follower_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_movies": { + "name": "import_movies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "import_id": { + "name": "import_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "item_status": { + "name": "item_status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "import_status": { + "name": "import_status", + "type": "import_item_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "TMDB_ID": { + "name": "TMDB_ID", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "__metadata": { + "name": "__metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "import_movies_import_id_user_imports_id_fk": { + "name": "import_movies_import_id_user_imports_id_fk", + "tableFrom": "import_movies", + "tableTo": "user_imports", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_series": { + "name": "import_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "import_id": { + "name": "import_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "item_status": { + "name": "item_status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "import_status": { + "name": "import_status", + "type": "import_item_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "TMDB_ID": { + "name": "TMDB_ID", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "watched_episodes": { + "name": "watched_episodes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_episodes": { + "name": "series_episodes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "__metadata": { + "name": "__metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "import_series_import_id_user_imports_id_fk": { + "name": "import_series_import_id_user_imports_id_fk", + "tableFrom": "import_series", + "tableTo": "user_imports", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "like_entity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_entity_id": { + "name": "idx_entity_id", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_user_id_users_id_fk": { + "name": "likes_user_id_users_id_fk", + "tableFrom": "likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_items": { + "name": "list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_items_id_list_id_pk": { + "name": "list_items_id_list_id_pk", + "columns": [ + "id", + "list_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "list_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lists_user_id_users_id_fk": { + "name": "lists_user_id_users_id_fk", + "tableFrom": "lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_tokens": { + "name": "magic_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "token_user_id_idx": { + "name": "token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "magic_tokens_user_id_users_id_fk": { + "name": "magic_tokens_user_id_users_id_fk", + "tableFrom": "magic_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_replies": { + "name": "review_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reply": { + "name": "reply", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "review_replies_user_id_users_id_fk": { + "name": "review_replies_user_id_users_id_fk", + "tableFrom": "review_replies", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_replies_review_id_reviews_id_fk": { + "name": "review_replies_review_id_reviews_id_fk", + "tableFrom": "review_replies", + "tableTo": "reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "review": { + "name": "review", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "has_spoilers": { + "name": "has_spoilers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "language": { + "name": "language", + "type": "languages", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reviews_user_id_users_id_fk": { + "name": "reviews_user_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_links": { + "name": "social_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "social_platforms", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_links_user_id_users_id_fk": { + "name": "social_links_user_id_users_id_fk", + "tableFrom": "social_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_platform_unique": { + "name": "user_platform_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "subscription_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "active_subscription_idx": { + "name": "active_subscription_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"subscriptions\".\"status\" = $1", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_activities": { + "name": "user_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "activity_type": { + "name": "activity_type", + "type": "activity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "like_entity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_activity_idx": { + "name": "user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_activities_user_id_users_id_fk": { + "name": "user_activities_user_id_users_id_fk", + "tableFrom": "user_activities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_episodes": { + "name": "user_episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "user_episodes_user_id_users_id_fk": { + "name": "user_episodes_user_id_users_id_fk", + "tableFrom": "user_episodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_episode_unique": { + "name": "user_episode_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "season_number", + "episode_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_imports": { + "name": "user_imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "items_count": { + "name": "items_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "import_status": { + "name": "import_status", + "type": "import_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "providers_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_imports_user_id_users_id_fk": { + "name": "user_imports_user_id_users_id_fk", + "tableFrom": "user_imports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_items": { + "name": "user_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_items_user_id_users_id_fk": { + "name": "user_items_user_id_users_id_fk", + "tableFrom": "user_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_items_userid_tmdbid_media_type_unique": { + "name": "user_items_userid_tmdbid_media_type_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "media_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_watch_entries": { + "name": "user_watch_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_item_id": { + "name": "user_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_watch_entries_user_item_idx": { + "name": "user_watch_entries_user_item_idx", + "columns": [ + { + "expression": "user_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_watch_entries_user_item_id_user_items_id_fk": { + "name": "user_watch_entries_user_item_id_user_items_id_fk", + "tableFrom": "user_watch_entries", + "tableTo": "user_items", + "columnsFrom": [ + "user_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "banner_url": { + "name": "banner_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "biography": { + "name": "biography", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "username_lower_idx": { + "name": "username_lower_idx", + "columns": [ + { + "expression": "LOWER(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_lower_idx": { + "name": "email_lower_idx", + "columns": [ + { + "expression": "LOWER(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "watch_providers_ids": { + "name": "watch_providers_ids", + "type": "integer[]", + "primaryKey": false, + "notNull": false + }, + "watch_region": { + "name": "watch_region", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_type": { + "name": "activity_type", + "schema": "public", + "values": [ + "CREATE_LIST", + "ADD_ITEM", + "DELETE_ITEM", + "LIKE_REVIEW", + "LIKE_REPLY", + "LIKE_LIST", + "CREATE_REVIEW", + "CREATE_REPLY", + "FOLLOW_USER", + "WATCH_EPISODE", + "CHANGE_STATUS", + "CREATE_ACCOUNT" + ] + }, + "public.import_item_status": { + "name": "import_item_status", + "schema": "public", + "values": [ + "COMPLETED", + "FAILED", + "NOT_STARTED" + ] + }, + "public.import_status_enum": { + "name": "import_status_enum", + "schema": "public", + "values": [ + "PARTIAL", + "COMPLETED", + "FAILED", + "NOT_STARTED" + ] + }, + "public.languages": { + "name": "languages", + "schema": "public", + "values": [ + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "de-DE", + "pt-BR", + "ja-JP" + ] + }, + "public.like_entity": { + "name": "like_entity", + "schema": "public", + "values": [ + "REVIEW", + "REPLY", + "LIST" + ] + }, + "public.list_visibility": { + "name": "list_visibility", + "schema": "public", + "values": [ + "PUBLIC", + "NETWORK", + "PRIVATE" + ] + }, + "public.media_type": { + "name": "media_type", + "schema": "public", + "values": [ + "TV_SHOW", + "MOVIE" + ] + }, + "public.providers_enum": { + "name": "providers_enum", + "schema": "public", + "values": [ + "MY_ANIME_LIST", + "LETTERBOXD" + ] + }, + "public.social_platforms": { + "name": "social_platforms", + "schema": "public", + "values": [ + "INSTAGRAM", + "TIKTOK", + "YOUTUBE", + "X" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "WATCHLIST", + "WATCHED", + "WATCHING", + "DROPPED" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "ACTIVE", + "CANCELED", + "EXPIRED", + "PENDING_CANCELLATION" + ] + }, + "public.subscription_type": { + "name": "subscription_type", + "schema": "public", + "values": [ + "MEMBER", + "PRO" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/src/db/migrations/meta/_journal.json b/apps/backend/src/db/migrations/meta/_journal.json index eef9c295..1bd1a955 100644 --- a/apps/backend/src/db/migrations/meta/_journal.json +++ b/apps/backend/src/db/migrations/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768684257805, "tag": "20260117211057_tearful_vertigo", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1770120000000, + "tag": "20260203120000_add_user_stats_indexes", + "breakpoints": true } ] } \ No newline at end of file From 2c0d0537f34075b7bc1801f1aabbae318880594a Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 11 Feb 2026 12:09:20 -0300 Subject: [PATCH 2/2] Enhance localization and user profile features - Added new localization strings for "Information" and "Name" across multiple languages. - Updated onboarding subtitles to include "Watching" status. - Modified AuthService to include a new displayName parameter in the updateUser method. - Introduced EditNameView for users to update their display name. - Enhanced EditProfileView with tabbed navigation for information, preferences, and settings sections. Co-authored-by: [Your Name] --- .../Plotwist.xcodeproj/project.pbxproj | 4 +- .../Plotwist/Localization/Strings.swift | 38 ++++- .../Plotwist/Services/AuthService.swift | 6 +- .../Plotwist/Services/ImageCache.swift | 5 + .../Plotwist/Services/OnboardingService.swift | 9 +- .../OnboardingAddTitlesContent.swift | 34 +++- .../Plotwist/Views/Profile/EditNameView.swift | 142 ++++++++++++++++ .../Views/Profile/EditProfileView.swift | 159 ++++++++++++++++-- 8 files changed, 371 insertions(+), 26 deletions(-) create mode 100644 apps/ios/Plotwist/Plotwist/Views/Profile/EditNameView.swift diff --git a/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj b/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj index ecae53c4..26cef058 100644 --- a/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj +++ b/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj @@ -268,7 +268,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Plotwist/Plotwist.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 54XPVTP5PA; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -297,7 +297,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Plotwist/Plotwist.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = 54XPVTP5PA; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index a16f4849..7f23b3f4 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -146,8 +146,10 @@ enum L10n { memberSince: "Member since", editProfile: "Edit", accountData: "Account", + information: "Information", preferences: "Preferences", editPicture: "Edit picture", + name: "Name", username: "Username", region: "Region", streamingServices: "Streaming Services", @@ -207,9 +209,10 @@ enum L10n { onboardingAddTitlesTitle: "Now the fun part! 🎬", onboardingAddTitlesSubtitle: "Add 5 titles to your watchlist", onboardingDiscoverTitle: "Swipe to build\nyour collection 🍿", - onboardingDiscoverSubtitle: "Right to save, left to skip, up if you've already watched", + onboardingDiscoverSubtitle: "Right to save, left to skip, up if watched, down if watching", onboardingWantToWatch: "Want to watch", onboardingAlreadyWatched: "Already watched", + onboardingWatching: "Watching", onboardingNotInterested: "Not interested", onboardingNoMoreItems: "You've seen them all!", onboardingReadyToContinue: "Ready to continue!", @@ -405,8 +408,10 @@ enum L10n { memberSince: "Membro desde", editProfile: "Editar", accountData: "Conta", + information: "Informações", preferences: "Preferências", editPicture: "Editar foto", + name: "Nome", username: "Nome de usuário", region: "Região", streamingServices: "Serviços de Streaming", @@ -466,9 +471,10 @@ enum L10n { onboardingAddTitlesTitle: "Agora a parte divertida! 🎬", onboardingAddTitlesSubtitle: "Adicione 5 títulos à sua watchlist", onboardingDiscoverTitle: "Deslize para montar\nsua coleção 🍿", - onboardingDiscoverSubtitle: "Direita para salvar, esquerda para pular, cima se já assistiu", + onboardingDiscoverSubtitle: "Direita para salvar, esquerda para pular, cima se já assistiu, baixo se assistindo", onboardingWantToWatch: "Quero assistir", onboardingAlreadyWatched: "Já assisti", + onboardingWatching: "Assistindo", onboardingNotInterested: "Não tenho interesse", onboardingNoMoreItems: "Você viu todos!", onboardingReadyToContinue: "Pronto para continuar!", @@ -664,8 +670,10 @@ enum L10n { memberSince: "Miembro desde", editProfile: "Editar", accountData: "Cuenta", + information: "Información", preferences: "Preferencias", editPicture: "Editar foto", + name: "Nombre", username: "Nombre de usuario", region: "Región", streamingServices: "Servicios de Streaming", @@ -725,9 +733,10 @@ enum L10n { onboardingAddTitlesTitle: "¡Ahora lo divertido! 🎬", onboardingAddTitlesSubtitle: "Agrega 5 títulos a tu watchlist", onboardingDiscoverTitle: "Desliza para crear\ntu colección 🍿", - onboardingDiscoverSubtitle: "Derecha para guardar, izquierda para pasar, arriba si ya lo viste", + onboardingDiscoverSubtitle: "Derecha para guardar, izquierda para pasar, arriba si ya lo viste, abajo si lo estás viendo", onboardingWantToWatch: "Quiero ver", onboardingAlreadyWatched: "Ya lo vi", + onboardingWatching: "Viendo", onboardingNotInterested: "No me interesa", onboardingNoMoreItems: "¡Los viste todos!", onboardingReadyToContinue: "¡Listo para continuar!", @@ -923,8 +932,10 @@ enum L10n { memberSince: "Membre depuis", editProfile: "Modifier", accountData: "Compte", + information: "Informations", preferences: "Préférences", editPicture: "Modifier la photo", + name: "Nom", username: "Nom d'utilisateur", region: "Région", streamingServices: "Services de Streaming", @@ -984,9 +995,10 @@ enum L10n { onboardingAddTitlesTitle: "Maintenant le fun ! 🎬", onboardingAddTitlesSubtitle: "Ajoute 5 titres à ta watchlist", onboardingDiscoverTitle: "Glisse pour créer\nta collection 🍿", - onboardingDiscoverSubtitle: "Droite pour sauver, gauche pour passer, haut si déjà vu", + onboardingDiscoverSubtitle: "Droite pour sauver, gauche pour passer, haut si déjà vu, bas si en cours", onboardingWantToWatch: "Envie de voir", onboardingAlreadyWatched: "Déjà vu", + onboardingWatching: "En cours", onboardingNotInterested: "Pas intéressé", onboardingNoMoreItems: "Tu as tout vu !", onboardingReadyToContinue: "Prêt à continuer !", @@ -1182,8 +1194,10 @@ enum L10n { memberSince: "Mitglied seit", editProfile: "Bearbeiten", accountData: "Konto", + information: "Informationen", preferences: "Einstellungen", editPicture: "Bild bearbeiten", + name: "Name", username: "Benutzername", region: "Region", streamingServices: "Streaming-Dienste", @@ -1243,9 +1257,10 @@ enum L10n { onboardingAddTitlesTitle: "Jetzt wird's spaßig! 🎬", onboardingAddTitlesSubtitle: "Füge 5 Titel zu deiner Watchlist hinzu", onboardingDiscoverTitle: "Wische, um deine\nSammlung aufzubauen 🍿", - onboardingDiscoverSubtitle: "Rechts zum Speichern, links zum Überspringen, hoch wenn schon gesehen", + onboardingDiscoverSubtitle: "Rechts zum Speichern, links zum Überspringen, hoch wenn gesehen, runter wenn du es gerade schaust", onboardingWantToWatch: "Will ich sehen", onboardingAlreadyWatched: "Schon gesehen", + onboardingWatching: "Schaue ich", onboardingNotInterested: "Kein Interesse", onboardingNoMoreItems: "Du hast alle gesehen!", onboardingReadyToContinue: "Bereit weiterzumachen!", @@ -1441,8 +1456,10 @@ enum L10n { memberSince: "Membro dal", editProfile: "Modifica", accountData: "Account", + information: "Informazioni", preferences: "Preferenze", editPicture: "Modifica foto", + name: "Nome", username: "Nome utente", region: "Regione", streamingServices: "Servizi di Streaming", @@ -1502,9 +1519,10 @@ enum L10n { onboardingAddTitlesTitle: "Ora il bello! 🎬", onboardingAddTitlesSubtitle: "Aggiungi 5 titoli alla tua watchlist", onboardingDiscoverTitle: "Scorri per creare\nla tua collezione 🍿", - onboardingDiscoverSubtitle: "Destra per salvare, sinistra per saltare, su se l'hai già visto", + onboardingDiscoverSubtitle: "Destra per salvare, sinistra per saltare, su se già visto, giù se lo stai guardando", onboardingWantToWatch: "Voglio vedere", onboardingAlreadyWatched: "Già visto", + onboardingWatching: "Sto guardando", onboardingNotInterested: "Non mi interessa", onboardingNoMoreItems: "Li hai visti tutti!", onboardingReadyToContinue: "Pronto a continuare!", @@ -1699,8 +1717,10 @@ enum L10n { memberSince: "メンバー登録日", editProfile: "編集", accountData: "アカウント", + information: "基本情報", preferences: "設定", editPicture: "写真を編集", + name: "名前", username: "ユーザー名", region: "地域", streamingServices: "ストリーミング", @@ -1760,9 +1780,10 @@ enum L10n { onboardingAddTitlesTitle: "お楽しみの時間!🎬", onboardingAddTitlesSubtitle: "ウォッチリストに5つ追加しよう", onboardingDiscoverTitle: "スワイプして\nコレクションを作ろう 🍿", - onboardingDiscoverSubtitle: "右で保存、左でスキップ、上は視聴済み", + onboardingDiscoverSubtitle: "右で保存、左でスキップ、上は視聴済み、下は視聴中", onboardingWantToWatch: "見たい", onboardingAlreadyWatched: "見た", + onboardingWatching: "視聴中", onboardingNotInterested: "興味なし", onboardingNoMoreItems: "全部見たよ!", onboardingReadyToContinue: "準備OK!", @@ -1976,8 +1997,10 @@ struct Strings { let memberSince: String let editProfile: String let accountData: String + let information: String let preferences: String let editPicture: String + let name: String let username: String let region: String let streamingServices: String @@ -2043,6 +2066,7 @@ struct Strings { let onboardingDiscoverSubtitle: String let onboardingWantToWatch: String let onboardingAlreadyWatched: String + let onboardingWatching: String let onboardingNotInterested: String let onboardingNoMoreItems: String let onboardingReadyToContinue: String diff --git a/apps/ios/Plotwist/Plotwist/Services/AuthService.swift b/apps/ios/Plotwist/Plotwist/Services/AuthService.swift index 3e227330..c54f098b 100644 --- a/apps/ios/Plotwist/Plotwist/Services/AuthService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/AuthService.swift @@ -150,8 +150,8 @@ class AuthService { // MARK: - Update User func updateUser( - username: String? = nil, avatarUrl: String? = nil, bannerUrl: String? = nil, - biography: String? = nil + displayName: String? = nil, username: String? = nil, avatarUrl: String? = nil, + bannerUrl: String? = nil, biography: String? = nil ) async throws -> User { guard let token = UserDefaults.standard.string(forKey: "token"), let url = URL(string: "\(API.baseURL)/user") @@ -165,6 +165,7 @@ class AuthService { request.setValue("application/json", forHTTPHeaderField: "Content-Type") var body: [String: Any] = [:] + if let displayName { body["displayName"] = displayName } if let username { body["username"] = username } if let avatarUrl { body["avatarUrl"] = avatarUrl } if let bannerUrl { body["bannerUrl"] = bannerUrl } @@ -326,6 +327,7 @@ class AuthService { // MARK: - Models struct User: Codable { let id: String + let displayName: String? let username: String let email: String let avatarUrl: String? diff --git a/apps/ios/Plotwist/Plotwist/Services/ImageCache.swift b/apps/ios/Plotwist/Plotwist/Services/ImageCache.swift index 5fb8f15e..a23881f8 100644 --- a/apps/ios/Plotwist/Plotwist/Services/ImageCache.swift +++ b/apps/ios/Plotwist/Plotwist/Services/ImageCache.swift @@ -192,6 +192,11 @@ struct CachedAsyncImage: View { self.animated = animated self.content = content self.placeholder = placeholder + // Pre-populate from cache so image is available from the first frame + // (avoids blank placeholders during view transitions) + if let url { + _loadedImage = State(initialValue: ImageCache.shared.image(for: url)) + } } var body: some View { diff --git a/apps/ios/Plotwist/Plotwist/Services/OnboardingService.swift b/apps/ios/Plotwist/Plotwist/Services/OnboardingService.swift index 8917e0cc..5b9753f0 100644 --- a/apps/ios/Plotwist/Plotwist/Services/OnboardingService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/OnboardingService.swift @@ -138,7 +138,7 @@ struct LocalSavedTitle: Codable, Identifiable { let mediaType: String // "movie" or "tv" let title: String let posterPath: String? - let status: String // "WATCHLIST" or "WATCHED" + let status: String // "WATCHLIST", "WATCHED", or "WATCHING" let savedAt: Date var posterURL: URL? { @@ -361,7 +361,12 @@ class OnboardingService: ObservableObject { for title in localSavedTitles { do { let apiMediaType = title.mediaType == "movie" ? "MOVIE" : "TV_SHOW" - let status: UserItemStatus = title.status == "WATCHED" ? .watched : .watchlist + let status: UserItemStatus + switch title.status { + case "WATCHED": status = .watched + case "WATCHING": status = .watching + default: status = .watchlist + } _ = try await UserItemService.shared.upsertUserItem( tmdbId: title.tmdbId, diff --git a/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift b/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift index 2551bbd5..8667215b 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift @@ -66,7 +66,7 @@ struct OnboardingAddTitlesContent: View { DeckStack( deck, option: Option( - allowedDirections: [.left, .top, .right], + allowedDirections: [.left, .top, .right, .bottom], numberOfVisibleCards: 3, maximumRotationOfCard: 12, judgmentThreshold: 120 @@ -241,6 +241,13 @@ struct OnboardingAddTitlesContent: View { iconColor: .green, text: strings.onboardingAlreadyWatched ) + case .bottom: + // Watching + swipeIndicatorPill( + icon: "play.circle.fill", + iconColor: .blue, + text: strings.onboardingWatching + ) default: EmptyView() } @@ -310,6 +317,28 @@ struct OnboardingAddTitlesContent: View { .cornerRadius(10) } + // Watching (down) + Button(action: { + if let targetID = deck.targetID { + deck.swipe(to: .bottom, id: targetID) + } + }) { + HStack(spacing: 6) { + Image(systemName: "play.circle.fill") + .font(.system(size: 13)) + .foregroundColor(.blue) + + Text(strings.onboardingWatching) + .font(.footnote.weight(.medium)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.appInputFilled) + .cornerRadius(10) + } + // Not interested (left) - last Button(action: { if let targetID = deck.targetID { @@ -352,6 +381,9 @@ struct OnboardingAddTitlesContent: View { case .top: // Already watched addTitle(item, status: "WATCHED") + case .bottom: + // Watching + addTitle(item, status: "WATCHING") case .left: // Not interested - just skip break diff --git a/apps/ios/Plotwist/Plotwist/Views/Profile/EditNameView.swift b/apps/ios/Plotwist/Plotwist/Views/Profile/EditNameView.swift new file mode 100644 index 00000000..f7eb071f --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Profile/EditNameView.swift @@ -0,0 +1,142 @@ +// +// EditNameView.swift +// Plotwist +// + +import SwiftUI + +struct EditNameView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var systemColorScheme + @ObservedObject private var themeManager = ThemeManager.shared + @State private var strings = L10n.current + @State private var displayName: String + @State private var isLoading = false + @State private var error: String? + + let currentDisplayName: String? + + init(currentDisplayName: String?) { + self.currentDisplayName = currentDisplayName + _displayName = State(initialValue: currentDisplayName ?? "") + } + + private var effectiveColorScheme: ColorScheme { + themeManager.current.colorScheme ?? systemColorScheme + } + + private var hasChanges: Bool { + displayName != (currentDisplayName ?? "") + } + + private var canSave: Bool { + hasChanges + } + + var body: some View { + ZStack { + Color.appBackgroundAdaptive.ignoresSafeArea() + + VStack(spacing: 0) { + headerView + contentView + } + } + .navigationBarHidden(true) + .preferredColorScheme(effectiveColorScheme) + .onReceive(NotificationCenter.default.publisher(for: .languageChanged)) { _ in + strings = L10n.current + } + } + + private var headerView: some View { + VStack(spacing: 0) { + HStack { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + .frame(width: 40, height: 40) + .background(Color.appInputFilled) + .clipShape(Circle()) + } + + Spacer() + + Text(strings.name) + .font(.title3.bold()) + .foregroundColor(.appForegroundAdaptive) + + Spacer() + + saveButton + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + + Rectangle() + .fill(Color.appBorderAdaptive.opacity(0.5)) + .frame(height: 1) + } + } + + private var saveButton: some View { + Button { + Task { await saveName() } + } label: { + if isLoading { + ProgressView() + .tint(.appBackgroundAdaptive) + .frame(width: 40, height: 40) + .background(Color.appForegroundAdaptive) + .clipShape(Circle()) + } else { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(canSave ? .appBackgroundAdaptive : .appMutedForegroundAdaptive) + .frame(width: 40, height: 40) + .background(canSave ? Color.appForegroundAdaptive : Color.clear) + .clipShape(Circle()) + } + } + .disabled(!canSave || isLoading) + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 8) { + Text(strings.name) + .font(.subheadline.weight(.medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + TextField(strings.onboardingNamePlaceholder, text: $displayName) + .autocorrectionDisabled() + .padding(12) + .background(Color.appInputFilled) + .cornerRadius(12) + + if let error { + Text(error) + .font(.caption) + .foregroundColor(.appDestructive) + } + + Spacer() + } + .padding(.horizontal, 24) + .padding(.top, 24) + } + + private func saveName() async { + error = nil + isLoading = true + defer { isLoading = false } + + do { + _ = try await AuthService.shared.updateUser(displayName: displayName) + NotificationCenter.default.post(name: .profileUpdated, object: nil) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Profile/EditProfileView.swift b/apps/ios/Plotwist/Plotwist/Views/Profile/EditProfileView.swift index 7ea7361c..ea7f0f36 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Profile/EditProfileView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Profile/EditProfileView.swift @@ -5,6 +5,93 @@ import SwiftUI +// MARK: - Edit Profile Tab +enum EditProfileTab: CaseIterable { + case information + case preferences + case settings + + func displayName(strings: Strings) -> String { + switch self { + case .information: return strings.information + case .preferences: return strings.preferences + case .settings: return strings.settings + } + } + + var icon: String { + switch self { + case .information: return "person.fill" + case .preferences: return "slider.horizontal.3" + case .settings: return "gearshape.fill" + } + } + + var index: Int { + switch self { + case .information: return 0 + case .preferences: return 1 + case .settings: return 2 + } + } +} + +// MARK: - Edit Profile Tabs +struct EditProfileTabs: View { + @Binding var selectedTab: EditProfileTab + @Binding var slideFromTrailing: Bool + let strings: Strings + @Namespace private var tabNamespace + + var body: some View { + HStack(spacing: 0) { + ForEach(EditProfileTab.allCases, id: \.self) { tab in + Button { + guard selectedTab != tab else { return } + slideFromTrailing = tab.index > selectedTab.index + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + selectedTab = tab + } + } label: { + VStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 12)) + .foregroundColor(selectedTab == tab ? .appForegroundAdaptive : .appMutedForegroundAdaptive) + + Text(tab.displayName(strings: strings)) + .font(.subheadline.weight(.medium)) + .foregroundColor(selectedTab == tab ? .appForegroundAdaptive : .appMutedForegroundAdaptive) + } + + ZStack { + Rectangle() + .fill(Color.clear) + .frame(height: 3) + + if selectedTab == tab { + Rectangle() + .fill(Color.appForegroundAdaptive) + .frame(height: 3) + .matchedGeometryEffect(id: "editProfileTabIndicator", in: tabNamespace) + } + } + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + .overlay( + Rectangle() + .fill(Color.appBorderAdaptive) + .frame(height: 1), + alignment: .bottom + ) + } +} + struct EditProfileView: View { let user: User @@ -15,6 +102,8 @@ struct EditProfileView: View { @State private var userPreferences: UserPreferences? @State private var isLoadingPreferences = true @State private var streamingProviders: [StreamingProvider] = [] + @State private var selectedTab: EditProfileTab = .information + @State private var slideFromTrailing: Bool = true private let labelWidth: CGFloat = 100 @@ -63,15 +152,22 @@ struct EditProfileView: View { VStack(spacing: 0) { headerView - + + profilePictureSection + + EditProfileTabs( + selectedTab: $selectedTab, + slideFromTrailing: $slideFromTrailing, + strings: strings + ) + .padding(.top, 8) + ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - profilePictureSection - Divider().background(Color.appBorderAdaptive.opacity(0.5)) - fieldsSection - signOutSection - } + tabContentView + .id(selectedTab) + .padding(.bottom, 40) } + .clipped() } } .navigationBarHidden(true) @@ -139,9 +235,39 @@ struct EditProfileView: View { .padding(.vertical, 24) } - // MARK: - Fields Section - private var fieldsSection: some View { + // MARK: - Tab Content View + private var tabContentView: some View { + Group { + switch selectedTab { + case .information: + informationSection + case .preferences: + preferencesSection + case .settings: + settingsSection + } + } + .frame(maxWidth: .infinity, alignment: .top) + .transition(.asymmetric( + insertion: .move(edge: slideFromTrailing ? .trailing : .leading), + removal: .move(edge: slideFromTrailing ? .leading : .trailing) + )) + .animation(.spring(response: 0.4, dampingFraction: 0.88), value: selectedTab) + } + + // MARK: - Information Section + private var informationSection: some View { VStack(spacing: 0) { + // Name + NavigationLink(destination: EditNameView(currentDisplayName: user.displayName)) { + EditProfileRow( + label: strings.name, + value: user.displayName?.isEmpty == false ? user.displayName! : "-", + labelWidth: labelWidth + ) + } + fieldDivider + // Username NavigationLink(destination: EditUsernameView(currentUsername: user.username)) { EditProfileRow(label: strings.username, value: user.username, labelWidth: labelWidth) @@ -156,8 +282,12 @@ struct EditProfileView: View { labelWidth: labelWidth ) } - fieldDivider + } + } + // MARK: - Preferences Section + private var preferencesSection: some View { + VStack(spacing: 0) { // Region NavigationLink(destination: EditRegionView(currentRegion: userPreferences?.watchRegion)) { EditProfileBadgeRow(label: strings.region) { @@ -174,8 +304,12 @@ struct EditProfileView: View { // Streaming Services streamingServicesRow - fieldDivider + } + } + // MARK: - Settings Section + private var settingsSection: some View { + VStack(spacing: 0) { // Theme NavigationLink(destination: EditThemeView()) { EditProfileBadgeRow(label: strings.theme) { @@ -190,6 +324,8 @@ struct EditProfileView: View { ProfileBadge(text: Language.current.displayName, prefix: Language.current.flag) } } + + signOutSection } } @@ -275,7 +411,6 @@ struct EditProfileView: View { #endif } .padding(.horizontal, 24) - .padding(.bottom, 40) } // MARK: - Helpers