From 4fef74b9014e522fe7474925c8917122793c437b Mon Sep 17 00:00:00 2001 From: Stefan Probst Date: Tue, 6 Oct 2026 13:44:06 +0200 Subject: [PATCH] feat(notification): format prices with thousands separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prices in notifications were rendered as raw numbers ("1000000 €"), which is hard to read at a glance in a push message. Add a shared formatPrice() helper that groups digits the German way ("1.000.000 €") and use it for new listings as well as for the old and new price of price changes, so every notification adapter benefits. German grouping is applied regardless of the UI language, since the amount is always in euros. Co-Authored-By: Claude Opus 5.5 --- lib/utils/formatListing.js | 20 +++++++-- .../listings/priceHistoryService.test.js | 4 +- test/utils/formatListing.test.js | 45 ++++++++++++++++++- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lib/utils/formatListing.js b/lib/utils/formatListing.js index 511cc1f78..09f0a8051 100644 --- a/lib/utils/formatListing.js +++ b/lib/utils/formatListing.js @@ -30,6 +30,20 @@ const ROOMS_LABEL = { tr: 'Odalar', }; +/** + * German digit grouping for every price, whatever the UI language: the amount is always in euros, + * and "1200000 €" is hard to read at a glance in a push notification. + */ +const PRICE_FORMAT = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }); + +/** + * Formats a price in euros with thousands separators, e.g. `1000000` becomes `1.000.000 €`. + * + * @param {number} price + * @returns {string} + */ +export const formatPrice = (price) => `${PRICE_FORMAT.format(price)} €`; + /** * Formats a listing's numerical fields (price, size, rooms) into strings with their respective units. * @@ -41,7 +55,7 @@ export const formatListing = (listing, language = 'en') => { const rooms = ROOMS_LABEL[language] ?? ROOMS_LABEL.en; return { ...listing, - price: listing.price != null ? `${listing.price} €` : null, + price: listing.price != null ? formatPrice(listing.price) : null, size: listing.size != null ? `${listing.size} m²` : null, rooms: listing.rooms != null ? `${listing.rooms} ${rooms}` : null, // Null unless a router actually answered for this listing. Adapters drop it with the same @@ -101,8 +115,8 @@ export const formatPriceChange = (change, language = 'en') => { const percent = Math.abs(change.changePercent).toFixed(1); return { ...formatListing(change.listing, language), - oldPrice: `${change.oldPrice} €`, - newPrice: `${change.newPrice} €`, + oldPrice: formatPrice(change.oldPrice), + newPrice: formatPrice(change.newPrice), // Signed and with the unit, so an adapter can drop it straight into a line of text. changePercent: `${change.changePercent < 0 ? '-' : '+'}${percent} %`, direction: change.direction, diff --git a/test/services/listings/priceHistoryService.test.js b/test/services/listings/priceHistoryService.test.js index 586ca9941..99f6d40fb 100644 --- a/test/services/listings/priceHistoryService.test.js +++ b/test/services/listings/priceHistoryService.test.js @@ -165,8 +165,8 @@ describe('services/listings/priceHistoryService', () => { await notifyPriceChanges([change('job1')]); expect(state.sent[0][1][0]).toMatchObject({ - oldPrice: '1200 €', - newPrice: '1100 €', + oldPrice: '1.200 €', + newPrice: '1.100 €', changePercent: '-8.3 %', changeHeadline: 'Price reduced', }); diff --git a/test/utils/formatListing.test.js b/test/utils/formatListing.test.js index 4fb82632a..90c81e9b3 100644 --- a/test/utils/formatListing.test.js +++ b/test/utils/formatListing.test.js @@ -4,7 +4,7 @@ */ import { describe, it, expect } from 'vitest'; -import { formatListing } from '../../lib/utils/formatListing.js'; +import { formatListing, formatPrice, formatPriceChange } from '../../lib/utils/formatListing.js'; /** * Notification text used to be German whatever the user had set the interface to, because the @@ -29,7 +29,7 @@ describe('formatListing', () => { it('formats price and size with their language-neutral units', () => { const formatted = formatListing(listing, 'de'); - expect(formatted.price).toBe('1200 €'); + expect(formatted.price).toBe('1.200 €'); expect(formatted.size).toBe('74 m²'); }); @@ -54,3 +54,44 @@ describe('formatListing', () => { expect(listing).toEqual(original); }); }); + +describe('formatPrice', () => { + it('groups thousands with dots so large prices stay readable', () => { + expect(formatPrice(1000000)).toBe('1.000.000 €'); + expect(formatPrice(100000)).toBe('100.000 €'); + expect(formatPrice(1200)).toBe('1.200 €'); + }); + + it('leaves small prices alone', () => { + expect(formatPrice(800)).toBe('800 €'); + expect(formatPrice(0)).toBe('0 €'); + }); + + it('uses a decimal comma and keeps at most two decimals', () => { + expect(formatPrice(1234.5)).toBe('1.234,5 €'); + expect(formatPrice(999.999)).toBe('1.000 €'); + }); + + it('uses German grouping even when the interface is English', () => { + expect(formatListing({ id: 'l3', title: 'House', price: 450000 }, 'en').price).toBe('450.000 €'); + }); +}); + +describe('formatPriceChange', () => { + it('formats old and new price the same way as a new listing', () => { + const formatted = formatPriceChange( + { + listing: { id: 'l4', title: 'House', price: 475000 }, + oldPrice: 499000, + newPrice: 475000, + changePercent: -4.81, + direction: 'down', + }, + 'de', + ); + expect(formatted.oldPrice).toBe('499.000 €'); + expect(formatted.newPrice).toBe('475.000 €'); + expect(formatted.price).toBe('475.000 €'); + expect(formatted.changePercent).toBe('-4.8 %'); + }); +});