Compare commits

...

5 Commits

Author SHA1 Message Date
Maurice d6a6b49b6b test(planner): the #3074 case in a file of its own, and the category pick inside guessedCategory, so both files stay within their size 2026-11-12 23:18:51 +01:00
Maurice 0855bafca7 fix(search): place searches find a name typed without its accents (#3075)
"Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the
places list, a collection, the saved-place picker, the import from a trip
and the location picker now compare without case and Latin accents, with
letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin
script stay, since there they change the word.
2026-11-12 23:06:36 +01:00
Maurice a39f3fe40c fix(planner): a place added from a collection keeps its category (#3074)
The place form guessed the category of a picked place from its kind, so a
place taken from a collection inside a trip lost the category it was saved
under, while adding it from the collection kept it. A picked place that
carries a category this server has now brings it along; a category chosen
in the form by hand still wins.
2026-11-12 23:06:35 +01:00
Maurice f13a0aa6ee fix(collections): pins on the map show their place's category (#3073)
The server nests a saved place's category, while the markers and the hover
card read the flat fields a trip place carries, so every pin on a
collection's map was the grey default. The map now hands its places on with
the category's colour, icon and name where the markers look for them.
2026-11-12 22:02:05 +01:00
Maurice 10d1a61614 fix(ios): a running TREK Cloud subscription is found on a fresh install too
The check before a purchase read only the entitlements on the device. Those
can be empty while the subscription runs, so the app offered the plans and
the purchase changed the running subscription's plan in the App Store. The
status of the subscription group now decides when the device has none. A
renewal the device has not finished yet no longer counts as a purchase
waiting for its instance either, which also let the check through.
2026-11-12 21:21:51 +01:00
13 changed files with 235 additions and 31 deletions
+44 -7
View File
@@ -685,7 +685,7 @@ extension TrekShellPlugin {
// A held one that was delivered stops the purchase here. One that was paid
// but never set up is handed back as bought, so its instance is set up now.
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
if await TrekStore.isUnfinished(held.id) {
if await TrekStore.awaitingSetup(held) {
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
} else {
call.resolve(["kind": "subscribed"])
@@ -759,8 +759,8 @@ extension TrekShellPlugin {
call.resolve(["held": false])
return
}
let unfinished = await TrekStore.isUnfinished(held.id)
call.resolve(["held": !unfinished])
let awaiting = await TrekStore.awaitingSetup(held)
call.resolve(["held": !awaiting])
}
}
}
@@ -793,17 +793,54 @@ enum TrekStore {
}
}
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? {
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
/// The subscription this Apple ID holds under `prefix`, newest first. The
/// entitlements on the device come first; on a fresh install they can still
/// be empty while the subscription runs, so the status of the subscription
/// group, which StoreKit asks the App Store for, decides then.
static func currentEntitlement(prefix: String) async -> Held? {
var newest: (date: Date, held: Held)?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID)
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
}
}
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) }
if let newest { return newest.held }
return await subscriptionGroupHeld(prefix: prefix)
}
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
/// productIdFor in iap.ts); one of them is enough to find their group.
private static let plans = ["start", "basis", "plus", "pro"]
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
guard let products = try? await Product.products(for: ids),
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
for status in statuses where running.contains(status.state) {
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
return held(status.transaction.jwsRepresentation, transaction)
}
}
return nil
}
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
}
/// A first purchase that was paid but whose instance was never set up: handed
/// back as bought so it is set up now. A renewal the device has not finished
/// yet is no such purchase; its subscription is running and counts as held.
static func awaitingSetup(_ held: Held) async -> Bool {
guard held.firstPurchase else { return false }
return await isUnfinished(held.id)
}
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
@@ -12,6 +12,7 @@ import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
});
});
describe('CollectionMap pins (#3073)', () => {
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
category_name: null,
category_color: null,
category_icon: null,
});
});
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
const { view } = await renderMap([
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
]);
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
expect(html.toLowerCase()).toContain('#7c3aed');
});
});
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport'
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
* while the map is built rather than flying in.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places])
const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { textMatchesSearch } from '../../utils/placeSearch'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
const [shown, setShown] = useState(PAGE)
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = places.filter(p => {
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (!q) return true
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
return textMatchesSearch([p.name, p.address], search)
})
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
else list.sort((a, b) => a.name.localeCompare(b.name))
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { formatDate } from '../../utils/formatters'
import { textMatchesSearch } from '../../utils/placeSearch'
import { getApiErrorMessage } from '../../types'
import type { Category, TranslationFn } from '../../types'
import type { CollectionImportablePlace, Trip } from '@trek/shared'
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
const savedCount = items.length - newCount
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items
.filter(p => (onlyNew ? !p.already_in_list : true))
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
.filter(p => textMatchesSearch([p.name, p.address], query))
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
}, [items, onlyNew, query])
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react'
import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { textMatchesSearch } from '../../utils/placeSearch'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
: results
return { showPicks, rows }
}
@@ -0,0 +1,59 @@
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { collectionsApi } from '../../api/collections';
import { useAddonStore } from '../../store/addonStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal with a place from a collection', () => {
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
loaded: true,
});
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
incomingInvites: [],
});
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
places: [
{
id: 10,
collection_id: 1,
name: 'Zebra Cafe',
status: 'idea',
lat: 35.66,
lng: 139.7,
category_id: 7,
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
},
],
});
const onSave = vi.fn().mockResolvedValue(undefined);
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
const categories = [
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
];
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
fireEvent.click(await screen.findByText('Zebra Cafe'));
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
});
});
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
return { lat, lng }
}
/**
* The category a picked place already carries, as the form's value, when this server
* has it. A place saved in a collection keeps its category_id, and categories are the
* server's own, so it fits the trip as it stands. Guessing from the place's kind
* instead dropped it (#3074).
*/
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
const id = result.category_id
if (typeof id !== 'number') return null
return categories.some(c => c.id === id) ? String(id) : null
}
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
prefillForm, type PlaceFormData, type PlacePrefill,
prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
} from './PlaceFormModal.helpers'
import { useNewCategory, usePlaceForm } from './usePlaceForm'
import { usePlaceSearch } from './usePlaceSearch'
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// the next pick may replace it and a hand choice is never overwritten (#2282).
const categoryGuessedRef = useRef(false)
const guessedCategory = (result: Record<string, unknown>): string => {
const id = guessCategoryId(result, categories || [])
const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
const id = saved ? null : guessCategoryId(result, categories || [])
categoryGuessedRef.current = id != null
return id != null ? String(id) : ''
return saved ?? (id != null ? String(id) : '')
}
// The hours the details column looked up, as a week the contact block can take over (#2472).
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
@@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace;
describe('collectionsModel', () => {
// ── filterPlaces ────────────────────────────────────────────────────────────
describe('filterPlaces', () => {
it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => {
const places = [
cp({ id: 1, name: 'Óbidos', status: 'idea' }),
cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }),
cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }),
];
expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]);
expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]);
expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]);
});
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
@@ -1,5 +1,6 @@
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import { textMatchesSearch } from '../../utils/placeSearch'
/**
* Pure data shaping + presentation metadata for the Collections page. No React
@@ -36,7 +37,6 @@ export function filterPlaces(
labelFilter: number[] = [],
ratingFilter: number | 'all' = 'all',
): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => {
if (!p) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
@@ -44,12 +44,7 @@ export function filterPlaces(
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
(p.address ?? '').toLowerCase().includes(q) ||
(p.notes ?? '').toLowerCase().includes(q)
)
return textMatchesSearch([p.name, p.address, p.notes], search)
})
}
@@ -93,6 +88,25 @@ export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
}
/**
* A saved place with its category where the trip map reads it. The server nests a
* collection place's category (`category.color`), the markers and the hover card
* read the flat fields a trip place carries, so every pin here was the grey default
* (#3073).
*/
export function withMarkerCategory(place: CollectionPlace): CollectionPlace & {
category_name: string | null
category_color: string | null
category_icon: string | null
} {
return {
...place,
category_name: place.category?.name ?? null,
category_color: place.category?.color ?? null,
category_icon: place.category?.icon ?? null,
}
}
/**
* Normalise a user-typed link: prepend https:// when there's no scheme so the
* href is absolute (a bare "booking.com" would otherwise resolve relative to the
+23 -1
View File
@@ -1,6 +1,6 @@
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
import { describe, it, expect } from 'vitest'
import { placeMatchesSearch } from './placeSearch'
import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch'
const place = {
name: 'Café Central',
@@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => {
expect(placeMatchesSearch(place, 'pizza')).toBe(false)
})
})
describe('search without accents (#3075)', () => {
it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => {
for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) {
expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true)
expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true)
}
})
it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => {
expect(foldForSearch('Straße')).toBe('strasse')
expect(foldForSearch('Łódź')).toBe('lodz')
expect(foldForSearch('Ærø')).toBe('aero')
expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true)
})
it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => {
expect(foldForSearch('が')).toBe('が')
expect(textMatchesSearch(['かき'], 'がき')).toBe(false)
expect(foldForSearch('한국')).toBe('한국')
})
})
+29 -5
View File
@@ -10,9 +10,33 @@ type Searchable = Pick<Place, 'name' | 'address' | 'description' | 'notes'>
* can never disagree about what a search finds.
*/
export function placeMatchesSearch(place: Searchable, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
return [place.name, place.address, place.description, place.notes].some(
field => !!field && field.toLowerCase().includes(q),
)
return textMatchesSearch([place.name, place.address, place.description, place.notes], query)
}
/** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */
const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */
const FOLDED_LETTERS: Record<string, string> = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' }
/**
* Text as a search compares it: lower case and without accents, so "Obidos" finds
* "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only
* Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since
* there it changes the word.
*/
export function foldForSearch(text: string): string {
return text
.toLocaleLowerCase()
.normalize('NFD')
.replace(LATIN_MARKS, '')
.replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch)
.normalize('NFC')
}
/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */
export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean {
const q = foldForSearch(query.trim())
if (!q) return true
return fields.some(field => !!field && foldForSearch(field).includes(q))
}