Repository: ForumMagnum/ForumMagnum
Pull request #10689: Add button to BestOfLessWrongAnnouncement
Author: login: Raemon
Created: 2025-04-06T18:37:05Z | Merged: 2025-04-08T21:19:31Z

Adds a "view all" button that links to /bestoflesswrong page, and cleans up mobile styling.

<img width="1540" alt="image" src="https://github.com/user-attachments/assets/f4c8278e-942e-46e4-8501-4fb01d20c42e" />

<img width="501" alt="image" src="https://github.com/user-attachments/assets/51927bc9-115a-4254-9f24-75bed612f1d5" />


Diff:
diff --git a/packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx b/packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx
index 09fc5117836..99db3d9c624 100644
--- a/packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx
+++ b/packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx
@@ -9,47 +9,82 @@ import { Link } from '@/lib/reactRouterWrapper';
 import { postGetPageUrl } from '@/lib/collections/posts/helpers';
 
 const styles = defineStyles("BestOfLessWrongAnnouncement", (theme: ThemeType) => ({ 
+  titleContainer: {
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+  },
   title: {
     ...theme.typography.title,
-    fontSize: 50,
+    fontSize: 40,
+    marginTop: 12,
     marginBottom: 16,
+    display: 'block',
+    [theme.breakpoints.down('sm')]: {
+      fontSize: 40,
+    },
+  },
+  viewAllLink: {
+    ...theme.typography.body1,
+    fontSize: '1.1rem',
+    ...theme.typography.commentStyle,
+    textTransform: 'uppercase',
+    color: theme.palette.grey[700],
+    position: 'relative',
+    top: 2,
+    [theme.breakpoints.down('sm')]: {
+      paddingRight: 14,
+    },
   },
   categoriesContainer: {
     display: "flex",
     flexDirection: "row",
     justifyContent: "space-between",
     flexWrap: "wrap",
-    '&:hover $item': {
-      opacity: 1,
+    '&:hover $categoryTitle': {
+      [theme.breakpoints.up('md')]: {
+        opacity: 0,
+      },
+    },
+    '&:hover $categoryImage': {
+      [theme.breakpoints.up('md')]: {
+        filter: "brightness(0.5) saturate(.5)",
+      },
     },
     '&:hover $winnerItem': {
-      opacity: 1,
-      borderBottom: theme.palette.border.grey200,
+      [theme.breakpoints.up('md')]: {
+        opacity: 1,
+        borderBottom: theme.palette.border.grey200,
+      },
     },
     '&:hover $winnerItem:last-child': {
-      borderBottom: 'none',
+      [theme.breakpoints.up('md')]: {
+        borderBottom: 'none',
+      },
     },
     '&:hover $winnerTitle': {
-      opacity: 1,
-    },
-    '&:hover $categoryImage': {
-      filter: "brightness(0.5) saturate(.5)",
-    },
-    '&:hover $categoryTitle': {
-      opacity: 0,
+      [theme.breakpoints.up('md')]: {
+        opacity: 1,
+      },
     },
     '&:hover $winnerCategoryRank': {
-      opacity: 1,
+      [theme.breakpoints.up('md')]: {
+        opacity: 1,
+      },
     },
   },
-  winnersContainer: {
-    display: "flex",
-    flexDirection: "column",
-    width: "100%",
-    height: "100%",
-    position: "absolute",
-    top: 0,
-    left: 0,
+  category: {
+    width: "calc(16.6% - 3px)",
+    overflow: "hidden",
+    marginBottom: 8,
+    position: "relative",
+    cursor: "pointer",
+    '&:hover': {
+      opacity: 1
+    },
+    [theme.breakpoints.down('sm')]: {
+      width: "calc(33.3% - 3px)",
+    },
   },
   categoryImage: {
     width: "100%",
@@ -59,26 +94,22 @@ const styles = defineStyles("BestOfLessWrongAnnouncement", (theme: ThemeType) =>
     borderRadius: 2,
     filter: "brightness(0.85) saturate(1)",
     transition: "opacity 0.2s ease-in-out",
+    [theme.breakpoints.down('sm')]: {
+      height: 180,
+    },
   },
   categoryImageContainer: {
     height: 360,
     width: "100%",
     overflow: "hidden",
     position: "relative",
+    [theme.breakpoints.down('sm')]: {
+      height: 180,
+    },
   },
   categoryImageHover: {
     opacity: 0,
   },
-  category: {
-    width: "calc(16% - 2px)",
-    overflow: "hidden",
-    marginBottom: 8,
-    position: "relative",
-    cursor: "pointer",
-    '&:hover': {
-      opacity: 1
-    },
-  },
   categoryTitle: {
     ...theme.typography.body1,
     fontSize: 18,
@@ -96,6 +127,18 @@ const styles = defineStyles("BestOfLessWrongAnnouncement", (theme: ThemeType) =>
     backdropFilter: 'blur(1px)',
     textShadow: `0 0 4px ${theme.palette.greyAlpha(.8)}, 0 0 8px ${theme.palette.greyAlpha(.2)}`,
   },
+  winnersContainer: {
+    display: "flex",
+    flexDirection: "column",
+    width: "100%",
+    height: "100%",
+    position: "absolute",
+    top: 0,
+    left: 0,
+    [theme.breakpoints.down('sm')]: {
+      display: 'none',
+    },
+  },
   winnerItem: {
     width: "100%",
     height: 120,
@@ -162,6 +205,9 @@ const styles = defineStyles("BestOfLessWrongAnnouncement", (theme: ThemeType) =>
     left: 8,
     opacity: 0,
     textShadow: `0 0 4px ${theme.palette.greyAlpha(.8)}`,
+    [theme.breakpoints.down('sm')]: {
+      display: 'none',
+    },
   },
   winnerImageBackground: {
     position: "absolute",
@@ -172,7 +218,18 @@ const styles = defineStyles("BestOfLessWrongAnnouncement", (theme: ThemeType) =>
     backgroundColor: theme.palette.background.paper,
     opacity: 0,
     zIndex: 1,
-  }
+  },
+  mobileLink: {
+    position: "absolute",
+    top: 0,
+    left: 0,
+    width: "100%",
+    height: "100%",
+    zIndex: 1,
+    [theme.breakpoints.up('md')]: {
+      display: "none"
+    }
+  },
 }));
 
 const BestOfLessWrongAnnouncement = () => {
@@ -221,14 +278,20 @@ const BestOfLessWrongAnnouncement = () => {
   return (
     <AnalyticsContext pageSectionContext="bestOfLessWrongAnnouncement">
       <SingleColumnSection>
-        <div className={classes.title}>
-          Best of LessWrong {REVIEW_YEAR}
+        <div className={classes.titleContainer}>
+          <Link to={`/bestoflesswrong`} className={classes.title}>
+            Best of LessWrong {REVIEW_YEAR}
+          </Link>
+          <Link to={`/bestoflesswrong`} className={classes.viewAllLink}> 
+            View All 
+          </Link>
         </div>
         <div className={classes.categoriesContainer}>
           {Object.keys(sections).map(category => {
             const section = sections[category as keyof typeof sections];
             return <div className={classes.category} key={category}>
               <div className={classes.categoryImageContainer}>
+                <Link to={`/bestoflesswrong?category=${category}`} className={classes.mobileLink}/>
                 <img src={section.img} className={classes.categoryImage}/>
                 <div className={classes.winnersContainer}> 
                   {section.topThree?.map(({post, _id}, index) => {
diff --git a/packages/lesswrong/server/reviewVoteUpdate.ts b/packages/lesswrong/server/reviewVoteUpdate.ts
index 94613baebdc..a8103b687be 100644
--- a/packages/lesswrong/server/reviewVoteUpdate.ts
+++ b/packages/lesswrong/server/reviewVoteUpdate.ts
@@ -349,6 +349,15 @@ const getReviewWinnerPosts = async () => {
   }, {sort: {finalReviewVoteScoreHighKarma: -1}, limit: 51}).fetch()
 }
 
+export const createReviewWinnerFromId = async (postId: string, idx: number, category: ReviewWinnerCategory) => {
+  const post = await Posts.findOne({_id: postId})
+  if (!post) {
+    throw new Error(`Post not found: ${postId}`)
+  }
+  const adminContext = createAdminContext();
+  return createReviewWinner(post, idx, category, adminContext)
+}
+
 const createReviewWinner = async (post: DbPost, idx: number, category: ReviewWinnerCategory, adminContext: ResolverContext) => { 
   return createMutator({
     collection: ReviewWinners,
@@ -388,3 +397,44 @@ export const createReviewWinners = async () => {
     return createReviewWinner(post, idx, category, adminContext)
   }))
 }
+
+// If you made any mistakes with the rank-order of the ReviewWinners (i.e. because you decided
+// to remove a post from the list), run this function to fix the rank orders.
+//
+// This is necessary because the reviewRanking has enforced uniqueness for rank+year, and
+// you can't edit an individual
+//
+// (This is similar but not identical to the updateCuratedOrder function in ReviewWinnersRepo,
+// which handles a similar case for the curatedOrder field, although only one post at a time)
+export const updateReviewWinnerRankings = async (year: number) => {
+  
+  const reviewWinners = await ReviewWinners.find({reviewYear: year}).fetch()
+  const postIds = reviewWinners.map(winner => winner.postId)
+  const posts = await Posts.find({ _id: { $in: postIds } }).fetch()
+
+  const postsById = Object.fromEntries(posts.map(post => [post._id, post]))
+
+  const sortedWinners = [...reviewWinners].sort((a, b) => {
+    const scoreA = postsById[a.postId]?.finalReviewVoteScoreHighKarma ?? 0;
+    const scoreB = postsById[b.postId]?.finalReviewVoteScoreHighKarma ?? 0;
+    return scoreB - scoreA; // Sort descending
+  });
+
+  // 4. Set temporary rankings in parallel
+  // (to avoid errors from the enforced uniqueness)
+  const tempRankStart = -10000;
+  await Promise.all(
+    sortedWinners.map((winner, i) => 
+      ReviewWinners.rawUpdateOne(
+        {_id: winner._id},
+        {$set: {reviewRanking: tempRankStart - i}}
+      )
+    )
+  );
+
+  sortedWinners.map((winner, i) => ReviewWinners.rawUpdateOne(
+      {_id: winner._id},
+      {$set: {reviewRanking: i}}
+    )
+  )
+};


Reviews:
user: login: b0b3rt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T19:53:14Z
user: login: b0b3rt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T19:53:57Z
user: login: b0b3rt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T19:54:52Z
user: login: b0b3rt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T19:55:15Z
user: login: Raemon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T20:24:11Z
user: login: Raemon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T20:30:00Z
user: login: b0b3rt
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-08T05:07:33Z

Review comments:
diff_hunk: @@ -221,8 +267,13 @@ const BestOfLessWrongAnnouncement = () => {
   return (
     <AnalyticsContext pageSectionContext="bestOfLessWrongAnnouncement">
       <SingleColumnSection>
-        <div className={classes.title}>
-          Best of LessWrong {REVIEW_YEAR}
+        <div className={classes.titleContainer}>
+          <Link to={`/bestoflesswrong`} className={classes.title}>
path: packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx
user: login: b0b3rt
body: Should this link contain something in the path for the indicated year?  (I'm a little confused by this link and the next link pointing to the exact same place.)
created_at: 2025-04-07T19:53:13Z
author_association: COLLABORATOR
diff_hunk: @@ -388,3 +397,54 @@ export const createReviewWinners = async () => {
     return createReviewWinner(post, idx, category, adminContext)
   }))
 }
+
+// If you made any mistakes with the rank-order of the ReviewWinners (i.e. because you decided
+// to remove a post from the list), run this function to fix the rank orders.
+//
+// This is necessary because the reviewRanking has enforced uniqueness for rank+year, and
+// you can't edit an individual
+//
+// (This is similar but not identical to the updateCuratedOrder function in ReviewWinnersRepo,
+// which handles a similar case for the curatedOrder field, although only one post at a time)
+export const updateReviewWinnerRankings = async (year: number) => {
+  const adminContext = createAdminContext();
+  
+  const [reviewWinners, posts] = await Promise.all([
+    ReviewWinners.find({reviewYear: year}).fetch(),
+    Posts.find({
+      _id: { $in: (await ReviewWinners.find({reviewYear: year}, {projection: {postId: 1}}).fetch())
+          .map(winner => winner.postId)
+      }
+    }).fetch()
+  ]);
+  
+  const postsById = Object.fromEntries(posts.map(post => [post._id, post]));
+  
+  const sortedWinners = [...reviewWinners].sort((a, b) => {
+    const scoreA = postsById[a.postId]?.finalReviewVoteScoreHighKarma ?? 0;
+    const scoreB = postsById[b.postId]?.finalReviewVoteScoreHighKarma ?? 0;
+    return scoreB - scoreA; // Sort descending
+  });
+
+  // 4. Set temporary rankings in parallel
+  // (we need to do this to )
path: packages/lesswrong/server/reviewVoteUpdate.ts
user: login: b0b3rt
body: ?
created_at: 2025-04-07T19:53:57Z
author_association: COLLABORATOR
diff_hunk: @@ -388,3 +397,54 @@ export const createReviewWinners = async () => {
     return createReviewWinner(post, idx, category, adminContext)
   }))
 }
+
+// If you made any mistakes with the rank-order of the ReviewWinners (i.e. because you decided
+// to remove a post from the list), run this function to fix the rank orders.
+//
+// This is necessary because the reviewRanking has enforced uniqueness for rank+year, and
+// you can't edit an individual
+//
+// (This is similar but not identical to the updateCuratedOrder function in ReviewWinnersRepo,
+// which handles a similar case for the curatedOrder field, although only one post at a time)
+export const updateReviewWinnerRankings = async (year: number) => {
+  const adminContext = createAdminContext();
+  
+  const [reviewWinners, posts] = await Promise.all([
+    ReviewWinners.find({reviewYear: year}).fetch(),
+    Posts.find({
+      _id: { $in: (await ReviewWinners.find({reviewYear: year}, {projection: {postId: 1}}).fetch())
path: packages/lesswrong/server/reviewVoteUpdate.ts
user: login: b0b3rt
body: if you're fetching posts based on the review winners, it doesn't make sense to try to parallelize the two queries; you're just making an extra db call for no reason
created_at: 2025-04-07T19:54:52Z
author_association: COLLABORATOR
diff_hunk: @@ -388,3 +397,54 @@ export const createReviewWinners = async () => {
     return createReviewWinner(post, idx, category, adminContext)
   }))
 }
+
+// If you made any mistakes with the rank-order of the ReviewWinners (i.e. because you decided
+// to remove a post from the list), run this function to fix the rank orders.
+//
+// This is necessary because the reviewRanking has enforced uniqueness for rank+year, and
+// you can't edit an individual
+//
+// (This is similar but not identical to the updateCuratedOrder function in ReviewWinnersRepo,
+// which handles a similar case for the curatedOrder field, although only one post at a time)
+export const updateReviewWinnerRankings = async (year: number) => {
+  const adminContext = createAdminContext();
+  
+  const [reviewWinners, posts] = await Promise.all([
+    ReviewWinners.find({reviewYear: year}).fetch(),
+    Posts.find({
+      _id: { $in: (await ReviewWinners.find({reviewYear: year}, {projection: {postId: 1}}).fetch())
path: packages/lesswrong/server/reviewVoteUpdate.ts
user: login: b0b3rt
body: (not a blocker since I assume this is a rarely-run script, but)
created_at: 2025-04-07T19:55:15Z
author_association: COLLABORATOR
diff_hunk: @@ -221,8 +267,13 @@ const BestOfLessWrongAnnouncement = () => {
   return (
     <AnalyticsContext pageSectionContext="bestOfLessWrongAnnouncement">
       <SingleColumnSection>
-        <div className={classes.title}>
-          Best of LessWrong {REVIEW_YEAR}
+        <div className={classes.titleContainer}>
+          <Link to={`/bestoflesswrong`} className={classes.title}>
path: packages/lesswrong/components/posts/PostsPage/BestOfLessWrong/BestOfLessWrongAnnouncement.tsx
user: login: Raemon
body: It autodirects to the current year so it doesn't really matter.
created_at: 2025-04-07T20:24:11Z
author_association: COLLABORATOR
diff_hunk: @@ -388,3 +397,54 @@ export const createReviewWinners = async () => {
     return createReviewWinner(post, idx, category, adminContext)
   }))
 }
+
+// If you made any mistakes with the rank-order of the ReviewWinners (i.e. because you decided
+// to remove a post from the list), run this function to fix the rank orders.
+//
+// This is necessary because the reviewRanking has enforced uniqueness for rank+year, and
+// you can't edit an individual
+//
+// (This is similar but not identical to the updateCuratedOrder function in ReviewWinnersRepo,
+// which handles a similar case for the curatedOrder field, although only one post at a time)
+export const updateReviewWinnerRankings = async (year: number) => {
+  const adminContext = createAdminContext();
+  
+  const [reviewWinners, posts] = await Promise.all([
+    ReviewWinners.find({reviewYear: year}).fetch(),
+    Posts.find({
+      _id: { $in: (await ReviewWinners.find({reviewYear: year}, {projection: {postId: 1}}).fetch())
path: packages/lesswrong/server/reviewVoteUpdate.ts
user: login: Raemon
body: Ah gotcha. I went and fixed it.
created_at: 2025-04-07T20:30:00Z
author_association: COLLABORATOR

PR comments:
user: login: Discordius
created_at: 2025-04-06T18:39:22Z
body: Oh no, I don't like it
author_association: COLLABORATOR
user: login: Discordius
created_at: 2025-04-06T18:39:30Z
body: Can you just add it to the top right instead?
author_association: COLLABORATOR
user: login: Raemon
created_at: 2025-04-06T19:02:00Z
body: So far I dislike all versions of that I've tried but if you like it better seems fine:

<img width="1540" alt="image" src="https://github.com/user-attachments/assets/a68a7b66-e8d5-4600-ad24-80feca041745" />


author_association: COLLABORATOR
user: login: jpaddison3
created_at: 2025-04-08T21:04:28Z
body: No objection
author_association: COLLABORATOR