diff --git a/README.md b/README.md index 2994263..06eec09 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Here's what it does: * Displays guest-origin stats per property, showing where upcoming guests are arriving from. * Shows the gender split (male, female, solo travelers) and age group distribution of past guests. * Checks room availability by type (mixed dorms, female-only dorms, private rooms) and their capacity. +* Tracks recent rating trends, comparing a property's recent review score against its all-time average. * Assigns demographic badges like "Great for Solo", "Female Friendly", "Young Crowd", and more. * Offers a custom filter system to narrow down properties by badge, demographics, and age groups. diff --git a/src/app/DTOs/ReviewMetrics.ts b/src/app/DTOs/ReviewMetrics.ts index ca158d3..60048d3 100644 --- a/src/app/DTOs/ReviewMetrics.ts +++ b/src/app/DTOs/ReviewMetrics.ts @@ -8,6 +8,9 @@ export class ReviewMetrics { private solo!: number private total!: number private ages!: Record + private recentOverallRating!: number | null + private recentReviewCount!: number + private overallRating!: number | null constructor (attributes: PropertyReviews) { Object.assign(this, attributes) @@ -56,4 +59,16 @@ export class ReviewMetrics { public getAgePercentage (age: string): number { return toPercent(this.ages[age] ?? 0, this.getTotal()) } + + public getRecentOverallRating (): number | null { + return this.recentOverallRating + } + + public getRecentReviewCount (): number { + return this.recentReviewCount + } + + public getOverallRating (): number | null { + return this.overallRating + } } diff --git a/src/app/Listeners/PropertiesInterceptedListener.ts b/src/app/Listeners/PropertiesInterceptedListener.ts index 4719483..ac055ce 100644 --- a/src/app/Listeners/PropertiesInterceptedListener.ts +++ b/src/app/Listeners/PropertiesInterceptedListener.ts @@ -18,7 +18,11 @@ export class PropertiesInterceptedListener extends AbstractListener { for (const property of properties) { await PropertyCardRenderer.render(property.id, property.name) - this.emit('worker:task:dispatch', 'fetch:reviews', property.id, property.name) + const overallRating: number | null = property.overallRating + ? Number((property.overallRating.overall / 10).toFixed(1)) + : null + + this.emit('worker:task:dispatch', 'fetch:reviews', property.id, property.name, overallRating) this.emit('worker:task:dispatch', 'fetch:availability', property.id, property.name, from, to) this.emit('worker:task:dispatch', 'fetch:countries', property.id, property.name, from, to) } diff --git a/src/app/Services/Hostelworld/Api/ReviewsClient.ts b/src/app/Services/Hostelworld/Api/ReviewsClient.ts index 5c7bb1e..7c18eca 100644 --- a/src/app/Services/Hostelworld/Api/ReviewsClient.ts +++ b/src/app/Services/Hostelworld/Api/ReviewsClient.ts @@ -9,6 +9,9 @@ export type PropertyReviews = { solo: number total: number ages: Record + recentOverallRating: number | null + recentReviewCount: number + overallRating: number | null } export class ReviewsClient { @@ -16,38 +19,56 @@ export class ReviewsClient { 'properties/{property}/reviews/?page={page}&sort=newest&allLanguages=true&monthCount=72&per-page=50' private static readonly ageBrackets: string[] = ['18-24', '25-30', '31-40', '41+'] + private static readonly recentMonthsWindow: number = 12 + private static readonly recentReviewsLimit: number = 25 - public static async fetch (propertyId: number): Promise { - const metrics: PropertyReviews = { + public static async fetch (propertyId: number, overallRating: number | null): Promise { + const { reviews, reviewStatistics, pagination } = await this.request(propertyId, 1) + const remainingReviews: Review[] = await this.fetchRemainingPages(propertyId, pagination.numberOfPages) + const allReviews: Review[] = [...reviews, ...remainingReviews] + + const metrics: PropertyReviews = this.emptyMetrics(overallRating) + metrics.total = pagination.totalNumberOfItems + metrics.solo = Math.round(metrics.total * ((reviewStatistics?.soloPercentage ?? 0) / 100)) + + this.collectDemographics(allReviews, metrics) + this.collectRecentRating(allReviews, metrics) + + return metrics + } + + private static emptyMetrics (overallRating: number | null): PropertyReviews { + return { male: 0, female: 0, other: 0, solo: 0, total: 0, - ages: Object.fromEntries( - this.ageBrackets.map(bracket => [bracket, 0]) - ) + ages: Object.fromEntries(this.ageBrackets.map(bracket => [bracket, 0])), + recentOverallRating: null, + recentReviewCount: 0, + overallRating } + } - const { reviews: firstPageReviews, reviewStatistics, pagination } = await this.request(propertyId, 1) - - metrics.total = pagination.totalNumberOfItems - metrics.solo = Math.round(metrics.total * ((reviewStatistics?.soloPercentage ?? 0) / 100)) + private static async fetchRemainingPages (propertyId: number, totalPages: number): Promise { + const remainingPages: number = totalPages - 1 - const leftOverPages: number = pagination.numberOfPages - 1 - const restOfPagesReviews: Review[] = Array.from(await Promise.all( + const pages: Review[][] = await Promise.all( Array - .from({ length: leftOverPages }, (_, index) => index + 2) + .from({ length: remainingPages }, (_, index) => index + 2) .map(async page => { await delay(randomNumber(1, 5) * 100) const { reviews } = await this.request(propertyId, page) return reviews }) - )).flat() + ) - const reviews: Review[] = [...firstPageReviews, ...restOfPagesReviews] + return pages.flat() + } + private static collectDemographics (reviews: Review[], metrics: PropertyReviews): void { for (const review of reviews) { metrics.male += Number(['MALE', 'ALLMALEGROUP'].includes(review.groupInformation.groupTypeCode)) metrics.female += Number(['FEMALE', 'ALLFEMALEGROUP'].includes(review.groupInformation.groupTypeCode)) @@ -56,8 +77,27 @@ export class ReviewsClient { const age: string = review.groupInformation.age metrics.ages[age] = (metrics.ages[age] ?? 0) + 1 } + } - return metrics + private static collectRecentRating (reviews: Review[], metrics: PropertyReviews): void { + const cutoffDate: Date = new Date() + cutoffDate.setMonth(cutoffDate.getMonth() - this.recentMonthsWindow) + const cutoffTimestamp: number = cutoffDate.getTime() + let ratingSum: number = 0 + + for (const review of reviews) { + if (metrics.recentReviewCount >= this.recentReviewsLimit) break + + const reviewTimestamp: number = new Date(review.date).getTime() + if (reviewTimestamp < cutoffTimestamp) break + + ratingSum += review.rating.overall + metrics.recentReviewCount++ + } + + if (metrics.recentReviewCount > 0) { + metrics.recentOverallRating = Number((ratingSum / metrics.recentReviewCount / 10).toFixed(1)) + } } private static async request (propertyId: number, page: number): Promise { diff --git a/src/app/Types/HostelworldPropertyReviews.ts b/src/app/Types/HostelworldPropertyReviews.ts index be1d668..a9579ec 100644 --- a/src/app/Types/HostelworldPropertyReviews.ts +++ b/src/app/Types/HostelworldPropertyReviews.ts @@ -61,7 +61,7 @@ type GroupInformation = { export type Review = { id: string - date: Date + date: string notes: string isMachineTranslated: boolean languageCode: string diff --git a/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts b/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts index b2f309a..24255f4 100644 --- a/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts +++ b/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts @@ -46,7 +46,8 @@ export class PropertyCardRenderer { this.view.update?.({ propertyId, reviews: PropertyCardViewDTOFactory.reviewsRow(metrics), - ageGroups: PropertyCardViewDTOFactory.ageGroupsRow(metrics) + ageGroups: PropertyCardViewDTOFactory.ageGroupsRow(metrics), + recentRating: PropertyCardViewDTOFactory.recentRatingRow(metrics) }) } diff --git a/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts b/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts index 82fbfb5..1c56f30 100644 --- a/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts +++ b/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts @@ -2,13 +2,20 @@ import type { Property } from 'DTOs/Property' import type { ReviewMetrics } from 'DTOs/ReviewMetrics' import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' -export type MetricType = 'reviews' | 'availability' | 'ageGroups' +export type MetricType = 'reviews' | 'availability' | 'ageGroups' | 'recentRating' + +export type TrendDirection = 'up' | 'down' | 'flat' export type MetricValueViewDTO = { label: string value: string } +export type RecentRatingViewDTO = { + items: MetricValueViewDTO[] + trendDirection: TrendDirection | null +} + export type MetricRowViewDTO = { title: string items: MetricValueViewDTO[] @@ -19,18 +26,21 @@ export type PropertyCardViewDTO = { reviews?: MetricRowViewDTO | null availability?: MetricRowViewDTO | null ageGroups?: MetricRowViewDTO | null + recentRating?: RecentRatingViewDTO | null } export class PropertyCardLabels { public static readonly reviews: ReadonlyArray = ['Male', 'Female', 'Other', 'Solo'] public static readonly availability: ReadonlyArray = ['Mixed', 'Female', 'Private', 'Guests'] public static readonly ageGroups: ReadonlyArray = ['18-24', '25-30', '31-40', '41+'] + public static readonly recentRating: ReadonlyArray = ['Score', 'Trend', 'Reviews', 'Period'] public static forType (metricType: MetricType): ReadonlyArray { switch (metricType) { case 'reviews': return this.reviews case 'availability': return this.availability case 'ageGroups': return this.ageGroups + case 'recentRating': return this.recentRating } } @@ -39,6 +49,7 @@ export class PropertyCardLabels { case 'reviews': return 'Reviews' case 'availability': return 'Availability' case 'ageGroups': return 'Age Groups' + case 'recentRating': return 'Recent Rating' } } } @@ -54,7 +65,8 @@ export class PropertyCardViewDTOFactory { propertyId, reviews: undefined, availability: undefined, - ageGroups: undefined + ageGroups: undefined, + recentRating: undefined } } @@ -63,7 +75,8 @@ export class PropertyCardViewDTOFactory { propertyId: property.getId(), reviews: this.reviewsRow(property.getReviewMetrics()), availability: this.availabilityRow(property.getAvailabilityMetrics()), - ageGroups: this.ageGroupsRow(property.getReviewMetrics()) + ageGroups: this.ageGroupsRow(property.getReviewMetrics()), + recentRating: this.recentRatingRow(property.getReviewMetrics()) } } @@ -116,4 +129,41 @@ export class PropertyCardViewDTOFactory { })) } } + + public static recentRatingRow (metrics: ReviewMetrics): RecentRatingViewDTO { + const recent: number | null = metrics.getRecentOverallRating() + const hasRating: boolean = recent !== null + + return { + items: [ + { label: 'Score', value: hasRating ? String(recent) : 'N/A' }, + { label: 'Trend', value: this.trendLabel(recent, metrics.getOverallRating()) }, + { label: 'Reviews', value: hasRating ? String(metrics.getRecentReviewCount()) : '0' }, + { label: 'Period', value: '12 months' } + ], + trendDirection: this.trendDirection(recent, metrics.getOverallRating()) + } + } + + private static trendLabel (recent: number | null, allTime: number | null): string { + if (recent === null || allTime === null) return '—' + + const difference: number = Number((recent - allTime).toFixed(1)) + + if (difference > 0) return `+${difference}` + if (difference < 0) return String(difference) + + return '0.0' + } + + private static trendDirection (recent: number | null, allTime: number | null): TrendDirection | null { + if (recent === null || allTime === null) return null + + const difference: number = Number((recent - allTime).toFixed(1)) + + if (difference > 0) return 'up' + if (difference < 0) return 'down' + + return 'flat' + } } diff --git a/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx b/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx index 066574a..764d247 100644 --- a/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx +++ b/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx @@ -18,7 +18,6 @@ export function MetricsRow (properties: Properties): JSX.Element {
{title} -
{(label, index) => ( diff --git a/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx b/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx index f19f65c..ad89b5e 100644 --- a/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx +++ b/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx @@ -1,19 +1,22 @@ import { createSignal, createMemo, type Setter, type JSX } from 'solid-js' -import type { MetricRowViewDTO } from 'UI/Renderers/PropertyCard/ViewDTOs' +import type { MetricRowViewDTO, RecentRatingViewDTO } from 'UI/Renderers/PropertyCard/ViewDTOs' import { PropertyCardNotes } from 'UI/Renderers/PropertyCard/ViewDTOs' import { MetricsRow } from './MetricsRow' +import { RecentRating } from './RecentRating' import { Note } from './Note' export type CardState = { reviews?: MetricRowViewDTO | null availability?: MetricRowViewDTO | null ageGroups?: MetricRowViewDTO | null + recentRating?: RecentRatingViewDTO | null } export type CardStateSetters = { setReviews: Setter setAvailability: Setter setAgeGroups: Setter + setRecentRating: Setter } type Properties = { @@ -32,11 +35,15 @@ export function PropertyCardContainer (properties: Properties): JSX.Element { const [ageGroups, setAgeGroups] = createSignal( properties.initialState.ageGroups ) + const [recentRating, setRecentRating] = createSignal( + properties.initialState.recentRating + ) - properties.onStateReady({ setReviews, setAvailability, setAgeGroups }) + properties.onStateReady({ setReviews, setAvailability, setAgeGroups, setRecentRating }) const isFinalized = createMemo(() => - reviews() !== undefined && availability() !== undefined && ageGroups() !== undefined + reviews() !== undefined && availability() !== undefined && + ageGroups() !== undefined && recentRating() !== undefined ) const isLoading = createMemo(() => !isFinalized()) const note = createMemo(() => isFinalized() ? PropertyCardNotes.finalized : PropertyCardNotes.loading) @@ -44,6 +51,7 @@ export function PropertyCardContainer (properties: Properties): JSX.Element { return ( <>
+ diff --git a/src/app/UI/SolidJS/PropertyCard/Components/RecentRating.tsx b/src/app/UI/SolidJS/PropertyCard/Components/RecentRating.tsx new file mode 100644 index 0000000..5191821 --- /dev/null +++ b/src/app/UI/SolidJS/PropertyCard/Components/RecentRating.tsx @@ -0,0 +1,45 @@ +import { Show, For, type JSX } from 'solid-js' +import type { RecentRatingViewDTO } from 'UI/Renderers/PropertyCard/ViewDTOs' +import { PropertyCardLabels } from 'UI/Renderers/PropertyCard/ViewDTOs' + +type Properties = { + data?: RecentRatingViewDTO | null +} + +export function RecentRating (properties: Properties): JSX.Element { + const title: string = PropertyCardLabels.titleForType('recentRating') + const labels: ReadonlyArray = PropertyCardLabels.forType('recentRating') + + function isDisabled (): boolean { return properties.data === null } + function isLoaded (): boolean { return properties.data !== null && properties.data !== undefined } + + function valueClass (index: number): string { + const isTrendColumn: boolean = index === 1 + + return isTrendColumn && properties.data?.trendDirection + ? `hor-value hor-trend hor-trend-${properties.data.trendDirection}` + : 'hor-value' + } + + return ( + +
+
+ {title} +
+ + {(label, index) => ( +
+
{label}
+
+ }> + {properties.data?.items[index()]?.value ?? ''} + +
+
+ )} +
+
+
+ ) +} diff --git a/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts b/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts index 13c0d2e..6d5f93b 100644 --- a/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts +++ b/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts @@ -16,7 +16,8 @@ export class PropertyCardView implements ViewAdapterInterface { @@ -12,8 +12,8 @@ export class FetchReviewsTask extends AbstractQueuedTask { } protected async execute (args: Args): Promise { - const [propertyId, propertyName]: Args = args - const data: PropertyReviews = await ReviewsClient.fetch(propertyId) + const [propertyId, propertyName, overallRating]: Args = args + const data: PropertyReviews = await ReviewsClient.fetch(propertyId, overallRating) return { propertyId, propertyName, data } } diff --git a/src/assets/styles/app.scss b/src/assets/styles/app.scss index 794a8c2..9deff84 100644 --- a/src/assets/styles/app.scss +++ b/src/assets/styles/app.scss @@ -60,12 +60,12 @@ .hor-metrics-grid { width: 100%; display: grid; - line-height: 1.2rem; + line-height: 1.1rem; grid-column: 1 / -1; grid-template-columns: fit-content(100%) repeat(4, auto); contain: layout; content-visibility: auto; - contain-intrinsic-size: auto 80px; + contain-intrinsic-size: auto 60px; .hor-row { display: contents; @@ -76,18 +76,27 @@ display: flex; align-items: center; flex-direction: column; - justify-content: space-between; + justify-content: center; + gap: 1px; &.hor-title { - font-size: 0.85rem; + font-size: 0.75rem; grid-column: 1; - color: var(--wds-color-ink-darker); + color: var(--wds-color-ink); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; } &.hor-item { font-size: 0.8rem; .hor-label { + font-size: 0.75rem; + line-height: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; color: var(--wds-color-ink-darker); } @@ -104,13 +113,26 @@ border-bottom: 0.0625rem solid var(--wds-color-ink-lighter); } } + + &.hor-rating-row { + .hor-rating-score { + font-weight: 700; + color: var(--wds-color-ink-darker); + } + + .hor-trend { + &.hor-trend-up { color: #16a34a; } + &.hor-trend-down { color: #dc2626; } + &.hor-trend-flat { color: var(--wds-color-ink); } + } + } } } .hor-note { text-align: center; font-size: 0.8rem; - padding: 0.2rem; + padding: 0.15rem; color: var(--wds-color-ink); border-top: 0.0625rem solid var(--wds-color-ink-lighter); @@ -120,7 +142,7 @@ } .hor-skeleton-pulse { - height: 1rem; + height: 0.85rem; width: 3rem; margin: 0 auto; background: linear-gradient(90deg, var(--wds-color-ink-lighter) 25%, rgba(255, 255, 255, 0.5) 50%, var(--wds-color-ink-lighter) 75%); @@ -155,13 +177,20 @@ } @media (max-width: 520px) { - .property-card .hor-metrics-grid .hor-row .hor-title, + .property-card .hor-metrics-grid .hor-row .hor-title { + font-size: 0.6rem; + } + .property-card .hor-metrics-grid .hor-row .hor-item { - font-size: calc(100% - 5%); + font-size: 0.8rem; + } + + .property-card .hor-metrics-grid .hor-row .hor-item .hor-label { + font-size: 0.65rem; } .property-card .hor-metrics-grid .hor-row .hor-item .hor-value { - font-size: calc(100% - 40%); + font-size: 0.75rem; } }