From ba393f11020102ef9e6db77828fa1463d6807d8c Mon Sep 17 00:00:00 2001 From: Wojtek Bazant Date: Sat, 4 Jan 2025 09:01:39 +0000 Subject: [PATCH] Revisit review summary --- src/components/entry/EntryOverview.js | 3 + src/components/entry/EntryReviews.js | 2 - src/components/entry/ReviewSummary.js | 245 ++++++++++++-------------- src/utils/createReviewSummary.ts | 83 +++++++++ 4 files changed, 202 insertions(+), 131 deletions(-) create mode 100644 src/utils/createReviewSummary.ts diff --git a/src/components/entry/EntryOverview.js b/src/components/entry/EntryOverview.js index 71c22555..940b36e3 100644 --- a/src/components/entry/EntryOverview.js +++ b/src/components/entry/EntryOverview.js @@ -21,6 +21,7 @@ import { ReportButton } from './overview/ReportButton' import Tags from './overview/Tags' import TypesHeader from './overview/TypesHeader' import { ReviewButton } from './ReviewButton' +import ReviewSummary from './ReviewSummary' import { formatISOString, formatMonth } from './textFormatters' const hasSeasonality = (locationData) => @@ -72,6 +73,7 @@ const EntryOverview = () => { locationId, location: locationData, pane, + reviews, } = useSelector((state) => state.location) const { locationsWithoutPanorama } = useSelector((state) => state.misc) const dispatch = useDispatch() @@ -200,6 +202,7 @@ const EntryOverview = () => {

+
diff --git a/src/components/entry/EntryReviews.js b/src/components/entry/EntryReviews.js index 44ce10ac..4068d3ca 100644 --- a/src/components/entry/EntryReviews.js +++ b/src/components/entry/EntryReviews.js @@ -6,7 +6,6 @@ import { useIsDesktop } from '../../utils/useBreakpoint' import { ReviewForm } from '../form/ReviewForm' import Review from './Review' import { ReviewButton } from './ReviewButton' -import ReviewSummary from './ReviewSummary' const EntryReviews = () => { const isDesktop = useIsDesktop() @@ -22,7 +21,6 @@ const EntryReviews = () => { } return ( <> - {!isDesktop && }

Reviews

{reviews.map((review) => { diff --git a/src/components/entry/ReviewSummary.js b/src/components/entry/ReviewSummary.js index d46921e1..77feee01 100644 --- a/src/components/entry/ReviewSummary.js +++ b/src/components/entry/ReviewSummary.js @@ -1,167 +1,154 @@ import { Star as StarEmpty } from '@styled-icons/boxicons-regular' import { Star, StarHalf } from '@styled-icons/boxicons-solid' -import { groupBy, prop as rProp } from 'ramda' import { useTranslation } from 'react-i18next' import styled from 'styled-components/macro' -import { formatMonth } from './textFormatters' +import { createReviewSummary } from '../../utils/createReviewSummary' -const SummaryTable = styled.table` - border-spacing: 0; - width: 100%; +const StatsContainer = styled.div` + display: flex; + flex-direction: column; + color: ${({ theme }) => theme.secondaryText}; margin-bottom: 1em; + gap: 4px; svg { - width: 1.2em; - height: 1.2em; - margin: -0.3em 0 0 0.3em; + width: 1em; + height: 1em; color: ${({ theme }) => theme.orange}; } +` - tbody { - line-height: 1.14; - } - - td:nth-child(1) { - font-size: 1rem; - color: ${({ theme }) => theme.tertiaryText}; - margin: 3px 0; - } - - td:nth-child(2) { - font-size: 1.14rem; - text-align: right; - } - - td > p { - margin-top: 0.5em; - font-size: 1rem; - } +const StatsRow = styled.div` + display: flex; + flex-wrap: wrap; +` - h3 { - margin-top: 0; +const Separator = styled.span` + margin: 0 0.5em; + &::before { + content: 'ยท'; } ` -const FruitingSummaryRow = ({ reviews }) => { - const { t, i18n } = useTranslation() - if (!reviews?.length) { +const formatMonthList = (months) => { + if (!months.length) { return null } - const reviewsByMonth = reviews.reduce((monthToCount, review) => { - if (!review.observed_on) { - return monthToCount - } - const month = new Date(review.observed_on).getMonth() - - monthToCount = { - ...monthToCount, - [month]: (monthToCount[month] || 0) + 1, - } - - return monthToCount + const monthCounts = months.reduce((acc, month) => { + acc[month] = (acc[month] || 0) + 1 + return acc }, {}) - const reviewMonthPairs = Object.entries(reviewsByMonth) - - return ( - - -

- {t(`locations.infowindow.fruiting.${reviews[0].fruiting}`)}:{' '} - {reviewMonthPairs - .map( - ([month, count]) => - `${formatMonth(month, i18n.language)} (${count})`, - ) - .join(', ')} -

- - - ) + const monthsStr = Object.entries(monthCounts) + .map(([month, count]) => { + const date = new Date(1, parseInt(month)) + const monthStr = date.toLocaleDateString(undefined, { + month: 'long', + }) + return `${monthStr} (${count})` + }) + .join(', ') + + return monthsStr } -const FruitingSummary = ({ reviews }) => { - const { - 0: flowerReviews, - 1: unripeReviews, - 2: ripeReviews, - } = groupBy(rProp('fruiting'), reviews) +const getStarRating = (score) => { + if (!score) { + return null + } - return ( - <> - - Fruiting - - - - - - ) -} + const stars = [] + const remainder = score % 1 + const fullStars = Math.floor(score) + 1 + + for (let i = 0; i < 5; i++) { + if (i < fullStars) { + stars.push() + } else if (i === fullStars && remainder >= 0.25 && remainder <= 0.75) { + stars.push() + } else { + stars.push() + } + } -const SummaryRow = ({ title, scores, total }) => { - const aggregateScore = scores.reduce((a, b) => a + b, 0) / scores.length - const percentScore = aggregateScore / total + return stars +} - let icon = +const ReviewSummary = ({ reviews }) => { + const { t } = useTranslation() + const summary = createReviewSummary(reviews) + const stats = [] + + if (summary.quality.average !== null) { + stats.push( + + {t('glossary.quality')} {getStarRating(summary.quality.average)} ( + {summary.quality.count}) + , + ) + } - if (percentScore <= 0.25) { - icon = - } else if (percentScore <= 0.5) { - icon = + if (summary.yield.average !== null) { + stats.push( + + {t('glossary.yield')} {getStarRating(summary.yield.average)} ( + {summary.yield.count}) + , + ) } - if (Number.isNaN(percentScore)) { - icon = null + const flowers = formatMonthList(summary.fruiting.flowers) + if (flowers) { + stats.push( + + {t('locations.infowindow.fruiting.0')} โ€“ {flowers} + , + ) } - return ( - - {title} - - {scores.length > 0 ? aggregateScore.toFixed(1) : <>—} - /{total} - {icon} - - - ) -} + const unripe = formatMonthList(summary.fruiting.unripe) + if (unripe) { + stats.push( + + {t('locations.infowindow.fruiting.1')} โ€“ {unripe} + , + ) + } -const ReviewSummary = ({ reviews }) => { - const { t } = useTranslation() - const qualityScores = reviews.reduce((scores, review) => { - if (review.quality_rating) { - return [...scores, review.quality_rating] - } - return scores - }, []) + const ripe = formatMonthList(summary.fruiting.ripe) + if (ripe) { + stats.push( + + {t('locations.infowindow.fruiting.2')} โ€“ {ripe} + , + ) + } - const yieldScores = reviews.reduce((scores, review) => { - if (review.yield_rating) { - return [...scores, review.yield_rating] - } - return scores - }, []) + if (stats.length === 0) { + return null + } return ( - -

Summary

- - - - - -
+ + + {[stats[0], stats[1]].filter(Boolean).map((stat, i) => ( + <> + {i > 0 && } + {stat} + + ))} + + + {stats.slice(2).map((stat, i) => ( + <> + {i > 0 && } + {stat} + + ))} + + ) } diff --git a/src/utils/createReviewSummary.ts b/src/utils/createReviewSummary.ts new file mode 100644 index 00000000..3213852b --- /dev/null +++ b/src/utils/createReviewSummary.ts @@ -0,0 +1,83 @@ +import { components } from './apiSchema' + +type Review = components['schemas']['Review'] + +interface ReviewSummary { + quality: { + average: number | null + count: number + } + yield: { + average: number | null + count: number + } + fruiting: { + flowers: number[] + unripe: number[] + ripe: number[] + } +} + +export const createReviewSummary = (reviews: Review[]): ReviewSummary => { + let qualitySum = 0 + let qualityCount = 0 + for (const review of reviews) { + if (review.quality_rating !== null) { + qualitySum += review.quality_rating || 0 + qualityCount++ + } + } + + let yieldSum = 0 + let yieldCount = 0 + for (const review of reviews) { + if (review.yield_rating !== null) { + yieldSum += review.yield_rating || 0 + yieldCount++ + } + } + + const validFruitingReviews = reviews.filter( + (review) => review.observed_on && review.fruiting !== null, + ) + + const fruitingByMonth = { + flowers: validFruitingReviews + .filter( + (review): review is Review & { observed_on: string } => + review.fruiting === 0 && review.observed_on !== null, + ) + .map((review) => new Date(review.observed_on).getMonth()) + .sort((a, b) => a - b), + unripe: validFruitingReviews + .filter( + (review): review is Review & { observed_on: string } => + review.fruiting === 1 && review.observed_on !== null, + ) + .map((review) => new Date(review.observed_on).getMonth()) + .sort((a, b) => a - b), + ripe: validFruitingReviews + .filter( + (review): review is Review & { observed_on: string } => + review.fruiting === 2 && review.observed_on !== null, + ) + .map((review) => new Date(review.observed_on).getMonth()) + .sort((a, b) => a - b), + } + + return { + quality: { + average: qualityCount > 0 ? qualitySum / qualityCount : null, + count: qualityCount, + }, + yield: { + average: yieldCount > 0 ? yieldSum / yieldCount : null, + count: yieldCount, + }, + fruiting: { + flowers: fruitingByMonth.flowers.sort((a, b) => a - b), + unripe: fruitingByMonth.unripe.sort((a, b) => a - b), + ripe: fruitingByMonth.ripe.sort((a, b) => a - b), + }, + } +}