Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4c75f55
Upgrade yarn version.
KaramanisDev Feb 1, 2026
4b158a4
Enable aggressive tree shaking via sideEffects.
KaramanisDev Feb 1, 2026
fd2dd01
Add circular dependency detection.
KaramanisDev Feb 1, 2026
37355c4
Revamp UI property card rendering with SolidJS.
KaramanisDev Feb 2, 2026
fe36449
Force display of mobile deals.
KaramanisDev Jan 27, 2026
0c3769f
Utilize modern sass compiler.
KaramanisDev Jan 28, 2026
235046e
Extend property card with age groups metric.
KaramanisDev Feb 2, 2026
57ebd7b
Display demographic badges on property cards.
KaramanisDev Feb 2, 2026
f3ea32b
Support custom extension filters.
KaramanisDev Mar 11, 2026
ec0bf6a
Prefer function declarations in UI components.
KaramanisDev Mar 11, 2026
bc232b6
Preserve element scope in waitForElement.
KaramanisDev Mar 11, 2026
1589d28
Guard regex match in search URL parser.
KaramanisDev Mar 11, 2026
c777215
Extract shared Vue component accessor.
KaramanisDev Mar 11, 2026
2ef041b
Strengthen DTO constructor types.
KaramanisDev Mar 11, 2026
90be037
Replace broad Function types.
KaramanisDev Mar 11, 2026
75db857
Apply CQS to mutate-and-return methods.
KaramanisDev Mar 11, 2026
eebee2c
Extract composition buffer for clean reset.
KaramanisDev Mar 11, 2026
b93b9d5
Extract shared Hostelworld API types.
KaramanisDev Mar 11, 2026
7540cc7
Remove redundant void operator.
KaramanisDev Mar 11, 2026
2999969
Use regex for exact property ID matching.
KaramanisDev Mar 11, 2026
10dfc30
Merge duplicate .metrics-grid selectors.
KaramanisDev Mar 11, 2026
c9cb983
Make note div more narrow.
KaramanisDev Mar 11, 2026
7f8a85f
Improve type safety across utils and API types.
KaramanisDev Mar 12, 2026
209d041
Lift EventBus out of FilterModal component.
KaramanisDev Mar 12, 2026
fd66d5a
Scope all extension CSS classes with hor- prefix.
KaramanisDev Mar 12, 2026
e92ecac
Extract PropertyFilterService from listener.
KaramanisDev Mar 12, 2026
a33285b
Update readme.
KaramanisDev Mar 12, 2026
0f75de1
Add a tip me option :)
KaramanisDev Mar 12, 2026
93fca50
Re-enable native pagination.
KaramanisDev Mar 12, 2026
bd77219
Speed up overall property composition.
KaramanisDev Mar 12, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ packages/*
node_modules/
yarn-error.log
package-lock.json
*.tsbuildinfo
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,40 @@

## Introduction

Hostelworld on Roids (H.O.R) is a browser extension designed to enhance your experience while searching and selecting
hostels.
Hostelworld on Roids (H.O.R) is an open source browser extension designed to enhance your experience
while searching and selecting hostels.

It eliminates obstructions from featured/promoted properties and unlocks hidden native features on hostelworld, such as
displaying guest-origin stats per property and searched cities. Moreover, it lists some properties which may be
unavailable due to scheduling conflicts, but would have been more ideal for you if your travel dates were more flexible.
It eliminates obstructions from featured/promoted properties and unlocks hidden native features on Hostelworld. Moreover,
it lists some properties which may be unavailable due to scheduling conflicts, but would have been more ideal for you if
your travel dates were more flexible.

In addition, the extension provides extra metrics by analyzing the availability and reviews per property, presenting
In a nutshell, the extension provides extra metrics by analyzing the availability and reviews per property, presenting
this data directly on the property card. This might help to facilitate your decision-making process when selecting a
hostel.

Here's what it does:

* Removes featured/promoted property statue obstructions from search results.
* Surfaces mobile-exclusive deals that are normally reserved only for the mobile app.
* 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.
* 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.

H.O.R was developed as a personal side project to simplify the process of selecting the "best" hostel from an extensive
range of good ones. It proved & continues to prove useful during my travels, assisting me in finding the most suitable
place to stay depending on my mood, whether I was looking for some busy or quieter place to stay at. :)


## Support

If you find this extension useful, consider buying me a coffee! It helps keep the project alive and motivates
further development.

[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/karamanisdev)


## Installation

[![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/dfilmjjmeegkakfmnadkimgflocnnnbg.svg?style=for-the-badge)](https://chrome.google.com/webstore/detail/dfilmjjmeegkakfmnadkimgflocnnnbg)
Expand Down
26 changes: 18 additions & 8 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
'use strict'

module.exports = {
presets: [
[
'@babel/preset-env',
module.exports = (api) => {
api.cache(true)

return {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: '3',
targets: '> 1%, not dead'
}
]
],
overrides: [
{
useBuiltIns: 'usage',
corejs: '3',
targets: '> 1%, not dead'
test: /\.tsx$/,
presets: ['babel-preset-solid']
}
]
]
}
}
16 changes: 13 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,23 @@ export default [
'plugin:@typescript-eslint/recommended'
),
{
files: ['**/*.{ts,js,mjs}'],
files: ['**/*.{ts,tsx,js,mjs}'],
plugins: {
unicorn
},
languageOptions: {
globals: {
...globals.node
...globals.node,
...globals.browser
},
parser: tsParser,
ecmaVersion: 2023,
sourceType: 'module'
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true
}
}
},
rules: {
'max-len': ['error', { code: 120 }],
Expand All @@ -55,6 +61,10 @@ export default [
}],
'import/no-useless-path-segments': ['error', {
noUselessIndex: true
}],
'import/no-cycle': ['error', {
maxDepth: Infinity,
ignoreExternal: true
}]
}
}
Expand Down
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@
"name": "hostelworld-on-roids-extension",
"engines": {
"npm": "^11.4.2",
"yarn": "^4.9.2",
"yarn": "^4.12.0",
"node": "^24.3.0"
},
"packageManager": "yarn@4.9.2",
"packageManager": "yarn@4.12.0",
"sideEffects": [
"*.css",
"*.scss"
],
"scripts": {
"watch": "yarn build:dev --watch --mode=development",
"build:prod": "webpack --config webpack.config.mjs --mode=production",
"build:dev": "webpack --config webpack.config.mjs --mode=development",
"lint": "yarn lint:tsc && yarn lint:eslint && yarn lint:scss && yarn lint:commit",
"lint:tsc": "tsc --noEmit --incremental false",
"lint:eslint": "eslint \"{src,scripts}/**/*.{ts,js,mjs}\"",
"lint:eslint": "eslint \"{src,scripts}/**/*.{ts,tsx,js,mjs}\"",
"lint:scss": "stylelint \"src/**/*.scss\"",
"lint:commit": "node scripts/commitlint.mjs",
"crx:key": "node scripts/crx-key.mjs",
Expand All @@ -37,6 +41,7 @@
"@typescript-eslint/parser": "^8.54.0",
"archiver": "^7.0.1",
"babel-loader": "^10.0.0",
"babel-preset-solid": "^1.9.0",
"chalk": "^5.6.2",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^13.0.1",
Expand Down Expand Up @@ -70,6 +75,7 @@
},
"dependencies": {
"ky": "^1.14.3",
"serialize-anything": "^1.2.3"
"serialize-anything": "^1.2.3",
"solid-js": "^1.9.0"
}
}
12 changes: 12 additions & 0 deletions src/app/Core/ContentInitializer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { ScriptLoader } from 'Utils/ScriptLoader'
import { ExtensionRuntime } from 'Utils/ExtensionRuntime'
import { ExtensionConfig } from 'Utils/ExtensionConfig'
import { WorkerRPCEndpoint } from 'Communication/WorkerRPCEndpoint'

export class ContentInitializer {
public static async init (): Promise<void> {
WorkerRPCEndpoint.listen()

ExtensionConfig.init(
{
name: ExtensionRuntime.manifestName(),
version: ExtensionRuntime.manifestVersion(),
homepage: ExtensionRuntime.manifestHomepage(),
assets: {
icon: ExtensionRuntime.assetUrl('icons/icon32.png')
}
}
)

/*
Why? you wonder?
Well in order to have access to the window attributes to be able to do things like intercepting XHR requests etc...
Expand Down
8 changes: 6 additions & 2 deletions src/app/Core/WorkerInitializer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { WorkerRPCEndpoint } from 'Communication/WorkerRPCEndpoint'
import { WorkerTaskDispatcher } from 'WorkerTasks/WorkerTaskDispatcher'
import type { WorkerTaskResult } from 'WorkerTasks/WorkerTaskDispatcher'
import { ComposePropertyTask } from 'WorkerTasks/Tasks/ComposePropertyTask'
import { FetchReviewsTask } from 'WorkerTasks/Tasks/FetchReviewsTask'
import { FetchAvailabilityTask } from 'WorkerTasks/Tasks/FetchAvailabilityTask'
import { FetchCountriesTask } from 'WorkerTasks/Tasks/FetchCountriesTask'

export class WorkerInitializer {
public static init (): void {
Expand All @@ -13,6 +15,8 @@ export class WorkerInitializer {
}

private static registerTasks (): void {
WorkerTaskDispatcher.register('compose:property', new ComposePropertyTask())
WorkerTaskDispatcher.register('fetch:reviews', new FetchReviewsTask())
WorkerTaskDispatcher.register('fetch:availability', new FetchAvailabilityTask())
WorkerTaskDispatcher.register('fetch:countries', new FetchCountriesTask())
}
}
2 changes: 1 addition & 1 deletion src/app/DTOs/AvailabilityMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export class AvailabilityMetrics {
private max!: Metrics
private current!: Metrics

constructor (attributes: Record<string, Metrics>) {
constructor (attributes: { max: Metrics; current: Metrics }) {
Object.assign(this, attributes)
}

Expand Down
17 changes: 16 additions & 1 deletion src/app/DTOs/Property.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics'
import type { ReviewMetrics } from 'DTOs/ReviewMetrics'
import type { BookedCountry } from 'DTOs/BookedCountry'
import type { PropertyBadge } from 'Services/PropertyBadgeService'

type PropertyAttributes = {
id: number
name: string
reviewMetrics: ReviewMetrics
availabilityMetrics: AvailabilityMetrics
bookedCountries: BookedCountry[]
badges: PropertyBadge[]
}

export class Property {
private id!: number
private name!: string
private reviewMetrics!: ReviewMetrics
private availabilityMetrics!: AvailabilityMetrics
private bookedCountries!: BookedCountry[]
private badges!: PropertyBadge[]

constructor (attributes: Record<string, unknown>) {
constructor (attributes: PropertyAttributes) {
Object.assign(this, attributes)
}

Expand All @@ -32,4 +43,8 @@ export class Property {
public getBookedCountries (): BookedCountry[] {
return this.bookedCountries
}

public getBadges (): PropertyBadge[] {
return this.badges
}
}
12 changes: 11 additions & 1 deletion src/app/DTOs/ReviewMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient'
import { toPercent } from 'Utils'

export class ReviewMetrics {
Expand All @@ -6,8 +7,9 @@ export class ReviewMetrics {
private other!: number
private solo!: number
private total!: number
private ages!: Record<string, number>

constructor (attributes: Record<string, number>) {
constructor (attributes: PropertyReviews) {
Object.assign(this, attributes)
}

Expand Down Expand Up @@ -46,4 +48,12 @@ export class ReviewMetrics {
public getTotal (): number {
return this.total
}

public getAges (): Record<string, number> {
return this.ages
}

public getAgePercentage (age: string): number {
return toPercent(this.ages[age] ?? 0, this.getTotal())
}
}
17 changes: 14 additions & 3 deletions src/app/DTOs/Search.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { dateAddDays } from 'Utils'

type SearchAttributes = {
to: Date
from: Date
cityId: string
}

export class Search {
private to!: Date
private from!: Date
private cityId!: string

constructor (attributes: Record<string, Date | string>) {
constructor (attributes: SearchAttributes) {
Object.assign(this, attributes)
}

Expand All @@ -21,15 +27,20 @@ export class Search {
return this.cityId
}

public static createFromHostelworldSearchUrl (url: URL): typeof this.prototype {
public static createFromHostelworldSearchUrl (url: URL): Search {
const parameters: URLSearchParams = url.searchParams
if (!parameters.has('date-start') || !parameters.has('num-nights')) {
throw new Error('Not a hostelworld search url.')
}

const from: Date = new Date(<string>parameters.get('date-start'))
const daysToAdd: number = Number(parameters.get('num-nights'))
const [, cityId]: [string, string] = url.toString().match(/cities\/(\d+)\/properties\//) as [string, string]

const match: RegExpMatchArray | null = url.toString().match(/cities\/(\d+)\/properties\//)
if (!match) {
throw new Error('Not a hostelworld search url.')
}
const [, cityId]: [string, string] = match as [string, string]

return new this({
cityId, from, to: dateAddDays(from, daysToAdd)
Expand Down
32 changes: 13 additions & 19 deletions src/app/Factories/PropertyFactory.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,33 @@
import type { Search } from 'DTOs/Search'
import type { Property as HostelworldProperty } from 'Types/HostelworldSearch'
import { Property } from 'DTOs/Property'
import { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics'
import type { PropertyAvailability } from 'Services/Hostelworld/Api/AvailabilityClient'
import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient'
import type { PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient'
import { ReviewMetrics } from 'DTOs/ReviewMetrics'
import { ReviewsClient } from 'Services/Hostelworld/Api/ReviewsClient'
import { VisitorsCountryClient } from 'Services/Hostelworld/Api/VisitorsCountryClient'
import { BookedCountry } from 'DTOs/BookedCountry'
import { AvailabilityClient } from 'Services/Hostelworld/Api/AvailabilityClient'
import { PropertyBadgeService, type PropertyBadge, type PropertyMetrics } from 'Services/PropertyBadgeService'

export class PropertyFactory {
public static async create (property: HostelworldProperty, search: Search): Promise<Property> {
const { id, name } = property

const [reviews, availability, countries]: [PropertyReviews, PropertyAvailability, PropertyGuestsCountries] =
await Promise.all([
ReviewsClient.fetch(id),
AvailabilityClient.fetch(id, search.getFrom(), search.getTo()),
VisitorsCountryClient.fetch(id, search.getFrom(), search.getTo())
])

public static create (
id: number,
name: string,
reviews: PropertyReviews,
availability: PropertyAvailability,
countries: PropertyGuestsCountries
): Property {
const reviewMetrics: ReviewMetrics = new ReviewMetrics(reviews)
const availabilityMetrics: AvailabilityMetrics = new AvailabilityMetrics(availability)
const bookedCountries: BookedCountry[] = countries.map(
country => new BookedCountry(country)
)
const bookedCountries: BookedCountry[] = countries.map(country => new BookedCountry(country))
const metrics: PropertyMetrics = { reviews: reviewMetrics, availability: availabilityMetrics }
const badges: PropertyBadge[] = PropertyBadgeService.badgesFor(metrics)

return new Property({
id,
name,
reviewMetrics,
availabilityMetrics,
bookedCountries,
availabilityMetrics
badges
})
}
}
Loading
Loading