~timharek/timharek.no

7c86af35fba54c363cf481d33f1243722f4e3401 — Tim Hårek Andreassen a month ago 867990d
feat(watched): Add average rating

Signed-off-by: Tim Hårek Andreassen <tim@harek.no>
2 files changed, 61 insertions(+), 2 deletions(-)

M routes/logs/[slug].tsx
M src/schemas.ts
M routes/logs/[slug].tsx => routes/logs/[slug].tsx +55 -0
@@ 12,6 12,7 @@ import {
  GameEntry,
  Log,
  MovieEntry,
  RATING_MAX,
  Review,
} from "../../src/schemas.ts";
import { z } from "zod";


@@ 120,6 121,10 @@ export default function Page({ data }: PageProps<LogProps>) {
    (entry) => new Date(entry.date).getFullYear(),
  );

  const isWatched = entries.some((entry) =>
    entry.type === "movie" || entry.type === "tv"
  );

  return (
    <>
      <Head>


@@ 131,6 136,7 @@ export default function Page({ data }: PageProps<LogProps>) {
          data-dark-theme="dark"
        >
          <PageHeader title={page.title} updated={page.updatedAt} />
          {isWatched && <WatchedStats entries={entries} />}
          <div
            class="markdown-body mb-4"
            dangerouslySetInnerHTML={{ __html: page.html }}


@@ 158,6 164,55 @@ export default function Page({ data }: PageProps<LogProps>) {
  );
}

function WatchedStats({ entries }: { entries: Entry[] }) {
  const isWatched = entries.some((entry) =>
    entry.type === "movie" || entry.type === "tv"
  );
  if (!isWatched) return <></>;
  const averageRatingMovie = averageRating(entries, "movie");
  const averageRatingTV = averageRating(entries, "tv");
  return (
    <div class="flex flex-wrap gap-4 justify-between py-4">
      <RatingInfo rating={averageRatingMovie} text="Average movie rating" />
      <RatingInfo rating={averageRatingTV} text="Average TV rating" />
    </div>
  );
}

function averageRating(entries: Entry[], type: "movie" | "tv"): number {
  const scoreRaw = entries.filter((entry) => entry.type === type)
    .reduce((accumulator, entry, index, array) => {
      if (entry.type !== "movie" && entry.type !== "tv") {
        return accumulator;
      }
      accumulator += entry.review.rating;
      if (index === array.length - 1) {
        return accumulator / array.length;
      }
      return accumulator;
    }, 0);

  return Number(scoreRaw.toPrecision(2));
}

function RatingInfo({ rating, text }: { rating: number; text: string }) {
  const percentage = (rating / RATING_MAX) * 100;

  return (
    <div class="flex items-center gap-4">
      <div
        class="flex w-full max-w-32 items-center justify-center aspect-square rounded-full p-1.5 bg-primary"
        style={`background-image: conic-gradient(transparent, transparent ${percentage}%, #e7e8e8 ${percentage}%)`}
      >
        <span class="rounded-full w-full h-full bg-bg text-text flex justify-center items-center text-center text-3xl font-bold">
          {rating} / {RATING_MAX}
        </span>
      </div>
      <div class="text-2xl font-semibold">{text}</div>
    </div>
  );
}

interface ItemProps {
  item: Entry;
}

M src/schemas.ts => src/schemas.ts +6 -2
@@ 1,8 1,11 @@
import { z } from "zod";

export const RATING_MAX = 5;
export const RATING_MIN = 1;

const Review = z.object({
  rating: z.number(),
  comment: z.string().nullable(),
  rating: z.number().min(RATING_MIN).max(RATING_MAX),
  comment: z.string().optional().nullable(),
});

const MovieEntry = z.object({


@@ 80,3 83,4 @@ export type Review = z.infer<typeof Review>;
export type BookEntry = z.infer<typeof BookEntry>;
export type GameEntry = z.infer<typeof GameEntry>;
export type MovieEntry = z.infer<typeof MovieEntry>;
export type TVEntry = z.infer<typeof TVEntry>;