Repository: IgnisDa/ryot (language: rust)

Issue:
[Bug] Importer sets date of ended_on to current date
When importing for example this JSON:

```JSON
{
  "media": [
    {
      "source_id": "Finding Nemo",
      "lot": "movie",
      "source": "tmdb",
      "identifier": "12",
      "seen_history": [
        {
          "progress": "100",
          "started_on": "2005-07-15",
          "ended_on": "2005-07-15"
        }
      ],
      "reviews": [],
      "collections": [
        "Completed"
      ]
    },
  ],
  "people": [],
  "workouts": [],
  "exercises": [],
  "media_groups": [],
  "measurements": [],
  "workout_templates": []
}
```

It is added to the movies collection, but the watch history shows the current date as the ended on value.


Discussion / hints:
Note to self: remove usage of `process_import` and commit the seen items manually and call the `HandleOnSeenComplete` job.

Fix patch:
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
index c09ebbd0e5..2a3bc35ee6 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
@@ -44,12 +44,7 @@ import {
 } from "@mantine/hooks";
 import { notifications } from "@mantine/notifications";
 import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node";
-import {
-	Link,
-	useLoaderData,
-	useNavigate,
-	useRevalidator,
-} from "@remix-run/react";
+import { Link, useLoaderData, useNavigate } from "@remix-run/react";
 import {
 	CreateOrUpdateUserWorkoutDocument,
 	CreateOrUpdateUserWorkoutTemplateDocument,
@@ -178,7 +173,7 @@ export default function Page() {
 	const events = useApplicationEvents();
 	const [parent] = useAutoAnimate();
 	const navigate = useNavigate();
-	const revalidator = useRevalidator();
+	const [isSaveBtnLoading, setIsSaveBtnLoading] = useState(false);
 	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
 	const playCompleteTimerSound = () => {
 		const sound = new Howl({ src: ["/timer-completed.mp3"] });
@@ -283,34 +278,7 @@ export default function Page() {
 								onClose={() => setSupersetModalOpened(null)}
 							/>
 							<Stack ref={parent}>
-								<TextInput
-									size="sm"
-									label="Name"
-									placeholder="A name for your workout"
-									value={currentWorkout.name}
-									required
-									onChange={(e) =>
-										setCurrentWorkout(
-											produce(currentWorkout, (draft) => {
-												draft.name = e.currentTarget.value;
-											}),
-										)
-									}
-								/>
-								<Textarea
-									size="sm"
-									minRows={2}
-									label="Comment"
-									placeholder="Your thoughts about this workout"
-									value={currentWorkout.comment}
-									onChange={(e) =>
-										setCurrentWorkout(
-											produce(currentWorkout, (draft) => {
-												draft.comment = e.currentTarget.value;
-											}),
-										)
-									}
-								/>
+								<NameAndCommentInputs />
 								<Group>
 									<WorkoutDurationTimer />
 									<StatDisplay
@@ -397,10 +365,11 @@ export default function Page() {
 									{currentWorkout.exercises.length > 0 ? (
 										<>
 											<Button
+												radius="md"
 												color="green"
 												variant="subtle"
-												radius="md"
 												size="compact-sm"
+												loading={isSaveBtnLoading}
 												onClick={async () => {
 													if (!currentWorkout.name) {
 														notifications.show({
@@ -413,17 +382,19 @@ export default function Page() {
 														});
 														return;
 													}
-													setCurrentWorkout(
-														produce(currentWorkout, (draft) => {
-															draft.currentActionOrCompleted = true;
-														}),
-													);
 													const yes = await confirmWrapper({
 														confirmation: loaderData.isCreatingTemplate
 															? "Only sets that have data will added. Are you sure you want to save this template?"
 															: "Only sets marked as confirmed will be recorded. Are you sure you want to finish this workout?",
 													});
 													if (yes) {
+														setIsSaveBtnLoading(true);
+														setCurrentWorkout(
+															produce(currentWorkout, (draft) => {
+																draft.currentActionOrCompleted = true;
+															}),
+														);
+														await new Promise((r) => setTimeout(r, 1000));
 														const input = currentWorkoutToCreateWorkoutInput(
 															currentWorkout,
 															loaderData.isCreatingTemplate,
@@ -437,41 +408,50 @@ export default function Page() {
 															});
 														}
 														stopTimer();
-														const [entityId, fitnessEntity] = await match(
-															loaderData.isCreatingTemplate,
-														)
-															.with(true, () =>
-																clientGqlService
-																	.request(
-																		CreateOrUpdateUserWorkoutTemplateDocument,
-																		input,
-																	)
-																	.then((c) => [
-																		c.createOrUpdateUserWorkoutTemplate,
-																		FitnessEntity.Templates,
-																	]),
+														try {
+															const [entityId, fitnessEntity] = await match(
+																loaderData.isCreatingTemplate,
 															)
-															.with(false, () =>
-																clientGqlService
-																	.request(
-																		CreateOrUpdateUserWorkoutDocument,
-																		input,
-																	)
-																	.then((c) => [
-																		c.createOrUpdateUserWorkout,
-																		FitnessEntity.Workouts,
-																	]),
+																.with(true, () =>
+																	clientGqlService
+																		.request(
+																			CreateOrUpdateUserWorkoutTemplateDocument,
+																			input,
+																		)
+																		.then((c) => [
+																			c.createOrUpdateUserWorkoutTemplate,
+																			FitnessEntity.Templates,
+																		]),
+																)
+																.with(false, () =>
+																	clientGqlService
+																		.request(
+																			CreateOrUpdateUserWorkoutDocument,
+																			input,
+																		)
+																		.then((c) => [
+																			c.createOrUpdateUserWorkout,
+																			FitnessEntity.Workouts,
+																		]),
+																)
+																.exhaustive();
+															if (
+																loaderData.action === FitnessAction.LogWorkout
 															)
-															.exhaustive();
-														revalidator.revalidate();
-														if (loaderData.action === FitnessAction.LogWorkout)
-															events.createWorkout();
-														navigate(
-															$path("/fitness/:entity/:id", {
-																entity: fitnessEntity,
-																id: entityId,
-															}),
-														);
+																events.createWorkout();
+															navigate(
+																$path("/fitness/:entity/:id", {
+																	entity: fitnessEntity,
+																	id: entityId,
+																}),
+															);
+														} catch (e) {
+															notifications.show({
+																color: "red",
+																message: `Error while saving workout: ${JSON.stringify(e)}`,
+															});
+															setIsSaveBtnLoading(false);
+														}
 													}
 												}}
 											>
@@ -500,7 +480,6 @@ export default function Page() {
 														deleteUploadedAsset(asset.key);
 												}
 												navigate($path("/"), { replace: true });
-												revalidator.revalidate();
 												setCurrentWorkout(RESET);
 											}
 										}}
@@ -556,6 +535,53 @@ export default function Page() {
 	);
 }
 
+const NameAndCommentInputs = () => {
+	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
+	invariant(currentWorkout);
+
+	const [name, setName] = useDebouncedState(currentWorkout.name, 500);
+	const [comment, setComment] = useDebouncedState(currentWorkout.comment, 500);
+
+	useDidUpdate(() => {
+		if (name)
+			setCurrentWorkout(
+				produce(currentWorkout, (draft) => {
+					draft.name = name;
+				}),
+			);
+	}, [name]);
+
+	useDidUpdate(() => {
+		if (comment)
+			setCurrentWorkout(
+				produce(currentWorkout, (draft) => {
+					draft.comment = comment;
+				}),
+			);
+	}, [comment]);
+
+	return (
+		<>
+			<TextInput
+				size="sm"
+				required
+				label="Name"
+				defaultValue={name}
+				placeholder="A name for your workout"
+				onChange={(e) => setName(e.currentTarget.value)}
+			/>
+			<Textarea
+				size="sm"
+				minRows={2}
+				label="Comment"
+				defaultValue={comment}
+				placeholder="Your thoughts about this workout"
+				onChange={(e) => setComment(e.currentTarget.value)}
+			/>
+		</>
+	);
+};
+
 const StatDisplay = (props: {
 	name: string;
 	value: string;
@@ -926,6 +952,7 @@ const ExerciseDisplay = (props: {
 	const navigate = useNavigate();
 	const [parent] = useAutoAnimate();
 	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
+	const [currentTimer, _] = useTimerAtom();
 	const exercise = useGetExerciseAtIndex(props.exerciseIdx);
 	invariant(exercise);
 	const coreDetails = useCoreDetails();
@@ -965,12 +992,26 @@ const ExerciseDisplay = (props: {
 
 	if (!currentWorkout) return null;
 
-	const isExerciseComplete =
-		getProgressOfExercise(currentWorkout, props.exerciseIdx) === "complete";
+	const exerciseProgress = getProgressOfExercise(
+		currentWorkout,
+		props.exerciseIdx,
+	);
 	const partOfSuperset = currentWorkout.supersets.find((s) =>
 		s.exercises.includes(exercise.identifier),
 	);
 
+	const didLastSetOfExerciseActivateTimer =
+		currentTimer?.triggeredBy?.exerciseIdentifier === exercise.identifier &&
+		currentTimer?.triggeredBy?.setIdx === exercise.sets.length - 1;
+
+	const toggleExerciseCollapse = () => {
+		setCurrentWorkout(
+			produce(currentWorkout, (draft) => {
+				draft.exercises[props.exerciseIdx].isCollapsed = !exercise.isCollapsed;
+			}),
+		);
+	};
+
 	return (
 		<>
 			<Modal
@@ -1089,24 +1130,26 @@ const ExerciseDisplay = (props: {
 								{exercise.exerciseId}
 							</Anchor>
 							<Group wrap="nowrap" mr={-10}>
-								<ActionIcon
-									variant="transparent"
-									style={{
-										transition: "rotate 0.3s",
-										rotate: exercise.isCollapsed ? "180deg" : undefined,
-									}}
-									color={isExerciseComplete ? "green" : undefined}
-									onClick={() => {
-										setCurrentWorkout(
-											produce(currentWorkout, (draft) => {
-												draft.exercises[props.exerciseIdx].isCollapsed =
-													!exercise.isCollapsed;
-											}),
-										);
-									}}
-								>
-									<IconChevronUp />
-								</ActionIcon>
+								{didLastSetOfExerciseActivateTimer ? (
+									<DisplayLastExerciseSetRestTimer
+										toggleExerciseCollapse={toggleExerciseCollapse}
+									/>
+								) : (
+									<ActionIcon
+										variant="transparent"
+										style={{
+											transition: "rotate 0.3s",
+											rotate: exercise.isCollapsed ? "180deg" : undefined,
+										}}
+										color={match(exerciseProgress)
+											.with("complete", () => "green")
+											.with("in-progress", () => "blue")
+											.otherwise(() => undefined)}
+										onClick={() => toggleExerciseCollapse()}
+									>
+										<IconChevronUp />
+									</ActionIcon>
+								)}
 								<Menu.Target>
 									<ActionIcon color="blue">
 										<IconDotsVertical size={20} />
@@ -1450,6 +1493,38 @@ const ExerciseDisplay = (props: {
 	);
 };
 
+const DisplayLastExerciseSetRestTimer = (props: {
+	toggleExerciseCollapse: () => void;
+}) => {
+	const [currentTimer] = useTimerAtom();
+	forceUpdateEverySecond();
+
+	if (!currentTimer) return null;
+
+	return (
+		<RingProgress
+			roundCaps
+			size={30}
+			thickness={2}
+			style={{ cursor: "pointer" }}
+			onClick={() => props.toggleExerciseCollapse()}
+			sections={[
+				{
+					value:
+						(dayjsLib(currentTimer.endAt).diff(dayjsLib(), "seconds") * 100) /
+						currentTimer.totalTime,
+					color: "blue",
+				},
+			]}
+			label={
+				<Text ta="center" size="xs">
+					{Math.ceil(dayjsLib(currentTimer.endAt).diff(dayjsLib()) / 1000)}
+				</Text>
+			}
+		/>
+	);
+};
+
 const getNextSetInWorkout = (
 	currentWorkout: InProgressWorkout,
 	currentExerciseIdx: number,
diff --git a/crates/models/database/src/seen.rs b/crates/models/database/src/seen.rs
index fa79af3287..7671948c24 100644
--- a/crates/models/database/src/seen.rs
+++ b/crates/models/database/src/seen.rs
@@ -100,9 +100,12 @@ impl ActiveModelBehavior for ActiveModel {
     {
         let state = self.state.clone().unwrap();
         let progress = self.progress.clone().unwrap();
+        let finished_on = self.finished_on.clone().unwrap();
         if progress == dec!(100) && state == SeenState::InProgress {
             self.state = ActiveValue::Set(SeenState::Completed);
-            self.finished_on = ActiveValue::Set(Some(Utc::now().date_naive()));
+            if finished_on.is_none() {
+                self.finished_on = ActiveValue::Set(Some(Utc::now().date_naive()));
+            }
         }
         if insert {
             self.id = ActiveValue::Set(format!("see_{}", nanoid!(12)));
diff --git a/crates/models/media/src/lib.rs b/crates/models/media/src/lib.rs
index e8637e2715..5619d537c7 100644
--- a/crates/models/media/src/lib.rs
+++ b/crates/models/media/src/lib.rs
@@ -345,14 +345,16 @@ pub struct ProgressUpdateInput {
     pub metadata_id: String,
     pub date: Option<NaiveDate>,
     pub progress: Option<Decimal>,
+    #[graphql(skip_input)]
+    pub start_date: Option<NaiveDate>,
     pub change_state: Option<SeenState>,
     pub show_season_number: Option<i32>,
     pub show_episode_number: Option<i32>,
     pub manga_volume_number: Option<i32>,
     pub anime_episode_number: Option<i32>,
     pub podcast_episode_number: Option<i32>,
-    pub manga_chapter_number: Option<Decimal>,
     pub provider_watched_on: Option<String>,
+    pub manga_chapter_number: Option<Decimal>,
 }
 
 #[derive(Enum, Clone, Debug, Copy, PartialEq, Eq)]
diff --git a/crates/services/importer/src/audiobookshelf.rs b/crates/services/importer/src/audiobookshelf.rs
index 43bf4a58f7..d863aa1725 100644
--- a/crates/services/importer/src/audiobookshelf.rs
+++ b/crates/services/importer/src/audiobookshelf.rs
@@ -52,7 +52,7 @@ where
             query["filter"] = json!(format!("progress.{}", BASE64.encode(b"finished")));
         }
         let finished_items = client
-            .get(&format!("{}/libraries/{}/items", url, library.id))
+            .get(format!("{}/libraries/{}/items", url, library.id))
             .query(&query)
             .send()
             .await
@@ -188,7 +188,7 @@ async fn get_item_details(
         query["episode"] = json!(episode);
     }
     let item = client
-        .get(&format!("{}/items/{}", url, id))
+        .get(format!("{}/items/{}", url, id))
         .query(&query)
         .send()
         .await
diff --git a/crates/services/importer/src/jellyfin.rs b/crates/services/importer/src/jellyfin.rs
index 6889398e45..3a7abeb227 100644
--- a/crates/services/importer/src/jellyfin.rs
+++ b/crates/services/importer/src/jellyfin.rs
@@ -21,7 +21,7 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
 
     let query = json!({ "recursive": true, "IsPlayed": true, "fields": "ProviderIds" });
     let library_data = client
-        .get(&format!("{}/Users/{}/Items", base_url, user_id))
+        .get(format!("{}/Users/{}/Items", base_url, user_id))
         .query(&query)
         .send()
         .await
@@ -42,7 +42,7 @@ pub async fn import(input: DeployUrlAndKeyAndUsernameImportInput) -> Result<Impo
                     let mut tmdb_id = series_id_to_tmdb_id.get(&series_id).cloned().flatten();
                     if tmdb_id.is_none() {
                         let details = client
-                            .get(&format!("{}/Items/{}", base_url, series_id))
+                            .get(format!("{}/Items/{}", base_url, series_id))
                             .send()
                             .await
                             .unwrap()
diff --git a/crates/services/importer/src/trakt.rs b/crates/services/importer/src/trakt.rs
index e034efc8d3..b33f4e2279 100644
--- a/crates/services/importer/src/trakt.rs
+++ b/crates/services/importer/src/trakt.rs
@@ -75,7 +75,7 @@ pub async fn import(input: DeployTraktImportInput) -> Result<ImportResult> {
 
     for list in lists.iter_mut() {
         let rsp = client
-            .get(&format!("{}/lists/{}/items", url, list.ids.trakt))
+            .get(format!("{}/lists/{}/items", url, list.ids.trakt))
             .send()
             .await
             .unwrap();
diff --git a/crates/services/integration/src/yank/audiobookshelf.rs b/crates/services/integration/src/yank/audiobookshelf.rs
index 76aab4157d..7d17c4a602 100644
--- a/crates/services/integration/src/yank/audiobookshelf.rs
+++ b/crates/services/integration/src/yank/audiobookshelf.rs
@@ -191,7 +191,7 @@ impl AudiobookshelfYankIntegration {
                 .unwrap();
             for library in libraries_resp.libraries {
                 let items = client
-                    .get(&format!("libraries/{}/items", library.id))
+                    .get(format!("libraries/{}/items", library.id))
                     .send()
                     .await
                     .map_err(|e| anyhow!(e))?
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index 24aa0ca622..b14fdcea6b 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -2517,8 +2517,10 @@ ORDER BY RANDOM() LIMIT 10;
                             });
                         }
                     }
-                } else if cal_event.date == meta.publish_date.unwrap() {
-                    need_to_delete = false;
+                } else if let Some(date) = meta.publish_date {
+                    if cal_event.date == date {
+                        need_to_delete = false;
+                    }
                 };
 
                 if need_to_delete {
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index d7ec4366fb..45ab25644c 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -1434,20 +1434,21 @@ pub async fn progress_update(
             } else {
                 None
             };
-            let finished_on = if action == ProgressUpdateAction::JustStarted {
-                None
-            } else {
-                input.date
+            let finished_on = match action {
+                ProgressUpdateAction::JustStarted => None,
+                _ => input.date,
             };
             ryot_log!(debug, "Progress update finished on = {:?}", finished_on);
-            let (progress, started_on) = if matches!(action, ProgressUpdateAction::JustStarted) {
-                (
+            let (progress, mut started_on) = match action {
+                ProgressUpdateAction::JustStarted => (
                     input.progress.unwrap_or(dec!(0)),
                     Some(Utc::now().date_naive()),
-                )
-            } else {
-                (dec!(100), None)
+                ),
+                _ => (dec!(100), None),
             };
+            if matches!(action, ProgressUpdateAction::InThePast) && input.start_date.is_some() {
+                started_on = input.start_date;
+            }
             ryot_log!(debug, "Progress update percentage = {:?}", progress);
             let seen_insert = seen::ActiveModel {
                 progress: ActiveValue::Set(progress),
@@ -1830,6 +1831,12 @@ pub async fn create_or_update_workout(
             },
         ));
     }
+    input.supersets.retain(|s| {
+        s.exercises.len() > 1
+            && s.exercises
+                .iter()
+                .all(|s| exercises.get(*s as usize).is_some())
+    });
     let summary_total = workout_totals.into_iter().sum();
     let model = workout::Model {
         end_time,
@@ -1977,17 +1984,18 @@ pub async fn process_import(
                 user_id,
                 respect_cache,
                 ProgressUpdateInput {
-                    metadata_id: metadata.id.clone(),
                     progress,
+                    change_state: None,
                     date: seen.ended_on,
+                    start_date: seen.started_on,
+                    metadata_id: metadata.id.clone(),
                     show_season_number: seen.show_season_number,
                     show_episode_number: seen.show_episode_number,
-                    podcast_episode_number: seen.podcast_episode_number,
+                    manga_volume_number: seen.manga_volume_number,
                     anime_episode_number: seen.anime_episode_number,
                     manga_chapter_number: seen.manga_chapter_number,
-                    manga_volume_number: seen.manga_volume_number,
+                    podcast_episode_number: seen.podcast_episode_number,
                     provider_watched_on: seen.provider_watched_on.clone(),
-                    change_state: None,
                 },
                 ss,
             )


Test patch: