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),
+ },
+ }
+}