More
This commit is contained in:
parent
cd34ee693f
commit
05a1f316e1
58 changed files with 3113 additions and 1277 deletions
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
|
||||
describe('api utilities', () => {
|
||||
it('builds API URLs from endpoint names, paths, and params', () => {
|
||||
|
|
@ -64,4 +65,20 @@ describe('api utilities', () => {
|
|||
)
|
||||
).toBe('Property type:Flat');
|
||||
});
|
||||
|
||||
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
|
||||
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Good+ primary schools within 2km:2:8');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||
import pb from './pocketbase';
|
||||
import { getSchoolBackendFeatureName } from './school-filter';
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
|
@ -82,8 +83,29 @@ export function buildFilterString(
|
|||
): string {
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) return '';
|
||||
return entries
|
||||
.filter(([name]) => name !== exclude)
|
||||
|
||||
const merged = new Map<string, [number, number] | string[]>();
|
||||
for (const [name, value] of entries) {
|
||||
if (name === exclude) continue;
|
||||
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
||||
const prev = merged.get(backendName);
|
||||
if (
|
||||
prev &&
|
||||
Array.isArray(prev) &&
|
||||
Array.isArray(value) &&
|
||||
typeof prev[0] === 'number' &&
|
||||
typeof value[0] === 'number'
|
||||
) {
|
||||
merged.set(backendName, [
|
||||
Math.max(prev[0] as number, value[0] as number),
|
||||
Math.min(prev[1] as number, value[1] as number),
|
||||
]);
|
||||
} else if (!prev) {
|
||||
merged.set(backendName, value);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.entries()]
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,47 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
|||
/** Default color for unknown POI groups */
|
||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
||||
|
||||
/** POI category → icon/logo URL for branded and transport categories */
|
||||
export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||
Airport: '/assets/twemoji/2708.png',
|
||||
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
|
||||
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
|
||||
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
|
||||
Bakery: '/assets/twemoji/1f950.png',
|
||||
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
|
||||
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
|
||||
'Bus station': '/assets/twemoji/1f68c.png',
|
||||
'Bus stop': '/assets/twemoji/1f68f.png',
|
||||
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
|
||||
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
|
||||
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
|
||||
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
|
||||
'Convenience Store': '/assets/twemoji/1f3ea.png',
|
||||
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
|
||||
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
|
||||
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
|
||||
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
|
||||
Ferry: '/assets/twemoji/26f4.png',
|
||||
Greengrocer: '/assets/twemoji/1f96c.png',
|
||||
'Heron Foods': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
|
||||
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
|
||||
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
|
||||
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
|
||||
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
|
||||
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
|
||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
|
||||
'Rail station': '/assets/twemoji/1f686.png',
|
||||
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
|
||||
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
|
||||
Supermarket: '/assets/twemoji/1f6d2.png',
|
||||
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
|
||||
'Taxi rank': '/assets/twemoji/1f695.png',
|
||||
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
|
||||
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
|
||||
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
|
||||
};
|
||||
|
||||
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
|
||||
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
|
||||
|
||||
|
|
|
|||
|
|
@ -103,8 +103,7 @@ export function buildPropertySearchUrls({
|
|||
|
||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const priceFilter =
|
||||
filters['Estimated current price'] ?? filters['Last known price'];
|
||||
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
||||
const minPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice =
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
enumIndexToColor,
|
||||
getBoundsFromViewState,
|
||||
getFeatureFillColor,
|
||||
getPoiIconUrl,
|
||||
zoomToResolution,
|
||||
} from './map-utils';
|
||||
|
||||
|
|
@ -36,6 +37,13 @@ describe('map utilities', () => {
|
|||
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
|
||||
});
|
||||
|
||||
it('prefers POI category logos before falling back to emoji icons', () => {
|
||||
expect(getPoiIconUrl('Waitrose', '🛒')).toBe(
|
||||
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
|
||||
);
|
||||
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||
});
|
||||
|
||||
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
||||
expect(
|
||||
getFeatureFillColor(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
ENUM_PALETTE,
|
||||
POI_CATEGORY_LOGOS,
|
||||
type GradientStop,
|
||||
} from './consts';
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
|
@ -196,6 +197,10 @@ export function emojiToTwemojiUrl(emoji: string): string {
|
|||
return `${TWEMOJI_BASE}${hex}.png`;
|
||||
}
|
||||
|
||||
export function getPoiIconUrl(category: string, emoji: string): string {
|
||||
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
|
||||
}
|
||||
|
||||
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
|
||||
export function enumIndexToColor(
|
||||
index: number,
|
||||
|
|
|
|||
216
frontend/src/lib/school-filter.ts
Normal file
216
frontend/src/lib/school-filter.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
|
||||
export const SCHOOL_FILTER_NAME = 'Schools';
|
||||
export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
|
||||
|
||||
export type SchoolPhase = 'primary' | 'secondary';
|
||||
export type SchoolRating = 'good' | 'outstanding';
|
||||
export type SchoolDistance = 2 | 5;
|
||||
|
||||
export interface SchoolFilterConfig {
|
||||
phase: SchoolPhase;
|
||||
rating: SchoolRating;
|
||||
distance: SchoolDistance;
|
||||
featureName: string;
|
||||
}
|
||||
|
||||
export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'good',
|
||||
distance: 2,
|
||||
featureName: 'Good+ primary schools within 2km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'good',
|
||||
distance: 2,
|
||||
featureName: 'Good+ secondary schools within 2km',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'outstanding',
|
||||
distance: 2,
|
||||
featureName: 'Outstanding primary schools within 2km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'outstanding',
|
||||
distance: 2,
|
||||
featureName: 'Outstanding secondary schools within 2km',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'good',
|
||||
distance: 5,
|
||||
featureName: 'Good+ primary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'good',
|
||||
distance: 5,
|
||||
featureName: 'Good+ secondary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'primary',
|
||||
rating: 'outstanding',
|
||||
distance: 5,
|
||||
featureName: 'Outstanding primary schools within 5km',
|
||||
},
|
||||
{
|
||||
phase: 'secondary',
|
||||
rating: 'outstanding',
|
||||
distance: 5,
|
||||
featureName: 'Outstanding secondary schools within 5km',
|
||||
},
|
||||
];
|
||||
|
||||
const SCHOOL_FEATURE_NAMES = new Set(SCHOOL_FILTERS.map((filter) => filter.featureName));
|
||||
|
||||
export function isBackendSchoolFeatureName(name: string): boolean {
|
||||
return SCHOOL_FEATURE_NAMES.has(name);
|
||||
}
|
||||
|
||||
export function isSchoolFilterName(name: string): boolean {
|
||||
return isBackendSchoolFeatureName(name) || name.startsWith(SCHOOL_FILTER_KEY_PREFIX);
|
||||
}
|
||||
|
||||
export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
|
||||
const synthetic = parseSchoolFilterKey(name);
|
||||
if (synthetic) return synthetic;
|
||||
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
|
||||
}
|
||||
|
||||
export function getSchoolFeatureName(
|
||||
phase: SchoolPhase,
|
||||
rating: SchoolRating,
|
||||
distance: SchoolDistance
|
||||
): string {
|
||||
return (
|
||||
SCHOOL_FILTERS.find(
|
||||
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
|
||||
)?.featureName ?? SCHOOL_FILTERS[0].featureName
|
||||
);
|
||||
}
|
||||
|
||||
export function createSchoolFilterKey(
|
||||
phase: SchoolPhase,
|
||||
rating: SchoolRating,
|
||||
distance: SchoolDistance,
|
||||
id: number | string
|
||||
): string {
|
||||
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
|
||||
}
|
||||
|
||||
export function getSchoolFilterKeyId(name: string): string | null {
|
||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||
return name.split(':')[4] ?? null;
|
||||
}
|
||||
|
||||
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
|
||||
const phase = phaseRaw as SchoolPhase;
|
||||
const rating = ratingRaw as SchoolRating;
|
||||
const distance = Number(distanceRaw) as SchoolDistance;
|
||||
if (
|
||||
(phase !== 'primary' && phase !== 'secondary') ||
|
||||
(rating !== 'good' && rating !== 'outstanding') ||
|
||||
(distance !== 2 && distance !== 5)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
rating,
|
||||
distance,
|
||||
featureName: getSchoolFeatureName(phase, rating, distance),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSchoolBackendFeatureName(name: string): string | null {
|
||||
if (isBackendSchoolFeatureName(name)) return name;
|
||||
return parseSchoolFilterKey(name)?.featureName ?? null;
|
||||
}
|
||||
|
||||
export function replaceSchoolFilterKeySelection(
|
||||
key: string,
|
||||
next: {
|
||||
phase?: SchoolPhase;
|
||||
rating?: SchoolRating;
|
||||
distance?: SchoolDistance;
|
||||
}
|
||||
): string {
|
||||
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
|
||||
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
|
||||
const id = parts[4] ?? '0';
|
||||
return createSchoolFilterKey(
|
||||
next.phase ?? config.phase,
|
||||
next.rating ?? config.rating,
|
||||
next.distance ?? config.distance,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
|
||||
return (
|
||||
SCHOOL_FILTERS.find((filter) => features.some((feature) => feature.name === filter.featureName))
|
||||
?.featureName ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveSchoolFeatureName(filters: FeatureFilters): string | null {
|
||||
return Object.keys(filters).find(isSchoolFilterName) ?? null;
|
||||
}
|
||||
|
||||
export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters {
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (isBackendSchoolFeatureName(name)) {
|
||||
const config = getSchoolFilterConfig(name);
|
||||
if (!config) continue;
|
||||
next[
|
||||
createSchoolFilterKey(
|
||||
config.phase,
|
||||
config.rating,
|
||||
config.distance,
|
||||
Object.keys(next).length
|
||||
)
|
||||
] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||
const sourceFeatureName = getDefaultSchoolFeatureName(features);
|
||||
const sourceFeature = sourceFeatureName
|
||||
? features.find((feature) => feature.name === sourceFeatureName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: SCHOOL_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Education',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 10,
|
||||
step: 1,
|
||||
description: 'Rated primary and secondary schools nearby',
|
||||
detail:
|
||||
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
|
||||
source: 'ofsted',
|
||||
raw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampSchoolRange(value: [number, number], feature?: FeatureMeta): [number, number] {
|
||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
|
||||
describe('url-state', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -79,6 +80,36 @@ describe('url-state', () => {
|
|||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
||||
});
|
||||
|
||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[schoolOne]: [1, 10],
|
||||
[schoolTwo]: [2, 15],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('school')).toEqual([
|
||||
'primary:good:2:1:10',
|
||||
'secondary:outstanding:5:2:15',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
|
||||
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the default area tab', () => {
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,20 @@ import {
|
|||
type TravelTimeEntry,
|
||||
type TravelTimeInitial,
|
||||
} from '../hooks/useTravelTime';
|
||||
import {
|
||||
SCHOOL_FILTER_NAME,
|
||||
createSchoolFilterKey,
|
||||
getSchoolFilterConfig,
|
||||
isSchoolFilterName,
|
||||
type SchoolDistance,
|
||||
type SchoolPhase,
|
||||
type SchoolRating,
|
||||
} from './school-filter';
|
||||
|
||||
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
||||
const filterParams = params.getAll('filter');
|
||||
if (filterParams.length === 0) return undefined;
|
||||
const schoolParams = params.getAll('school');
|
||||
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
|
||||
|
||||
const filters: FeatureFilters = {};
|
||||
for (const entry of filterParams) {
|
||||
|
|
@ -29,6 +39,27 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
|||
filters[name] = [rest];
|
||||
}
|
||||
}
|
||||
|
||||
schoolParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length !== 5) return;
|
||||
const phase = parts[0] as SchoolPhase;
|
||||
const rating = parts[1] as SchoolRating;
|
||||
const distance = Number(parts[2]) as SchoolDistance;
|
||||
const min = Number(parts[3]);
|
||||
const max = Number(parts[4]);
|
||||
if (
|
||||
(phase !== 'primary' && phase !== 'secondary') ||
|
||||
(rating !== 'good' && rating !== 'outstanding') ||
|
||||
(distance !== 2 && distance !== 5) ||
|
||||
isNaN(min) ||
|
||||
isNaN(max)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
|
||||
});
|
||||
|
||||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +157,16 @@ export function stateToParams(
|
|||
}
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
const schoolConfig = getSchoolFilterConfig(name);
|
||||
if (schoolConfig && isSchoolFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append(
|
||||
'school',
|
||||
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
||||
|
|
@ -170,13 +211,15 @@ export function summarizeParams(queryString: string): string {
|
|||
const parts: string[] = [];
|
||||
|
||||
const filterParams = params.getAll('filter');
|
||||
if (filterParams.length > 0) {
|
||||
const schoolParams = params.getAll('school');
|
||||
if (filterParams.length > 0 || schoolParams.length > 0) {
|
||||
const filterNames = filterParams
|
||||
.map((entry) => {
|
||||
const colonIdx = entry.indexOf(':');
|
||||
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
})
|
||||
.filter((n) => n);
|
||||
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue