diff --git a/Cargo.lock b/Cargo.lock index 2491c94eca..beb9fee3a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,13 +837,11 @@ dependencies = [ "axum", "background", "cache-service", - "chrono", "chrono-tz", "collection-resolver", "collection-service", "common-utils", "config", - "database-models", "dependent-models", "dotenvy", "env-utils", @@ -853,6 +851,7 @@ dependencies = [ "file-storage-service", "fitness-resolver", "fitness-service", + "futures", "http 1.1.0", "importer-resolver", "importer-service", @@ -869,9 +868,7 @@ dependencies = [ "schematic", "sea-orm", "sea-orm-migration", - "serde", "serde_json", - "serde_with 3.11.0", "statistics-resolver", "statistics-service", "supporting-service", @@ -880,7 +877,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "unkey", + "traits", "user-resolver", "user-service", ] @@ -1280,6 +1277,7 @@ dependencies = [ "database-models", "database-utils", "dependent-models", + "dependent-utils", "enums", "media-models", "migrations", @@ -1326,6 +1324,7 @@ name = "common-models" version = "0.1.0" dependencies = [ "async-graphql", + "chrono", "educe", "enum_meta", "enums", @@ -1741,7 +1740,6 @@ dependencies = [ name = "dependent-utils" version = "0.1.0" dependencies = [ - "anyhow", "application-utils", "async-graphql", "background", @@ -3331,7 +3329,6 @@ dependencies = [ "indexmap 2.7.0", "itertools 0.13.0", "media-models", - "nanoid", "providers", "reqwest 0.12.9", "rust_decimal", @@ -3713,7 +3710,6 @@ dependencies = [ "serde", "serde_json", "serde_with 3.11.0", - "strum", ] [[package]] @@ -3840,11 +3836,13 @@ dependencies = [ "sea-query", "serde", "serde_json", + "serde_with 3.11.0", "slug", "supporting-service", "tokio", "tracing", "traits", + "unkey", "user-models", "uuid", ] @@ -4577,7 +4575,6 @@ dependencies = [ "application-utils", "async-trait", "chrono", - "chrono-tz", "common-models", "common-utils", "config", @@ -4604,6 +4601,7 @@ dependencies = [ "serde_json", "serde_with 3.11.0", "strum", + "supporting-service", "tracing", "traits", ] @@ -5831,8 +5829,11 @@ dependencies = [ name = "specific-models" version = "0.1.0" dependencies = [ + "common-models", "rust_decimal", + "sea-orm", "serde", + "serde_with 3.11.0", "strum", ] @@ -6131,8 +6132,7 @@ name = "statistics-resolver" version = "0.1.0" dependencies = [ "async-graphql", - "dependent-models", - "media-models", + "common-models", "statistics-service", "traits", ] @@ -6142,12 +6142,15 @@ name = "statistics-service" version = "0.1.0" dependencies = [ "async-graphql", + "common-models", "database-models", "database-utils", - "dependent-models", - "media-models", + "enums", + "hashbag", + "itertools 0.13.0", "sea-orm", "supporting-service", + "tokio", "tracing", ] @@ -6243,6 +6246,7 @@ dependencies = [ "background", "cache-service", "chrono-tz", + "common-models", "config", "file-storage-service", "openidconnect", @@ -6952,6 +6956,7 @@ dependencies = [ "dependent-models", "media-models", "traits", + "user-models", "user-service", ] diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 8c9074914e..e83be9b007 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -15,13 +15,11 @@ axum = { workspace = true } aws-sdk-s3 = { workspace = true } background = { path = "../../crates/background" } cache-service = { path = "../../crates/services/cache" } -chrono = { workspace = true } chrono-tz = { workspace = true } collection-resolver = { path = "../../crates/resolvers/collection" } collection-service = { path = "../../crates/services/collection" } common-utils = { path = "../../crates/utils/common" } config = { path = "../../crates/config" } -database-models = { path = "../../crates/models/database" } dependent-models = { path = "../../crates/models/dependent" } dotenvy = { workspace = true } env-utils = { path = "../../crates/utils/env" } @@ -31,6 +29,7 @@ file-storage-resolver = { path = "../../crates/resolvers/file-storage" } file-storage-service = { path = "../../crates/services/file-storage" } fitness-resolver = { path = "../../crates/resolvers/fitness" } fitness-service = { path = "../../crates/services/fitness" } +futures = { workspace = true } http = { workspace = true } importer-resolver = { path = "../../crates/resolvers/importer" } importer-service = { path = "../../crates/services/importer" } @@ -46,9 +45,7 @@ reqwest = { workspace = true } router-resolver = { path = "../../crates/resolvers/router" } sea-orm = { workspace = true } sea-orm-migration = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } -serde_with = { workspace = true } schematic = { workspace = true } statistics-resolver = { path = "../../crates/resolvers/statistics" } statistics-service = { path = "../../crates/services/statistics" } @@ -58,6 +55,6 @@ tower = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -unkey = { workspace = true } +traits = { path = "../../crates/traits" } user-resolver = { path = "../../crates/resolvers/user" } user-service = { path = "../../crates/services/user" } diff --git a/apps/backend/src/common.rs b/apps/backend/src/common.rs index b9693896c8..824b9fb401 100644 --- a/apps/backend/src/common.rs +++ b/apps/backend/src/common.rs @@ -19,8 +19,8 @@ use exporter_resolver::{ExporterMutation, ExporterQuery}; use exporter_service::ExporterService; use file_storage_resolver::{FileStorageMutation, FileStorageQuery}; use file_storage_service::FileStorageService; -use fitness_resolver::{ExerciseMutation, ExerciseQuery}; -use fitness_service::ExerciseService; +use fitness_resolver::{FitnessMutation, FitnessQuery}; +use fitness_service::FitnessService; use importer_resolver::{ImporterMutation, ImporterQuery}; use importer_service::ImporterService; use integration_service::IntegrationService; @@ -49,7 +49,7 @@ pub struct AppServices { pub app_router: Router, pub importer_service: Arc, pub exporter_service: Arc, - pub exercise_service: Arc, + pub fitness_service: Arc, pub statistics_service: Arc, pub integration_service: Arc, pub miscellaneous_service: Arc, @@ -57,7 +57,6 @@ pub struct AppServices { #[allow(clippy::too_many_arguments)] pub async fn create_app_services( - is_pro: bool, db: DatabaseConnection, timezone: chrono_tz::Tz, s3_client: aws_sdk_s3::Client, @@ -73,7 +72,6 @@ pub async fn create_app_services( let cache_service = CacheService::new(&db); let supporting_service = Arc::new( SupportingService::new( - is_pro, &db, timezone, cache_service, @@ -87,7 +85,7 @@ pub async fn create_app_services( ); let user_service = Arc::new(UserService(supporting_service.clone())); let importer_service = Arc::new(ImporterService(supporting_service.clone())); - let exercise_service = Arc::new(ExerciseService(supporting_service.clone())); + let fitness_service = Arc::new(FitnessService(supporting_service.clone())); let exporter_service = Arc::new(ExporterService(supporting_service.clone())); let collection_service = Arc::new(CollectionService(supporting_service.clone())); let statistics_service = Arc::new(StatisticsService(supporting_service.clone())); @@ -104,7 +102,7 @@ pub async fn create_app_services( .data(user_service.clone()) .data(importer_service.clone()) .data(exporter_service.clone()) - .data(exercise_service.clone()) + .data(fitness_service.clone()) .data(statistics_service.clone()) .data(collection_service.clone()) .data(file_storage_service.clone()) @@ -149,7 +147,7 @@ pub async fn create_app_services( app_router, importer_service, exporter_service, - exercise_service, + fitness_service, statistics_service, integration_service, miscellaneous_service, @@ -192,7 +190,7 @@ pub struct QueryRoot( MiscellaneousQuery, ImporterQuery, ExporterQuery, - ExerciseQuery, + FitnessQuery, FileStorageQuery, StatisticsQuery, CollectionQuery, @@ -204,7 +202,7 @@ pub struct MutationRoot( MiscellaneousMutation, ImporterMutation, ExporterMutation, - ExerciseMutation, + FitnessMutation, FileStorageMutation, CollectionMutation, UserMutation, diff --git a/apps/backend/src/job.rs b/apps/backend/src/job.rs index 194f2bedf1..528b7dd3c0 100644 --- a/apps/backend/src/job.rs +++ b/apps/backend/src/job.rs @@ -4,27 +4,41 @@ use apalis::prelude::*; use background::{ApplicationJob, CoreApplicationJob, ScheduledJob}; use common_utils::ryot_log; use exporter_service::ExporterService; -use fitness_service::ExerciseService; +use fitness_service::FitnessService; use importer_service::ImporterService; use integration_service::IntegrationService; use media_models::CommitMediaInput; use miscellaneous_service::MiscellaneousService; use statistics_service::StatisticsService; +use traits::TraceOk; -pub async fn background_jobs( +pub async fn run_background_jobs( information: ScheduledJob, misc_service: Data>, ) -> Result<(), Error> { ryot_log!(debug, "Running job at {:#?}", information.0); - misc_service.perform_background_jobs().await.unwrap(); + misc_service.perform_background_jobs().await.trace_ok(); Ok(()) } -pub async fn sync_integrations_data( +pub async fn run_frequent_jobs( _information: ScheduledJob, + fitness_service: Data>, + misc_service: Data>, integration_service: Data>, ) -> Result<(), Error> { - integration_service.yank_integrations_data().await.unwrap(); + misc_service + .perform_server_key_validation() + .await + .trace_ok(); + integration_service + .yank_integrations_data() + .await + .trace_ok(); + fitness_service + .process_users_scheduled_for_workout_revision() + .await + .trace_ok(); Ok(()) } @@ -67,7 +81,7 @@ pub async fn perform_application_job( integration_service: Data>, importer_service: Data>, exporter_service: Data>, - exercise_service: Data>, + fitness_service: Data>, statistics_service: Data>, ) -> Result<(), Error> { let name = information.to_string(); @@ -84,10 +98,9 @@ pub async fn perform_application_job( .await .is_ok() } - ApplicationJob::ReEvaluateUserWorkouts(user_id) => exercise_service - .re_evaluate_user_workouts(user_id) - .await - .is_ok(), + ApplicationJob::ReviseUserWorkouts(user_id) => { + fitness_service.revise_user_workouts(user_id).await.is_ok() + } ApplicationJob::UpdateMetadata(metadata_id, force_update) => misc_service .update_metadata_and_notify_users(&metadata_id, force_update) .await @@ -104,7 +117,7 @@ pub async fn perform_application_job( .update_metadata_group(&metadata_group_id) .await .is_ok(), - ApplicationJob::UpdateGithubExerciseJob(exercise) => exercise_service + ApplicationJob::UpdateGithubExerciseJob(exercise) => fitness_service .update_github_exercise(exercise) .await .is_ok(), @@ -126,12 +139,22 @@ pub async fn perform_application_job( ApplicationJob::PerformExport(user_id) => { exporter_service.perform_export(user_id).await.is_ok() } - ApplicationJob::UpdateExerciseLibrary => exercise_service + ApplicationJob::UpdateExerciseLibrary => fitness_service .deploy_update_exercise_library_job() .await .is_ok(), ApplicationJob::SyncIntegrationsData => { - integration_service.yank_integrations_data().await.is_ok() + integration_service + .yank_integrations_data() + .await + .trace_ok(); + integration_service + .sync_integrations_data_to_owned_collection() + .await + .is_ok() + } + ApplicationJob::PerformServerKeyValidation => { + misc_service.perform_server_key_validation().await.is_ok() } ApplicationJob::HandleEntityAddedToCollectionEvent(collection_to_entity_id) => { integration_service diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 0f4862e651..be68b012ac 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -18,16 +18,13 @@ use apalis::{ }; use aws_sdk_s3::config::Region; use background::ApplicationJob; -use chrono::{DateTime, NaiveDate, TimeZone, Utc}; -use common_utils::{convert_naive_to_utc, ryot_log, COMPILATION_TIMESTAMP, PROJECT_NAME, TEMP_DIR}; -use database_models::prelude::Exercise; -use env_utils::{APP_VERSION, UNKEY_API_ID}; +use common_utils::{ryot_log, PROJECT_NAME, TEMP_DIR}; +use env_utils::APP_VERSION; +use futures::future::join_all; use logs_wheel::LogFileInitializer; use migrations::Migrator; -use sea_orm::{ConnectionTrait, Database, DatabaseConnection, EntityTrait, PaginatorTrait}; +use sea_orm::{ConnectionTrait, Database, DatabaseConnection}; use sea_orm_migration::MigratorTrait; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; use tokio::{ join, net::TcpListener, @@ -35,13 +32,12 @@ use tokio::{ }; use tower::buffer::BufferLayer; use tracing_subscriber::{fmt, layer::SubscriberExt}; -use unkey::{models::VerifyKeyRequest, Client}; use crate::{ common::create_app_services, job::{ - background_jobs, perform_application_job, perform_core_application_job, - sync_integrations_data, + perform_application_job, perform_core_application_job, run_background_jobs, + run_frequent_jobs, }, }; @@ -70,7 +66,7 @@ async fn main() -> Result<()> { let config = Arc::new(config::load_app_config()?); if config.server.sleep_before_startup_seconds > 0 { let duration = TokioDuration::from_secs(config.server.sleep_before_startup_seconds); - ryot_log!(info, "Sleeping for {:?} before starting up...", duration); + ryot_log!(warn, "Sleeping for {:?} before starting up...", duration); sleep(duration).await; } @@ -78,14 +74,8 @@ async fn main() -> Result<()> { let sync_every_minutes = config.integration.sync_every_minutes; let disable_background_jobs = config.server.disable_background_jobs; - let compile_timestamp = Utc.timestamp_opt(COMPILATION_TIMESTAMP, 0).unwrap(); - ryot_log!(debug, "Compiled at: {}", compile_timestamp); - let config_dump_path = PathBuf::new().join(TEMP_DIR).join("config.json"); fs::write(config_dump_path, serde_json::to_string_pretty(&config)?)?; - let is_pro = get_is_pro(&config.server.pro_key, &compile_timestamp).await; - - ryot_log!(debug, "Is pro: {:#?}", is_pro); let mut aws_conf = aws_sdk_s3::Config::builder() .region(Region::new(config.file_storage.s3_region.clone())) @@ -129,24 +119,17 @@ async fn main() -> Result<()> { .unwrap_or_else(|_| chrono_tz::Etc::GMT); ryot_log!(info, "Timezone: {}", tz); - perform_application_job_storage - .enqueue(ApplicationJob::SyncIntegrationsData) - .await - .unwrap(); - - if Exercise::find().count(&db).await? == 0 { - ryot_log!( - info, - "Instance does not have exercises data. Deploying job to download them..." - ); - perform_application_job_storage - .enqueue(ApplicationJob::UpdateExerciseLibrary) - .await - .unwrap(); - } + join_all( + [ + ApplicationJob::PerformServerKeyValidation, + ApplicationJob::SyncIntegrationsData, + ApplicationJob::UpdateExerciseLibrary, + ] + .map(|j| perform_application_job_storage.enqueue(j)), + ) + .await; let app_services = create_app_services( - is_pro, db, tz, s3_client, @@ -196,9 +179,10 @@ async fn main() -> Result<()> { let listener = TcpListener::bind(format!("{host}:{port}")).await.unwrap(); ryot_log!(info, "Listening on: {}", listener.local_addr()?); + let fitness_service_1 = app_services.fitness_service.clone(); + let fitness_service_2 = app_services.fitness_service.clone(); let importer_service_1 = app_services.importer_service.clone(); let exporter_service_1 = app_services.exporter_service.clone(); - let exercise_service_1 = app_services.exercise_service.clone(); let statistics_service_1 = app_services.statistics_service.clone(); let integration_service_1 = app_services.integration_service.clone(); let integration_service_2 = app_services.integration_service.clone(); @@ -206,11 +190,12 @@ async fn main() -> Result<()> { let miscellaneous_service_1 = app_services.miscellaneous_service.clone(); let miscellaneous_service_2 = app_services.miscellaneous_service.clone(); let miscellaneous_service_3 = app_services.miscellaneous_service.clone(); + let miscellaneous_service_4 = app_services.miscellaneous_service.clone(); let monitor = Monitor::::new() .register_with_count( 1, - WorkerBuilder::new("background_jobs") + WorkerBuilder::new("daily_background_jobs") .stream( // every day CronStream::new_with_timezone(Schedule::from_str("0 0 0 * * *").unwrap(), tz) @@ -218,11 +203,11 @@ async fn main() -> Result<()> { ) .layer(ApalisTraceLayer::new()) .data(miscellaneous_service_1.clone()) - .build_fn(background_jobs), + .build_fn(run_background_jobs), ) .register_with_count( 1, - WorkerBuilder::new("sync_integrations_data") + WorkerBuilder::new("frequent_jobs") .stream( CronStream::new_with_timezone( Schedule::from_str(&format!("0 */{} * * * *", sync_every_minutes)).unwrap(), @@ -232,7 +217,9 @@ async fn main() -> Result<()> { ) .layer(ApalisTraceLayer::new()) .data(integration_service_1.clone()) - .build_fn(sync_integrations_data), + .data(fitness_service_2.clone()) + .data(miscellaneous_service_4.clone()) + .build_fn(run_frequent_jobs), ) // application jobs .register_with_count( @@ -247,7 +234,7 @@ async fn main() -> Result<()> { .register_with_count( 3, WorkerBuilder::new("perform_application_job") - .data(exercise_service_1.clone()) + .data(fitness_service_1.clone()) .data(exporter_service_1.clone()) .data(importer_service_1.clone()) .data(statistics_service_1.clone()) @@ -354,47 +341,3 @@ END $$; .await?; Ok(()) } - -async fn get_is_pro(pro_key: &str, compilation_time: &DateTime) -> bool { - if pro_key.is_empty() { - return false; - } - - ryot_log!(debug, "Verifying pro key for API ID: {:#?}", UNKEY_API_ID); - - #[skip_serializing_none] - #[derive(Debug, Serialize, Clone, Deserialize)] - struct Meta { - expiry: Option, - } - - let unkey_client = Client::new("public"); - let verify_request = VerifyKeyRequest::new(pro_key, UNKEY_API_ID); - let validated_key = match unkey_client.verify_key(verify_request).await { - Ok(verify_response) => { - if !verify_response.valid { - ryot_log!(debug, "Pro key is no longer valid."); - return false; - } - verify_response - } - Err(verify_error) => { - ryot_log!(debug, "Pro key verification error: {:?}", verify_error); - return false; - } - }; - let key_meta = validated_key - .meta - .map(|meta| serde_json::from_value::(meta).unwrap()); - ryot_log!(debug, "Expiry: {:?}", key_meta.clone().map(|m| m.expiry)); - if let Some(meta) = key_meta { - if let Some(expiry) = meta.expiry { - if compilation_time > &convert_naive_to_utc(expiry) { - ryot_log!(warn, "Pro key has expired. Please renew your subscription."); - return false; - } - } - } - ryot_log!(info, "Pro key verified successfully"); - true -} diff --git a/apps/frontend/app/components/common.tsx b/apps/frontend/app/components/common.tsx index f39b0f8218..1f2fb8cd69 100644 --- a/apps/frontend/app/components/common.tsx +++ b/apps/frontend/app/components/common.tsx @@ -59,6 +59,7 @@ import { IconTrash, IconX, } from "@tabler/icons-react"; +import clsx from "clsx"; import Cookies from "js-cookie"; import type { ReactNode, Ref } from "react"; import { useState } from "react"; @@ -233,7 +234,7 @@ export const DebouncedSearchInput = (props: { export const ProRequiredAlert = (props: { tooltipLabel?: string }) => { const coreDetails = useCoreDetails(); - return !coreDetails.isPro ? ( + return !coreDetails.isServerKeyValidated ? ( {PRO_REQUIRED_MESSAGE} @@ -255,6 +256,7 @@ export const BaseMediaDisplayItem = (props: { isLoading: boolean; nameRight?: ReactNode; imageUrl?: string | null; + highlightImage?: boolean; innerRef?: Ref; labels?: { right?: ReactNode; left?: ReactNode }; onImageClickBehavior: string | (() => Promise); @@ -276,8 +278,8 @@ export const BaseMediaDisplayItem = (props: { {iProps.children} ); const defaultOverlayProps = { - style: { zIndex: 10, ...blackBgStyles }, pos: "absolute", + style: { zIndex: 10, ...blackBgStyles }, } as const; return ( @@ -285,9 +287,17 @@ export const BaseMediaDisplayItem = (props: { - + {`Image ({ height: 180 })) .exhaustive(), }} - alt={`Image for ${props.name}`} - className={classes.mediaImage} styles={{ root: { transitionProperty: "transform", @@ -361,9 +369,9 @@ export const BaseMediaDisplayItem = (props: { ) : ( ({ base: 6, md: 3 })) .with(GridPacking.Dense, () => ({ md: 2 })) @@ -394,11 +402,11 @@ export const BaseMediaDisplayItem = (props: { }; export const FiltersModal = (props: { + title?: string; opened: boolean; cookieName: string; children: ReactNode; closeFiltersModal: () => void; - title?: string; }) => { const navigate = useNavigate(); @@ -467,8 +475,8 @@ export const CollectionsFilter = (props: { }; export const DisplayThreePointReview = (props: { - rating?: string | null; size?: number; + rating?: string | null; }) => match(convertDecimalToThreePointSmiley(Number(props.rating || ""))) .with(ThreePointSmileyRating.Happy, () => ( @@ -838,10 +846,10 @@ export const DisplayCollectionEntity = (props: { .run(); export const DisplayCollection = (props: { - creatorUserId: string; - col: { id: string; name: string }; entityId: string; entityLot: EntityLot; + creatorUserId: string; + col: { id: string; name: string }; }) => { const color = useGetRandomMantineColor(props.col.name); const submit = useConfirmSubmit(); diff --git a/apps/frontend/app/components/fitness.tsx b/apps/frontend/app/components/fitness.tsx index 23a81120d0..1c7ada6ddf 100644 --- a/apps/frontend/app/components/fitness.tsx +++ b/apps/frontend/app/components/fitness.tsx @@ -242,7 +242,7 @@ export const ExerciseHistory = (props: { const exercise = workoutDetails?.details.information.exercises[props.exerciseIdx]; const { data: exerciseDetails } = useQuery( - getExerciseDetailsQuery(exercise?.name || ""), + getExerciseDetailsQuery(exercise?.id || ""), ); const isInSuperset = props.supersetInformation?.find((s) => s.exercises.includes(props.exerciseIdx), @@ -276,7 +276,7 @@ export const ExerciseHistory = (props: { }), props.exerciseIdx.toString(), ) - : getExerciseDetailsPath(exercise.name) + : getExerciseDetailsPath(exercise.id) } fw="bold" lineClamp={1} @@ -284,7 +284,7 @@ export const ExerciseHistory = (props: { > {props.hideExerciseDetails ? workoutDetails.details.name - : exercise.name} + : exerciseDetails.name} {!props.hideExtraDetailsButton ? ( @@ -425,7 +425,7 @@ export const ExerciseDisplayItem = (props: { return ( { + return clientGqlService + .request(UserMetadataGroupDetailsDocument, props) + .then((data) => data.userMetadataGroupDetails); + }, + enabled: inViewport, + }); return ( ); }; @@ -327,23 +339,33 @@ export const PersonDisplayItem = (props: { }, enabled: inViewport, }); + const { data: userPersonDetails } = useQuery({ + queryKey: queryFactory.media.userPersonDetails(props.personId).queryKey, + queryFn: async () => { + return clientGqlService + .request(UserPersonDetailsDocument, props) + .then((data) => data.userPersonDetails); + }, + enabled: inViewport, + }); return ( sum + content.items.length, 0)} items` : undefined, right: props.rightLabel, }} - imageOverlay={{ topRight: props.topRight }} /> ); }; diff --git a/apps/frontend/app/lib/generals.ts b/apps/frontend/app/lib/generals.ts index dea2c05a70..703de1a364 100644 --- a/apps/frontend/app/lib/generals.ts +++ b/apps/frontend/app/lib/generals.ts @@ -3,12 +3,14 @@ import { createQueryKeys, mergeQueryKeys, } from "@lukemorales/query-key-factory"; +import type { MantineColor } from "@mantine/core"; import { MediaLot, MediaSource, MetadataDetailsDocument, MetadataPartialDetailsDocument, SetLot, + type UserAnalyticsQueryVariables, UserMetadataDetailsDocument, } from "@ryot/generated/graphql/backend/graphql"; import { inRange, isString } from "@ryot/ts-utils"; @@ -297,6 +299,11 @@ export const getStringAsciiValue = (input: string) => { return total; }; +export const selectRandomElement = (array: T[], input: string): T => { + // taken from https://stackoverflow.com/questions/44975435/using-mod-operator-in-javascript-to-wrap-around#comment76926119_44975435 + return array[(getStringAsciiValue(input) + array.length) % array.length]; +}; + export const getMetadataIcon = (lot: MediaLot) => match(lot) .with(MediaLot.Book, () => IconBook) @@ -353,9 +360,15 @@ const mediaQueryKeys = createQueryKeys("media", { metadataGroupDetails: (metadataGroupId: string) => ({ queryKey: ["metadataGroupDetails", metadataGroupId], }), + userMetadataGroupDetails: (metadataGroupId: string) => ({ + queryKey: ["userMetadataGroupDetails", metadataGroupId], + }), personDetails: (personId: string) => ({ queryKey: ["personDetails", personId], }), + userPersonDetails: (personId: string) => ({ + queryKey: ["userPersonDetails", personId], + }), genreImages: (genreId: string) => ({ queryKey: ["genreDetails", "images", genreId], }), @@ -392,16 +405,20 @@ const miscellaneousQueryKeys = createQueryKeys("miscellaneous", { coreDetails: () => ({ queryKey: ["coreDetails"], }), - dailyUserActivities: (startDate?: string, endDate?: string) => ({ - queryKey: ["dailyUserActivities", startDate, endDate], +}); + +const analyticsQueryKeys = createQueryKeys("analytics", { + user: (input: UserAnalyticsQueryVariables) => ({ + queryKey: ["user", input], }), }); export const queryFactory = mergeQueryKeys( usersQueryKeys, mediaQueryKeys, - collectionQueryKeys, fitnessQueryKeys, + analyticsQueryKeys, + collectionQueryKeys, miscellaneousQueryKeys, ); @@ -451,7 +468,34 @@ export const refreshUserMetadataDetails = (metadataId: string) => }); }, 1500); +export const convertUtcHourToLocalHour = ( + utcHour: number, + userTimezone?: string, +) => { + const targetTimezone = userTimezone || dayjs.tz.guess(); + const utcDate = dayjs.utc().hour(utcHour).minute(0).second(0); + const localDate = utcDate.tz(targetTimezone); + return localDate.hour(); +}; + export const getExerciseDetailsPath = (exerciseId: string) => $path("/fitness/exercises/item/:id", { id: encodeURIComponent(exerciseId), }); + +type EntityColor = Record; + +export const MediaColors: EntityColor = { + ANIME: "blue", + AUDIO_BOOK: "orange", + BOOK: "lime", + MANGA: "purple", + MOVIE: "cyan", + PODCAST: "yellow", + SHOW: "red", + VISUAL_NOVEL: "pink", + VIDEO_GAME: "teal", + WORKOUT: "violet", + REVIEW: "green.5", + USER_MEASUREMENT: "indigo", +}; diff --git a/apps/frontend/app/lib/hooks.ts b/apps/frontend/app/lib/hooks.ts index b2cec590da..b49371d6b9 100644 --- a/apps/frontend/app/lib/hooks.ts +++ b/apps/frontend/app/lib/hooks.ts @@ -20,8 +20,8 @@ import { FitnessAction, dayjsLib, getMetadataDetailsQuery, - getStringAsciiValue, getUserMetadataDetailsQuery, + selectRandomElement, } from "~/lib/generals"; import { type InProgressWorkout, useCurrentWorkout } from "~/lib/state/fitness"; import type { loader as dashboardLoader } from "~/routes/_dashboard"; @@ -34,9 +34,7 @@ export const useGetMantineColors = () => { export const useGetRandomMantineColor = (input: string) => { const colors = useGetMantineColors(); - - // taken from https://stackoverflow.com/questions/44975435/using-mod-operator-in-javascript-to-wrap-around#comment76926119_44975435 - return colors[(getStringAsciiValue(input) + colors.length) % colors.length]; + return selectRandomElement(colors, input); }; export const useFallbackImageUrl = (text = "No Image") => { @@ -107,8 +105,11 @@ export const useGetWorkoutStarter = () => { return fn; }; -export const useMetadataDetails = (metadataId?: string | null) => { - return useQuery(getMetadataDetailsQuery(metadataId)); +export const useMetadataDetails = ( + metadataId?: string | null, + enabled?: boolean, +) => { + return useQuery({ ...getMetadataDetailsQuery(metadataId), enabled }); }; export const useUserMetadataDetails = ( @@ -138,7 +139,7 @@ export const useUserUnitSystem = () => useUserPreferences().fitness.exercises.unitSystem; export const useApplicationEvents = () => { - const { version, isPro } = useCoreDetails(); + const { version, isServerKeyValidated: isPro } = useCoreDetails(); const sendEvent = (eventName: string, data: Record) => { window.umami?.track(eventName, { isPro, version, ...data }); diff --git a/apps/frontend/app/lib/state/fitness.ts b/apps/frontend/app/lib/state/fitness.ts index b6a65c1c14..75d3ac7930 100644 --- a/apps/frontend/app/lib/state/fitness.ts +++ b/apps/frontend/app/lib/state/fitness.ts @@ -48,6 +48,7 @@ type AlreadyDoneExerciseSet = Pick; type Media = { imageSrc: string; key: string }; export type Exercise = { + name: string; lot: ExerciseLot; identifier: string; exerciseId: string; @@ -57,6 +58,7 @@ export type Exercise = { isCollapsed?: boolean; sets: Array; isShowDetailsOpen: boolean; + scrollMarginRemoved?: true; openedDetailsTab?: "images" | "history"; alreadyDoneSets: Array; }; @@ -292,20 +294,21 @@ export const duplicateOldWorkout = async ( params.updateWorkoutId ? v.confirmedAt : undefined, ), ); - const exerciseDetails = await getExerciseDetails(ex.name); + const exerciseDetails = await getExerciseDetails(ex.id); inProgress.exercises.push({ identifier: randomUUID(), + name: exerciseDetails.details.name, isShowDetailsOpen: userFitnessPreferences.logging.showDetailsWhileEditing ? exerciseIdx === 0 : false, images: [], videos: [], alreadyDoneSets: sets.map((s) => ({ statistic: s.statistic })), - exerciseId: ex.name, + exerciseId: ex.id, lot: ex.lot, notes: ex.notes, sets: sets, - openedDetailsTab: !coreDetails.isPro + openedDetailsTab: !coreDetails.isServerKeyValidated ? "images" : (exerciseDetails.userDetails.history?.length || 0) > 0 ? "history" @@ -383,6 +386,7 @@ export const addExerciseToWorkout = async ( } draft.exercises.push({ identifier: randomUUID(), + name: exerciseDetails.details.name, isShowDetailsOpen: userFitnessPreferences.logging.showDetailsWhileEditing, exerciseId: ex.name, lot: ex.lot, diff --git a/apps/frontend/app/lib/utilities.server.ts b/apps/frontend/app/lib/utilities.server.ts index 024ae6bacf..b23f134d56 100644 --- a/apps/frontend/app/lib/utilities.server.ts +++ b/apps/frontend/app/lib/utilities.server.ts @@ -177,6 +177,7 @@ export const getDecodedJwt = (request: Request) => { export const getCachedCoreDetails = async () => { return await queryClient.ensureQueryData({ queryKey: queryFactory.miscellaneous.coreDetails().queryKey, + staleTime: dayjsLib.duration({ minutes: 5 }).asMilliseconds(), queryFn: () => serverGqlService.request(CoreDetailsDocument).then((d) => d.coreDetails), }); diff --git a/apps/frontend/app/routes/_dashboard._index.tsx b/apps/frontend/app/routes/_dashboard._index.tsx index e655a0b6e6..329426b0c2 100644 --- a/apps/frontend/app/routes/_dashboard._index.tsx +++ b/apps/frontend/app/routes/_dashboard._index.tsx @@ -1,46 +1,34 @@ -import { BarChart } from "@mantine/charts"; import { Alert, Box, Center, Container, Flex, - LoadingOverlay, - type MantineColor, - Paper, RingProgress, - Select, SimpleGrid, Stack, Text, Title, useMantineTheme, } from "@mantine/core"; -import { useInViewport } from "@mantine/hooks"; import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import { type CalendarEventPartFragment, CollectionContentsDocument, - DailyUserActivitiesDocument, DailyUserActivitiesResponseGroupedBy, DashboardElementLot, GraphqlSortOrder, - LatestUserSummaryDocument, MediaLot, + UserAnalyticsDocument, type UserPreferences, UserRecommendationsDocument, UserUpcomingCalendarEventsDocument, } from "@ryot/generated/graphql/backend/graphql"; import { - changeCase, - formatDateToNaiveDate, + formatQuantityWithCompactNotation, humanizeDuration, - isBoolean, isNumber, - mapValues, - pickBy, - snakeCase, } from "@ryot/ts-utils"; import { IconBarbell, @@ -49,9 +37,8 @@ import { IconScaleOutline, IconServer, } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; import CryptoJS from "crypto-js"; -import { Fragment, type ReactNode, useMemo } from "react"; +import { Fragment, type ReactNode } from "react"; import { $path } from "remix-routes"; import { ClientOnly } from "remix-utils/client-only"; import invariant from "tiny-invariant"; @@ -61,15 +48,7 @@ import { ApplicationGrid, ProRequiredAlert } from "~/components/common"; import { DisplayCollectionEntity } from "~/components/common"; import { displayWeightWithUnit } from "~/components/fitness"; import { MetadataDisplayItem } from "~/components/media"; -import { - TimeSpan, - clientGqlService, - dayjsLib, - getDateFromTimeSpan, - getLot, - getMetadataIcon, - queryFactory, -} from "~/lib/generals"; +import { MediaColors, dayjsLib, getLot, getMetadataIcon } from "~/lib/generals"; import { useCoreDetails, useGetMantineColors, @@ -117,7 +96,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { { collectionContents: inProgressCollectionContents }, userRecommendations, { userUpcomingCalendarEvents }, - { latestUserSummary }, + { userAnalytics }, ] = await Promise.all([ serverGqlService.authenticatedRequest(request, CollectionContentsDocument, { input: { @@ -132,14 +111,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { UserUpcomingCalendarEventsDocument, { input: { nextMedia: takeUpcoming } }, ), - serverGqlService.authenticatedRequest( - request, - LatestUserSummaryDocument, - undefined, - ), + serverGqlService.authenticatedRequest(request, UserAnalyticsDocument, { + input: { + dateRange: {}, + groupBy: DailyUserActivitiesResponseGroupedBy.Millennium, + }, + }), ]); return { - latestUserSummary, + userAnalytics, userUpcomingCalendarEvents, inProgressCollectionContents, userRecommendations, @@ -150,30 +130,13 @@ export const meta = (_args: MetaArgs) => { return [{ title: "Home | Ryot" }]; }; -type EntityColor = Record; - -const MediaColors: EntityColor = { - ANIME: "blue", - AUDIO_BOOK: "orange", - BOOK: "lime", - MANGA: "purple", - MOVIE: "cyan", - PODCAST: "yellow", - SHOW: "red", - VISUAL_NOVEL: "pink", - VIDEO_GAME: "teal", - WORKOUT: "violet", - MEASUREMENT: "indigo", - REVIEW: "green.5", -}; - export default function Page() { const loaderData = useLoaderData(); const coreDetails = useCoreDetails(); const userPreferences = useUserPreferences(); const unitSystem = useUserUnitSystem(); const theme = useMantineTheme(); - const latestUserSummary = loaderData.latestUserSummary; + const latestUserSummary = loaderData.userAnalytics.activities.items.at(0); const dashboardMessage = coreDetails.frontend.dashboardMessage; @@ -235,7 +198,7 @@ export default function Page() { .with([DashboardElementLot.Recommendations, false], ([v, _]) => (
Recommendations - {coreDetails.isPro ? ( + {coreDetails.isServerKeyValidated ? ( {loaderData.userRecommendations.map((lm) => ( @@ -246,241 +209,238 @@ export default function Page() { )}
)) - .with([DashboardElementLot.Activity, false], ([v, _]) => ( -
- Activity - -
- )) - .with([DashboardElementLot.Summary, false], ([v, _]) => ( -
- Summary - - - - - - - - - - - {userPreferences.featuresEnabled.media.enabled ? ( - <> + .with([DashboardElementLot.Summary, false], ([v, _]) => + latestUserSummary ? ( +
+ Summary + + + + + + + + + + + {userPreferences.featuresEnabled.media.enabled ? ( + <> + } + lot="Metadata stats" + color={theme.colors.grape[8]} + data={[ + { + label: "Media", + value: latestUserSummary.totalMetadataCount, + type: "number", + }, + { + label: "Reviews", + value: latestUserSummary.totalMetadataReviewCount, + type: "number", + hideIfZero: true, + }, + ]} + /> + {userPreferences.featuresEnabled.media.people ? ( + + } + lot="People stats" + color={theme.colors.red[9]} + data={[ + { + label: "People Reviewed", + value: + latestUserSummary.totalPersonReviewCount, + type: "number", + hideIfZero: true, + }, + ]} + /> + + ) : null} + + ) : null} + {userPreferences.featuresEnabled.fitness.enabled ? ( + + } + lot="Workouts" + color={theme.colors.teal[2]} + data={[ + { + label: "Workouts", + value: latestUserSummary.workoutCount, + type: "number", + }, + { + label: "Runtime", + value: latestUserSummary.totalWorkoutDuration, + type: "duration", + }, + { + label: "Runtime", + value: displayWeightWithUnit( + unitSystem, + latestUserSummary.totalWorkoutWeight, + true, + ), + type: "string", + }, + ]} + /> + + ) : null} + {userPreferences.featuresEnabled.fitness.enabled ? ( } - lot="Metadata stats" - color={theme.colors.grape[8]} + icon={} + lot="Fitness" + color={theme.colors.yellow[5]} data={[ { - label: "Media", - value: latestUserSummary.totalMetadataCount, - type: "number", - }, - { - label: "Reviews", - value: latestUserSummary.totalMetadataReviewCount, + label: "Measurements", + value: latestUserSummary.userMeasurementCount, type: "number", hideIfZero: true, }, ]} /> - {userPreferences.featuresEnabled.media.people ? ( - - } - lot="People stats" - color={theme.colors.red[9]} - data={[ - { - label: "People Reviewed", - value: latestUserSummary.totalPersonReviewCount, - type: "number", - hideIfZero: true, - }, - ]} - /> - - ) : null} - - ) : null} - {userPreferences.featuresEnabled.fitness.enabled ? ( - - } - lot="Workouts" - color={theme.colors.teal[2]} - data={[ - { - label: "Workouts", - value: latestUserSummary.workoutCount, - type: "number", - }, - { - label: "Runtime", - value: latestUserSummary.totalWorkoutDuration, - type: "duration", - }, - { - label: "Runtime", - value: displayWeightWithUnit( - unitSystem, - latestUserSummary.totalWorkoutWeight, - true, - ), - type: "string", - }, - ]} - /> - - ) : null} - {userPreferences.featuresEnabled.fitness.enabled ? ( - } - lot="Fitness" - color={theme.colors.yellow[5]} - data={[ - { - label: "Measurements", - value: latestUserSummary.measurementCount, - type: "number", - hideIfZero: true, - }, - ]} - /> - ) : null} - -
- )) + ) : null} +
+
+ ) : null, + ) .otherwise(() => undefined), )} @@ -564,9 +524,7 @@ const ActualDisplayStat = (props: { ), ) .with("number", () => - new Intl.NumberFormat("en-US", { - notation: "compact", - }).format(Number(d.value)), + formatQuantityWithCompactNotation(Number(d.value)), ) .exhaustive()} @@ -637,147 +595,3 @@ const UnstyledLink = (props: { children: ReactNode; to: string }) => { ); }; - -const ActivitySection = () => { - const { ref, inViewport } = useInViewport(); - const [timeSpan, setTimeSpan] = useLocalStorage( - "ActivitySectionTimeSpan", - TimeSpan.Last7Days, - ); - const { startDate, endDate } = useMemo(() => { - const now = dayjsLib(); - const end = now.endOf("day"); - const startDate = getDateFromTimeSpan(timeSpan); - return { - startDate: startDate - ? formatDateToNaiveDate(startDate.toDate()) - : undefined, - endDate: formatDateToNaiveDate(end.toDate()), - }; - }, [timeSpan]); - const { data: dailyUserActivitiesData } = useQuery({ - queryKey: queryFactory.miscellaneous.dailyUserActivities(startDate, endDate) - .queryKey, - enabled: inViewport, - queryFn: async () => { - const { dailyUserActivities } = await clientGqlService.request( - DailyUserActivitiesDocument, - { input: { startDate, endDate } }, - ); - const trackSeries = mapValues(MediaColors, () => false); - const data = dailyUserActivities.items.map((d) => { - const data = Object.entries(d) - .filter(([_, value]) => value !== 0) - .map(([key, value]) => ({ - [snakeCase( - key.replace("Count", "").replace("total", ""), - ).toUpperCase()]: value, - })) - .reduce(Object.assign, {}); - for (const key in data) - if (isBoolean(trackSeries[key])) trackSeries[key] = true; - return data; - }); - const series = pickBy(trackSeries); - return { - data, - series, - groupedBy: dailyUserActivities.groupedBy, - totalCount: dailyUserActivities.totalCount, - totalDuration: dailyUserActivities.totalDuration, - }; - }, - }); - const items = dailyUserActivitiesData?.totalCount || 0; - - return ( - - - - - - ) : null} @@ -187,8 +187,8 @@ export default function Page() { label="Name" required autoFocus - name="id" - defaultValue={loaderData.details?.id} + name="name" + defaultValue={loaderData.details?.name} /> ({ @@ -1366,14 +1373,17 @@ const EditHistoryItemModal = (props: { label="Time spent" description="How much time did you actually spend on this media?" > - + {POSSIBLE_DURATION_UNITS.map((input) => ( {input}} onChange={(v) => { setManualTimeSpentValue((prev) => ({ @@ -1463,7 +1473,7 @@ const HistoryItem = (props: { tab: string, index?: number, ) => { - if (!coreDetails.isPro) { + if (!coreDetails.isServerKeyValidated) { notifications.show({ color: "red", message: PRO_REQUIRED_MESSAGE, diff --git a/apps/frontend/app/routes/_dashboard.settings.integrations.tsx b/apps/frontend/app/routes/_dashboard.settings.integrations.tsx index ea6c998d8c..892f7c9df1 100644 --- a/apps/frontend/app/routes/_dashboard.settings.integrations.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.integrations.tsx @@ -69,6 +69,7 @@ const PRO_INTEGRATIONS = [IntegrationProvider.JellyfinPush]; const YANK_INTEGRATIONS = [ IntegrationProvider.Audiobookshelf, IntegrationProvider.Komga, + IntegrationProvider.PlexYank, ]; const PUSH_INTEGRATIONS = [ IntegrationProvider.Radarr, @@ -170,7 +171,9 @@ const createSchema = z.object({ syncToOwnedCollection: zx.CheckboxAsString.optional(), providerSpecifics: z .object({ - plexUsername: z.string().optional(), + plexYankBaseUrl: z.string().optional(), + plexYankToken: z.string().optional(), + plexSinkUsername: z.string().optional(), audiobookshelfBaseUrl: z.string().optional(), audiobookshelfToken: z.string().optional(), komgaBaseUrl: z.string().optional(), @@ -403,7 +406,9 @@ const CreateIntegrationModal = (props: { const coreDetails = useCoreDetails(); const [provider, setProvider] = useState(); const disableCreationButton = - !coreDetails.isPro && provider && PRO_INTEGRATIONS.includes(provider); + !coreDetails.isServerKeyValidated && + provider && + PRO_INTEGRATIONS.includes(provider); return ( )) - .with(IntegrationProvider.Plex, () => ( + .with(IntegrationProvider.PlexYank, () => ( + <> + + + + )) + .with(IntegrationProvider.PlexSink, () => ( <> )) @@ -529,14 +548,14 @@ const CreateIntegrationModal = (props: { {provider && YANK_INTEGRATIONS.includes(provider) ? ( ) : undefined} diff --git a/apps/frontend/app/routes/_dashboard.settings.miscellaneous.tsx b/apps/frontend/app/routes/_dashboard.settings.miscellaneous.tsx index ec6b124237..9b5de45f5c 100644 --- a/apps/frontend/app/routes/_dashboard.settings.miscellaneous.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.miscellaneous.tsx @@ -151,9 +151,9 @@ export default function Page() { - Re-evaluate workouts + Revise workouts - Re-evaluate all workouts. This may be useful if exercises done + Revise all workouts. This may be useful if exercises done during a workout have changed or workouts have been edited or deleted. @@ -161,9 +161,9 @@ export default function Page() { diff --git a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx index da2a446783..fc2551581e 100644 --- a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx @@ -270,12 +270,14 @@ export default function Page() { Features that you want to use. - {(["media", "fitness", "others"] as const).map((facet) => ( - - {startCase(facet)} - - {Object.entries(userPreferences.featuresEnabled[facet]).map( - ([name, isEnabled]) => ( + {(["media", "fitness", "analytics", "others"] as const).map( + (facet) => ( + + {startCase(facet)} + + {Object.entries( + userPreferences.featuresEnabled[facet], + ).map(([name, isEnabled]) => ( - ), - )} - - - ))} + ))} + + + ), + )} diff --git a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx index 8648e6818d..881b807a1c 100644 --- a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx @@ -37,6 +37,7 @@ import { type UserAccessLinksQuery, } from "@ryot/generated/graphql/backend/graphql"; import { + formatQuantityWithCompactNotation, getActionIntent, isNumber, isString, @@ -391,9 +392,7 @@ const DisplayAccessLink = (props: { Created: {dayjsLib(props.accessLink.createdOn).fromNow()}, Times Used:{" "} - {new Intl.NumberFormat("en-US", { - notation: "compact", - }).format(props.accessLink.timesUsed)} + {formatQuantityWithCompactNotation(props.accessLink.timesUsed)} {optionalDetails ? {optionalDetails} : null}
diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx index a8e74c1df1..4985db0f91 100644 --- a/apps/frontend/app/routes/_dashboard.tsx +++ b/apps/frontend/app/routes/_dashboard.tsx @@ -91,6 +91,7 @@ import { IconDeviceSpeaker, IconDeviceTv, IconEyeglass, + IconGraph, IconHome2, IconLogout, IconMoodEmpty, @@ -218,7 +219,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const fitnessLinks = [ ...(Object.entries(userPreferences.featuresEnabled.fitness || {}) - .filter(([v, _]) => v !== "enabled") + .filter(([v, _]) => !["enabled"].includes(v)) .map(([name, enabled]) => ({ name, enabled })) ?.filter((f) => f.enabled) .map((f) => ({ @@ -226,10 +227,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { href: joinURL("/fitness", f.name, "list"), })) || []), { label: "Exercises", href: $path("/fitness/exercises/list") }, - ].map((link) => ({ - label: link.label, - link: link.href, - })); + ] + .filter((link) => link !== undefined) + .map((link) => ({ label: link.label, link: link.href })); const settingsLinks = [ { label: "Preferences", link: $path("/settings/preferences") }, @@ -652,6 +652,16 @@ export default function Layout() { links={loaderData.fitnessLinks} /> ) : null} + {loaderData.userPreferences.featuresEnabled.analytics.enabled ? ( + {}} + toggle={toggleMobileNavbar} + href={$path("/analytics")} + /> + ) : null} {loaderData.userPreferences.featuresEnabled.others.calendar ? ( { return ( - {!coreDetails.isPro ? ( + {!coreDetails.isServerKeyValidated ? ( Ryot Pro @@ -923,11 +933,12 @@ const Footer = () => { ); }; -const WATCH_TIMES = [ - "Just Right Now", - "I don't remember", - "Custom Date", -] as const; +enum WatchTimes { + JustCompletedNow = "Just Completed Now", + IDontRemember = "I don't remember", + CustomDate = "Custom Date", + JustStartedIt = "Just Started It", +} const MetadataProgressUpdateForm = ({ closeMetadataProgressUpdateModal, @@ -968,7 +979,7 @@ const MetadataProgressUpdateForm = ({ inProgress={userMetadataDetails.inProgress} /> ) : ( - { + const [parent] = useAutoAnimate(); const [_, setMetadataToUpdate] = useMetadataProgressUpdate(); const [selectedDate, setSelectedDate] = useState( new Date(), ); - const [watchTime, setWatchTime] = - useState<(typeof WATCH_TIMES)[number]>("Just Right Now"); + const [watchTime, setWatchTime] = useState( + WatchTimes.JustCompletedNow, + ); const lastProviderWatchedOn = history[0]?.providerWatchedOn; const watchProviders = useGetWatchProviders(metadataDetails.lot); @@ -1118,19 +1131,32 @@ const NewProgressUpdateForm = ({
{[ ...Object.entries(metadataToUpdate), - ["metadataLot", metadataDetails.lot], - ].map(([k, v]) => ( - - {typeof v !== "undefined" ? ( - - ) : null} - - ))} - + watchTime !== WatchTimes.JustStartedIt + ? ["metadataLot", metadataDetails.lot] + : undefined, + watchTime === WatchTimes.JustStartedIt ? ["progress", "0"] : undefined, + selectedDate + ? ["date", formatDateToNaiveDate(selectedDate)] + : undefined, + ] + .filter((v) => typeof v !== "undefined") + .map(([k, v]) => ( + + {typeof v !== "undefined" ? ( + + ) : null} + + ))} + {metadataDetails.lot === MediaLot.Anime ? ( <> ) : null} - {selectedDate ? ( - ) : null}