Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions src/app/DTOs/ReviewMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export class ReviewMetrics {
private solo!: number
private total!: number
private ages!: Record<string, number>
private recentOverallRating!: number | null
private recentReviewCount!: number
private overallRating!: number | null

constructor (attributes: PropertyReviews) {
Object.assign(this, attributes)
Expand Down Expand Up @@ -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
}
}
6 changes: 5 additions & 1 deletion src/app/Listeners/PropertiesInterceptedListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
70 changes: 55 additions & 15 deletions src/app/Services/Hostelworld/Api/ReviewsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,66 @@ export type PropertyReviews = {
solo: number
total: number
ages: Record<string, number>
recentOverallRating: number | null
recentReviewCount: number
overallRating: number | null
}

export class ReviewsClient {
private static readonly endpoint: string = 'https://prod.apigee.hostelworld.com/legacy-hwapi-service/2.2/' +
'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<PropertyReviews> {
const metrics: PropertyReviews = {
public static async fetch (propertyId: number, overallRating: number | null): Promise<PropertyReviews> {
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<Review[]> {
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))
Expand All @@ -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<HostelworldPropertyReviews> {
Expand Down
2 changes: 1 addition & 1 deletion src/app/Types/HostelworldPropertyReviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type GroupInformation = {

export type Review = {
id: string
date: Date
date: string
notes: string
isMachineTranslated: boolean
languageCode: string
Expand Down
3 changes: 2 additions & 1 deletion src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down
56 changes: 53 additions & 3 deletions src/app/UI/Renderers/PropertyCard/ViewDTOs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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<string> = ['Male', 'Female', 'Other', 'Solo']
public static readonly availability: ReadonlyArray<string> = ['Mixed', 'Female', 'Private', 'Guests']
public static readonly ageGroups: ReadonlyArray<string> = ['18-24', '25-30', '31-40', '41+']
public static readonly recentRating: ReadonlyArray<string> = ['Score', 'Trend', 'Reviews', 'Period']

public static forType (metricType: MetricType): ReadonlyArray<string> {
switch (metricType) {
case 'reviews': return this.reviews
case 'availability': return this.availability
case 'ageGroups': return this.ageGroups
case 'recentRating': return this.recentRating
}
}

Expand All @@ -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'
}
}
}
Expand All @@ -54,7 +65,8 @@ export class PropertyCardViewDTOFactory {
propertyId,
reviews: undefined,
availability: undefined,
ageGroups: undefined
ageGroups: undefined,
recentRating: undefined
}
}

Expand All @@ -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())
}
}

Expand Down Expand Up @@ -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'
}
}
1 change: 0 additions & 1 deletion src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export function MetricsRow (properties: Properties): JSX.Element {
<div class={`hor-row${isLoaded() ? ' hor-loaded' : ''}`} data-metric={properties.metricType}>
<div class="hor-title">
<span>{title}</span>
<span>→</span>
</div>
<For each={[...labels]}>
{(label, index) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MetricRowViewDTO | null | undefined>
setAvailability: Setter<MetricRowViewDTO | null | undefined>
setAgeGroups: Setter<MetricRowViewDTO | null | undefined>
setRecentRating: Setter<RecentRatingViewDTO | null | undefined>
}

type Properties = {
Expand All @@ -32,18 +35,23 @@ export function PropertyCardContainer (properties: Properties): JSX.Element {
const [ageGroups, setAgeGroups] = createSignal<MetricRowViewDTO | null | undefined>(
properties.initialState.ageGroups
)
const [recentRating, setRecentRating] = createSignal<RecentRatingViewDTO | null | undefined>(
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)

return (
<>
<div class="hor-metrics-grid" data-property-id={properties.propertyId}>
<RecentRating data={recentRating()} />
<MetricsRow metricType="reviews" data={reviews()} />
<MetricsRow metricType="ageGroups" data={ageGroups()} />
<MetricsRow metricType="availability" data={availability()} />
Expand Down
45 changes: 45 additions & 0 deletions src/app/UI/SolidJS/PropertyCard/Components/RecentRating.tsx
Original file line number Diff line number Diff line change
@@ -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<string> = 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 (
<Show when={!isDisabled()}>
<div class={`hor-row hor-rating-row${isLoaded() ? ' hor-loaded' : ''}`}>
<div class="hor-title">
<span>{title}</span>
</div>
<For each={[...labels]}>
{(label, index) => (
<div class="hor-item">
<div class="hor-label">{label}</div>
<div class={valueClass(index())}>
<Show when={isLoaded()} fallback={<div class="hor-skeleton-pulse" />}>
{properties.data?.items[index()]?.value ?? ''}
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
)
}
Loading
Loading