Repository: IgnisDa/ryot (language: rust)

Issue:
[FEATURE REQUEST] - Rating based on emojis ✨
On top of rating from 1 to 5 or 1 to 10. An option to rate with either "happy face" "neuter face" or "sad face" emojis.

How AniList does it:

![Screenshot_20240910_085409_Vivaldi](https://github.com/user-attachments/assets/5d812203-2ac1-44c0-8385-7b23052a108f)

![Screenshot_20240910_085424_Vivaldi](https://github.com/user-attachments/assets/4861e4ea-968e-4541-81c4-f494a5658345)

Thanks for coming to my TED talk. 


Discussion / hints:
Also this is how it looks when selecting the option in the settings: 

![Screenshot_20240910_092213_Vivaldi](https://github.com/user-attachments/assets/3243571d-3aec-4c01-b7f6-e823bac7357e)


Fix patch:
diff --git a/apps/frontend/app/components/common.tsx b/apps/frontend/app/components/common.tsx
index 49d73009b2..39a1edeecd 100644
--- a/apps/frontend/app/components/common.tsx
+++ b/apps/frontend/app/components/common.tsx
@@ -51,7 +51,7 @@ import { $path } from "remix-routes";
 import type { DeepPartial } from "ts-essentials";
 import { match } from "ts-pattern";
 import { withQuery } from "ufo";
-import { dayjsLib, getSurroundingElements } from "~/lib/generals";
+import { dayjsLib, getSurroundingElements, reviewYellow } from "~/lib/generals";
 import {
 	useAppSearchParam,
 	useConfirmSubmit,
@@ -374,7 +374,7 @@ export const ReviewItemDisplay = (props: {
 					) : null}
 					{(Number(props.review.rating) || 0) > 0 ? (
 						<Flex align="center" gap={4}>
-							<IconStarFilled size={16} style={{ color: "#EBE600FF" }} />
+							<IconStarFilled size={16} style={{ color: reviewYellow }} />
 							<Text className={classes.text} fw="bold">
 								{props.review.rating}
 								{reviewScale === UserReviewScale.OutOfFive ? undefined : "%"}
diff --git a/apps/frontend/app/components/media.tsx b/apps/frontend/app/components/media.tsx
index 4741541017..4b0452d2e8 100644
--- a/apps/frontend/app/components/media.tsx
+++ b/apps/frontend/app/components/media.tsx
@@ -28,11 +28,20 @@ import {
 	UserReviewScale,
 	UserToMediaReason,
 } from "@ryot/generated/graphql/backend/graphql";
-import { changeCase, getInitials, isString, snakeCase } from "@ryot/ts-utils";
+import {
+	changeCase,
+	getInitials,
+	inRange,
+	isString,
+	snakeCase,
+} from "@ryot/ts-utils";
 import {
 	IconBackpack,
 	IconBookmarks,
 	IconCloudDownload,
+	IconMoodEmpty,
+	IconMoodHappy,
+	IconMoodSad,
 	IconPlayerPlay,
 	IconRosetteDiscountCheck,
 	IconStarFilled,
@@ -44,9 +53,11 @@ import { withQuery } from "ufo";
 import { MEDIA_DETAILS_HEIGHT } from "~/components/common";
 import { confirmWrapper } from "~/components/confirmation";
 import {
+	ThreePointSmileyRating,
 	clientGqlService,
 	getPartialMetadataDetailsQuery,
 	queryFactory,
+	reviewYellow,
 } from "~/lib/generals";
 import {
 	useConfirmSubmit,
@@ -217,6 +228,13 @@ export const BaseMediaDisplayItem = (props: {
 	);
 };
 
+const convertDecimalToThreePointSmiley = (rating: number) =>
+	inRange(rating, 0, 33.4)
+		? ThreePointSmileyRating.Sad
+		: inRange(rating, 33.4, 66.8)
+			? ThreePointSmileyRating.Neutral
+			: ThreePointSmileyRating.Happy;
+
 export const MetadataDisplayItem = (props: {
 	metadataId: string;
 	name?: string;
@@ -300,17 +318,34 @@ export const MetadataDisplayItem = (props: {
 				topRight: props.topRight ? (
 					props.topRight
 				) : averageRating ? (
-					<Group gap={4}>
-						<IconStarFilled size={12} style={{ color: "#EBE600FF" }} />
-						<Text c="white" size="xs" fw="bold" pr={4}>
-							{Number(averageRating) % 1 === 0
-								? Math.round(Number(averageRating)).toString()
-								: Number(averageRating).toFixed(1)}
-							{userPreferences.general.reviewScale === UserReviewScale.OutOfFive
-								? null
-								: " %"}
-						</Text>
-					</Group>
+					match(userPreferences.general.reviewScale)
+						.with(UserReviewScale.ThreePointSmiley, () =>
+							match(convertDecimalToThreePointSmiley(Number(averageRating)))
+								.with(ThreePointSmileyRating.Happy, () => (
+									<IconMoodHappy size={20} color={reviewYellow} />
+								))
+								.with(ThreePointSmileyRating.Neutral, () => (
+									<IconMoodEmpty size={20} color={reviewYellow} />
+								))
+								.with(ThreePointSmileyRating.Sad, () => (
+									<IconMoodSad size={20} color={reviewYellow} />
+								))
+								.exhaustive(),
+						)
+						.otherwise(() => (
+							<Group gap={4}>
+								<IconStarFilled size={12} style={{ color: reviewYellow }} />
+								<Text c="white" size="xs" fw="bold" pr={4}>
+									{Number(averageRating) % 1 === 0
+										? Math.round(Number(averageRating)).toString()
+										: Number(averageRating).toFixed(1)}
+									{userPreferences.general.reviewScale ===
+									UserReviewScale.OutOfFive
+										? null
+										: " %"}
+								</Text>
+							</Group>
+						))
 				) : (
 					<IconStarFilled
 						cursor="pointer"
@@ -323,7 +358,7 @@ export const MetadataDisplayItem = (props: {
 									entityTitle: metadataDetails.title,
 								});
 						}}
-						size={16}
+						size={18}
 						className={classes.starIcon}
 					/>
 				),
diff --git a/apps/frontend/app/lib/generals.ts b/apps/frontend/app/lib/generals.ts
index e1638657af..ed4d333da2 100644
--- a/apps/frontend/app/lib/generals.ts
+++ b/apps/frontend/app/lib/generals.ts
@@ -86,6 +86,14 @@ export enum TimeSpan {
 	AllTime = "All Time",
 }
 
+export enum ThreePointSmileyRating {
+	Happy = "Happy",
+	Neutral = "Neutral",
+	Sad = "Sad",
+}
+
+export const reviewYellow = "#EBE600FF";
+
 export const getSetColor = (l: SetLot) =>
 	match(l)
 		.with(SetLot.WarmUp, () => "yellow")
diff --git a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
index 780a16ad92..190d524481 100644
--- a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
@@ -104,6 +104,7 @@ import {
 	getVerb,
 	queryClient,
 	queryFactory,
+	reviewYellow,
 } from "~/lib/generals";
 import {
 	useApplicationEvents,
@@ -475,7 +476,7 @@ export default function Page() {
 									gap: 6,
 								}}
 							>
-								<IconStarFilled size={22} style={{ color: "#EBE600FF" }} />
+								<IconStarFilled size={22} style={{ color: reviewYellow }} />
 								<Text fz="sm">
 									{Number(loaderData.userMetadataDetails.averageRating).toFixed(
 										1,
diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx
index 0e94cee785..62c826f95f 100644
--- a/apps/frontend/app/routes/_dashboard.tsx
+++ b/apps/frontend/app/routes/_dashboard.tsx
@@ -87,6 +87,9 @@ import {
 	IconDeviceTv,
 	IconHome2,
 	IconLogout,
+	IconMoodEmpty,
+	IconMoodHappy,
+	IconMoodSad,
 	IconMoon,
 	IconPercentage,
 	IconSettings,
@@ -101,6 +104,7 @@ import { match } from "ts-pattern";
 import { joinURL, withQuery } from "ufo";
 import {
 	LOGO_IMAGE_URL,
+	ThreePointSmileyRating,
 	Verb,
 	getLot,
 	getVerb,
@@ -753,6 +757,13 @@ const WATCH_TIMES = [
 	"Custom Date",
 ] as const;
 
+const refreshUserMetadataDetails = (metadataId: string) =>
+	setTimeout(() => {
+		queryClient.invalidateQueries({
+			queryKey: queryFactory.media.userMetadataDetails(metadataId).queryKey,
+		});
+	}, 1500);
+
 const MetadataProgressUpdateForm = ({
 	closeMetadataProgressUpdateModal,
 }: {
@@ -780,11 +791,7 @@ const MetadataProgressUpdateForm = ({
 		submit(e);
 		const metadataId = metadataToUpdate.metadataId;
 		events.updateProgress(metadataDetails.title);
-		setTimeout(async () => {
-			await queryClient.invalidateQueries({
-				queryKey: queryFactory.media.userMetadataDetails(metadataId).queryKey,
-			});
-		}, 1500);
+		refreshUserMetadataDetails(metadataId);
 		closeMetadataProgressUpdateModal();
 	};
 
@@ -1161,6 +1168,13 @@ const NewProgressUpdateForm = ({
 	);
 };
 
+const convertThreePointSmileyToDecimal = (rating: ThreePointSmileyRating) =>
+	match(rating)
+		.with(ThreePointSmileyRating.Happy, () => 100)
+		.with(ThreePointSmileyRating.Neutral, () => 66.66)
+		.with(ThreePointSmileyRating.Sad, () => 33.33)
+		.exhaustive();
+
 const ReviewEntityForm = ({
 	closeReviewEntityModal,
 }: {
@@ -1170,6 +1184,26 @@ const ReviewEntityForm = ({
 	const events = useApplicationEvents();
 	const submit = useConfirmSubmit();
 	const [entityToReview] = useReviewEntity();
+	const [ratingInThreePointSmiley, setRatingInThreePointSmiley] = useState<
+		ThreePointSmileyRating | undefined
+	>();
+
+	const SmileySurround = (props: {
+		children: React.ReactNode;
+		smileyRating: ThreePointSmileyRating;
+	}) => (
+		<ThemeIcon
+			size="xl"
+			variant={
+				props.smileyRating === ratingInThreePointSmiley
+					? "outline"
+					: "transparent"
+			}
+			onClick={() => setRatingInThreePointSmiley(props.smileyRating)}
+		>
+			{props.children}
+		</ThemeIcon>
+	);
 
 	if (!entityToReview) return null;
 
@@ -1180,16 +1214,21 @@ const ReviewEntityForm = ({
 			action={withQuery("/actions", { intent: "performReviewAction" })}
 			onSubmit={async (e) => {
 				submit(e);
-				await queryClient.invalidateQueries({
-					queryKey: queryFactory.media.userMetadataDetails(
-						entityToReview.entityId,
-					).queryKey,
-				});
+				refreshUserMetadataDetails(entityToReview.entityId);
 				events.postReview(entityToReview.entityTitle);
 				closeReviewEntityModal();
 			}}
 		>
 			<input hidden name="entityId" value={entityToReview.entityId} readOnly />
+			{userPreferences.general.reviewScale ===
+				UserReviewScale.ThreePointSmiley && ratingInThreePointSmiley ? (
+				<input
+					hidden
+					name="rating"
+					value={convertThreePointSmileyToDecimal(ratingInThreePointSmiley)}
+					readOnly
+				/>
+			) : undefined}
 			<input
 				hidden
 				name="entityLot"
@@ -1237,6 +1276,24 @@ const ReviewEntityForm = ({
 								}
 							/>
 						))
+						.with(UserReviewScale.ThreePointSmiley, () => (
+							<Stack gap={4}>
+								<Text size="xs" c="dimmed">
+									How did it make you feel?
+								</Text>
+								<Group justify="space-around">
+									<SmileySurround smileyRating={ThreePointSmileyRating.Happy}>
+										<IconMoodHappy size={36} />
+									</SmileySurround>
+									<SmileySurround smileyRating={ThreePointSmileyRating.Neutral}>
+										<IconMoodEmpty size={36} />
+									</SmileySurround>
+									<SmileySurround smileyRating={ThreePointSmileyRating.Sad}>
+										<IconMoodSad size={36} />
+									</SmileySurround>
+								</Group>
+							</Stack>
+						))
 						.exhaustive()}
 					<Checkbox label="This review is a spoiler" mt="lg" name="isSpoiler" />
 				</Flex>
diff --git a/crates/models/user/src/lib.rs b/crates/models/user/src/lib.rs
index d62c2d6fea..2346c03a2a 100644
--- a/crates/models/user/src/lib.rs
+++ b/crates/models/user/src/lib.rs
@@ -256,6 +256,7 @@ pub enum UserReviewScale {
     OutOfFive,
     #[default]
     OutOfHundred,
+    ThreePointSmiley,
 }
 
 #[derive(Debug, Serialize, Deserialize, Enum, Clone, Eq, PartialEq, FromJsonQueryResult, Copy)]
diff --git a/crates/services/importer/src/lib.rs b/crates/services/importer/src/lib.rs
index 30c53b49c5..21dd7668bc 100644
--- a/crates/services/importer/src/lib.rs
+++ b/crates/services/importer/src/lib.rs
@@ -508,6 +508,7 @@ fn convert_review_into_input(
     let rating = match preferences.general.review_scale {
         UserReviewScale::OutOfFive => review.rating.map(|rating| rating / dec!(20)),
         UserReviewScale::OutOfHundred => review.rating,
+        UserReviewScale::ThreePointSmiley => review.rating.map(|rating| rating / dec!(3)),
     };
     let text = review.review.clone().and_then(|r| r.text);
     let is_spoiler = review.review.clone().map(|r| r.spoiler.unwrap_or(false));
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index 95324a4a64..205583f758 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -902,23 +902,20 @@ impl MiscellaneousService {
             .map(|a| Order::from(a.order))
             .unwrap_or(Order::Asc);
 
+        let review_scale = match preferences.general.review_scale {
+            UserReviewScale::OutOfFive => 20,
+            UserReviewScale::OutOfHundred => 1,
+            UserReviewScale::ThreePointSmiley => 3,
+        };
         let select = Metadata::find()
             .select_only()
             .column(metadata::Column::Id)
             .expr_as(
                 Func::round_with_precision(
                     Func::avg(
-                        Expr::col((AliasedReview::Table, AliasedReview::Rating)).div(
-                            match preferences.general.review_scale {
-                                UserReviewScale::OutOfFive => 20,
-                                UserReviewScale::OutOfHundred => 1,
-                            },
-                        ),
+                        Expr::col((AliasedReview::Table, AliasedReview::Rating)).div(review_scale),
                     ),
-                    match preferences.general.review_scale {
-                        UserReviewScale::OutOfFive => 1,
-                        UserReviewScale::OutOfHundred => 0,
-                    },
+                    review_scale,
                 ),
                 avg_rating_col,
             )
@@ -2591,6 +2588,7 @@ impl MiscellaneousService {
                 |r| match preferences.general.review_scale {
                     UserReviewScale::OutOfFive => r * dec!(20),
                     UserReviewScale::OutOfHundred => r,
+                    UserReviewScale::ThreePointSmiley => r * dec!(3),
                 },
             )),
             text: ActiveValue::Set(input.text),
diff --git a/crates/utils/database/src/lib.rs b/crates/utils/database/src/lib.rs
index 01d6a42536..709f2cf7b7 100644
--- a/crates/utils/database/src/lib.rs
+++ b/crates/utils/database/src/lib.rs
@@ -357,6 +357,7 @@ pub async fn item_reviews(
                     s.checked_div(match preferences.general.review_scale {
                         UserReviewScale::OutOfFive => dec!(20),
                         UserReviewScale::OutOfHundred => dec!(1),
+                        UserReviewScale::ThreePointSmiley => dec!(3),
                     })
                     .unwrap()
                     .round_dp(1)
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index 42e63d5f81..401f510aaf 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -2290,10 +2290,12 @@ export type UserPreferences = {
 
 export enum UserReviewScale {
   OutOfFive = 'OUT_OF_FIVE',
-  OutOfHundred = 'OUT_OF_HUNDRED'
+  OutOfHundred = 'OUT_OF_HUNDRED',
+  ThreePointSmiley = 'THREE_POINT_SMILEY'
 }
 
 export type UserToEntity = {
+  collectionId?: Maybe<Scalars['String']['output']>;
   createdOn: Scalars['DateTime']['output'];
   exerciseExtraInformation?: Maybe<UserToExerciseExtraInformation>;
   exerciseId?: Maybe<Scalars['String']['output']>;
diff --git a/libs/ts-utils/src/index.ts b/libs/ts-utils/src/index.ts
index b0a2cf9c82..2eb373a353 100644
--- a/libs/ts-utils/src/index.ts
+++ b/libs/ts-utils/src/index.ts
@@ -8,6 +8,7 @@ import {
 import camelCase from "lodash/camelCase";
 import cloneDeep from "lodash/cloneDeep";
 import groupBy from "lodash/groupBy";
+import inRange from "lodash/inRange";
 import isBoolean from "lodash/isBoolean";
 import isEmpty from "lodash/isEmpty";
 import isEqual from "lodash/isEqual";
@@ -96,6 +97,7 @@ export {
 	camelCase,
 	cloneDeep,
 	groupBy,
+	inRange,
 	isBoolean,
 	isEmpty,
 	isEqual,


Test patch: