npm package: @mrazakassar/audience-identifier-core
Description: Core audience identification engine with WORLDWIDE reverse geocoding. Auto-detects postal codes globally, generates geohash IDs, and matches audience segments by location, time, and behavior. Works with React Native and Strapi.
Author: Muhammad Raza
Latest version: 1.4.6

# Audience Identifier Core

A powerful NPM package for identifying audience segments based on geographic coordinates, timestamps, and behavioral patterns. Now with **WORLDWIDE reverse geocoding** and **Geohash ID generation**. Works seamlessly with **React Native**, **Strapi**, and any Node.js environment.

## ✨ What's New in v1.2.0

🌍 **WORLDWIDE Reverse Geocoding** - Works globally for ANY latitude/longitude on Earth  
🆔 **Auto Geohash Generation** - Unique geo IDs for location-based indexing & proximity search  
📍 **Auto ZIP/Postal Code Detection** - No API key required (uses free OpenStreetMap Nominatim)  
⚡ **Zero Configuration** - Just coordinates → everything else happens automatically  

## Features

✅ **13 Pre-defined Audience Segments** - Affluent Passengers, Commuters, Airport Travelers, and more
✅ **6 Daypart Classifications** - Early Morning, Midday, Evening Peak, Prime Time, Late Night, Overnight
✅ **15 Geographic Zones** - Mapped across major cities (extensible globally)
✅ **WORLDWIDE Reverse Geocoding** - No API key required (uses free OpenStreetMap Nominatim); rate limits apply
✅ **Auto Geohash IDs** - Unique identifiers for any location (precision 1-12)
✅ **Auto ZIP Detection** - Automatically detects postal codes from coordinates
✅ **Minimal open-source dependencies** - Geo uses @turf/bbox, @turf/boolean-point-in-polygon, @turf/union for NYC targeting
✅ **Cross-Platform** - Works in React Native (via backend), Strapi, Node.js
✅ **High Performance** - Built-in caching for ZIP-to-zone lookups and reverse geocode (TTL 24h)
✅ **Batch Processing** - Aggregate statistics from multiple locations

### Production recommendations

- **Reverse geocoding** uses Nominatim (OpenStreetMap), which is an external API. Run it **server-side only** in production (Node/Express/Koa/Strapi). Do not call Nominatim directly from React Native clients—have them call your backend instead.
- **Rate limiting:** Nominatim has usage limits; the package caches responses (24h TTL) to reduce repeated calls for the same coordinates. For high traffic, consider self-hosting Nominatim or using a paid geocoding provider.

## Installation

### Using npm

```bash
npm install @ivee/audience-identifier-core
```

### Using yarn

```bash
yarn add @ivee/audience-identifier-core
```

## Quick Start

### NEW! Simplest API (v1.2.0+) - AutoDetect Everything

No need to manually get ZIP codes anymore! Just pass coordinates and time:

```javascript
import { identifyAudience } from '@ivee/audience-identifier-core';

// Inside a component or async function
const getAudience = async () => {
  // That's it! ZIP code is auto-detected worldwide
  const result = await identifyAudience(
    40.7128,          // Latitude (anywhere on Earth)
    -74.0060,         // Longitude (anywhere on Earth)
    new Date()        // Timestamp
  );

  console.log(result.audiences);      // Matched audience segments
  console.log(result.geohashID);      // Unique geo ID: "dr5regw"
  console.log(result.coordinates);    // { lat, lng, zipCode: "10000" }
};
```

### 1. React Native Example

**Production:** Call your backend API that uses this package; do not call `identifyAudience` (and thus Nominatim) directly from the client. That avoids rate limits and keeps API usage server-side.

```javascript
// In React Native: call your backend
const getAudience = async () => {
  const res = await fetch('https://your-api.com/audience/identify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ lat: 40.7580, lng: -73.9855, timestamp: new Date().toISOString() }),
  });
  const result = await res.json();
  console.log(result.audiences); // [{ id: 'AUD-06', name: 'Financial / Work / Business', ... }]
};
```

### 2. Strapi Example

```javascript
// In your Strapi controller or service

const AudienceService = ({ strapi }) => ({
  async identifyUserAudience(lat, lng, timestamp) {
    const { identifyAudience } = require('@ivee/audience-identifier-core');
    
    // ZIP code is auto-detected worldwide!
    const result = await identifyAudience(lat, lng, timestamp);

    return result;
  }
});

module.exports = AudienceService;
```

### 3. Simple Node.js Example

```javascript
const { identifyAudience, batchIdentifyAudiences } = 
  require('@ivee/audience-identifier-core');

// Single identification with auto ZIP detection
(async () => {
  const result = await identifyAudience(
    40.7128,
    -74.0060,
    '2026-02-05T18:45:00Z'
  );
  
  console.log('Postal Code:', result.coordinates.zipCode);  // Auto-detected!
  console.log('Geohash ID:', result.geohashID);             // Geo ID: "dr5regw"
  console.log('Matched Audiences:', result.audiences);
  console.log('Daypart:', result.daypart.name);
})();
```

### 4. Worldwide Location Example

```javascript
const { identifyAudience } = require('@ivee/audience-identifier-core');

(async () => {
  // Works ANYWHERE on Earth
  const places = [
    { name: 'New York', lat: 40.7128, lng: -74.0060 },
    { name: 'London', lat: 51.5074, lng: -0.1278 },
    { name: 'Tokyo', lat: 35.6762, lng: 139.6503 },
    { name: 'Sydney', lat: -33.8688, lng: 151.2093 }
  ];

  for (const place of places) {
    const result = await identifyAudience(place.lat, place.lng, new Date());
    console.log(`${place.name}: ${result.coordinates.zipCode} (${result.geohashID})`);
    // Output:
    // New York: 10000 (dr5regw)
    // London: WC2N 5DX (gcpvj0d)
    // Tokyo: 168-0063 (xn76cy8)
    // Sydney: 2000 (r3gx2fb)
  }
})();
```

## API Reference

### `identifyAudience(lat, lng, timestamp, options?)` ⭐ RECOMMENDED

**NEW in v1.2.0** - Auto-detects ZIP/postal codes worldwide!

Identifies audience segments for a single location and time with automatic geocoding.

**Parameters:**
- `lat` (number): Latitude (-90 to 90) **required**
- `lng` (number): Longitude (-180 to 180) **required**
- `timestamp` (Date|string|number): When the action occurred **required**
- `options` (Object, optional):
  - `rideMode` (string): 'work', 'watch', 'shop'
  - `startZone` (Object): Start zone for commuter logic
  - `endZone` (Object): End zone for commuter logic

**Returns:** Promise<Object>
```javascript
{
  success: true,
  coordinates: { lat, lng, zipCode: "10000" },    // ← Auto-detected!
  geohashID: "dr5regw",                            // ← Auto-generated!
  timestamp: "2026-02-05T18:45:00Z",
  geoZones: [
    { id: "city-financial-districts", name: "City Financial Districts", ... }
  ],
  daypart: {
    id: "evening-peak",
    name: "Evening Peak",
    startTime: "16:00",
    endTime: "19:00"
  },
  audiences: [
    { id: "AUD-06", name: "Financial / Work / Business", tier: "Intent", ... }
  ],
  metadata: {
    isWeekday: true,
    queryTimeMs: 12,
    geohashPrecision: 7,
    version: "1.2.0"
  }
}
```

### `identifyAudienceWithZip(lat, lng, zipCode, timestamp, options?)`

**Legacy API** - If you already have ZIP codes from another geocoding service.

Same as above but takes explicit `zipCode` parameter.

### `batchIdentifyAudiences(records)`

Process multiple locations efficiently.

**Parameters:**
- `records` (Array): Array of record objects, each with:
  ```javascript
  {
    lat: 40.7128,
    lng: -74.0060,
    timestamp: '2026-02-05T18:45:00Z',
    options: { rideMode: 'work' }
  }
  ```

**Returns:** Promise<Object> - Aggregated statistics

### 🌍 `reverseGeocode(lat, lng, options?)` — single reverse geocoding (recommended)

**Single flow** used by `identifyAudience()` and for direct use:

1. **Try OpenStreetMap (Nominatim)** first for every call (US or non-US).
2. **On failure** (network/timeout/error), try **US cities fallback** (sync, no network).
3. **If not in US fallback**, return **default** postal/zip **10034** (`source: 'default'`).

No API key required (Nominatim). Responses are cached (TTL 24h). Use **server-side only** in production.

**Parameters:**
- `lat` (number): Latitude
- `lng` (number): Longitude
- `options` (object, optional): `{ timeout }` — Nominatim timeout in ms (default: 5000)

**Returns:** Promise<Object>
```javascript
{
  city: "New York",
  postalCode: "10000",
  zipCode: "10000",
  country: "United States",
  confidence: "high",
  source: "nominatim-worldwide"  // or "us-fallback" or "default"
}
```

**Usage:**
```javascript
const { reverseGeocode } = require('@mrazakassar/audience-identifier-core');

const geo = await reverseGeocode(51.5074, -0.1278); // London → OSM
const us = await reverseGeocode(40.71, -74.01);    // NYC → OSM (or US fallback if OSM fails)
// On full failure: { city: "Unknown", postalCode: "10034", zipCode: "10034", source: "default" }
```

### `reverseGeocodeWorldwide(lat, lng, timeout?)` — OSM only (no fallback)

Direct Nominatim (OpenStreetMap) call. **No fallback**; throws on failure. Use when you only want OSM and will handle errors yourself.

### `reverseGeocodeSync(lat, lng)` — US cities only (sync, no network)

Synchronous US-city lookup only. Returns nearest US city or `{ city: "Unknown", zipCode: "10004" }`. No API calls.

---

### 🆔 NEW: Geohash - Unique Geo IDs

**NEW in v1.2.0** - Auto-generate unique location IDs

Generates deterministic geohash IDs (like "dr5regw") for spatial indexing and proximity search.

```javascript
const {
  getGeohash,        // (lat, lng, precision) => "dr5regw"
  getGeohashInfo,    // Complete info with all details
  decodeGeohash,     // Reverse-convert hash to coordinates
  getGeohashNeighbors // Find 8 surrounding areas
} = require('@ivee/audience-identifier-core');

// Auto-generated in identifyAudience() response as "geohashID"
const result = await identifyAudience(40.7128, -74.0060, new Date());
console.log(result.geohashID);  // "dr5regw" ← Automatically generated!

// Direct usage:
const hash = getGeohash(40.7128, -74.0060, 7);
console.log(hash);  // "dr5regw" (~150m precision)

const info = getGeohashInfo(40.7128, -74.0060, 7);
console.log(info.neighbors);  // {north, south, east, west, ...}
```

**Precision Levels:**
- Precision 1: ~5000 km (country level)
- Precision 6: ~1.2 km (city level) ← Default
- Precision 7: ~152m (neighborhood level)
- Precision 8: ~38m (street level)
- Precision 12: ~60cm (building level)

---

### Utility Functions

```javascript
const {
  classifyDaypart,        // (timestamp) => {id, name, startTime, endTime}
  isWeekday,              // (timestamp) => boolean
  detectGeoZonesByZip,    // (lat, lng, zipCode, options) => [zones]
  matchAudiences,         // (geoZones, daypart, options) => [audiences]
  reverseGeocode,         // (lat, lng, options?) => Promise — OSM → US fallback → default 10034
  reverseGeocodeSync,     // (lat, lng) => US-only sync (no network)
  reverseGeocodeWorldwide // (lat, lng, timeout?) => Promise OSM-only
} = require('@ivee/audience-identifier-core');
```

## Audience Segments (13 Total)

| ID | Name | Tier | Key Attributes |
|----|------|------|---|
| AUD-01 | Affluent Passengers | Demo/Psychographic | Luxury zones, daytime hours |
| AUD-02 | Airport Travelers | Intent | Airport zones, all dayparts |
| AUD-03 | Business Travelers | Intent | Hotels, conventions, business hours |
| AUD-04 | Commuters | Intent | Home→Work/Workdays, peak hours |
| AUD-05 | Experience Seekers | Interests | Entertainment venues, evenings |
| AUD-06 | Financial/Work/Business | Intent | Business zones, workdays only |
| AUD-07 | General DMA | Fallback | All zones, all times (catch-all) |
| AUD-08 | Hungry Passengers | Purchase Intent | Restaurants, dining times |
| AUD-09 | Entertainment Lovers | Interests | Cinema, nightlife, evenings |
| AUD-10 | Night Out | Intent | Nightlife, late hours |
| AUD-11 | Out of Towners | Intent | Airports, hotels, tourism |
| AUD-12 | Retail Shoppers | Purchase Intent | Shopping districts, retail hours |
| AUD-13 | Sports Enthusiasts | Interests | Sports venues, event hours |

## Dayparts (6 Total)

| Name | Start | End | Use Case |
|------|-------|-----|----------|
| Early Morning | 05:00 | 09:00 | Commuting, early flights |
| Midday | 09:00 | 16:00 | Workday, shopping, lunch |
| Evening Peak | 16:00 | 19:00 | Post-work commute |
| Prime Time | 19:00 | 23:00 | Entertainment, dining |
| Late Night | 23:00 | 02:00 | Nightlife, late dining |
| Overnight | 02:00 | 05:00 | Red-eyes, shift work |

## Geographic Zones (15 Total)

Mapped across: **NYC**, **Chicago**, **LA**, **Miami**, **Charlotte**

- Airport Zones
- Hotel Zones
- City Financial Districts
- Office Hubs & Corporate Campuses
- Major Retail Corridors & Shopping Districts
- Restaurant & Food Districts
- Nightlife Corridors
- Cinema & Theater Zones
- Stadium & Sports Venue Zones
- Cultural & Museum Districts
- Tourism & Visitor Zones
- Affluent Residential Neighborhoods
- General Residential Neighborhoods
- And more...

## ZIP Code / Postal Code Handling

### ✨ NEW: Automatic Detection (v1.2.0+)

**You NO LONGER need external geocoding services!**

The main `identifyAudience()` function now automatically detects postal codes worldwide:

```javascript
const result = await identifyAudience(lat, lng, timestamp);
// ZIP/postal code is auto-detected! 
// Works for ANY location on Earth
```

Uses **FREE Nominatim (OpenStreetMap) API** - no API key required!

### Legacy: Manual ZIP Code (Optional)

If you already have ZIP codes from another source, you can use the legacy function:

```javascript
const { identifyAudienceWithZip } = require('@ivee/audience-identifier-core');

const result = await identifyAudienceWithZip(
  lat, lng, zipCode, timestamp, options
);
```

### Getting ZIP Codes (if needed for legacy code):

**For React Native:**
- [react-native-geocoding](https://github.com/marlonVR/react-native-geocoding)
- [Google Maps Geocoding API](https://developers.google.com/maps/documentation/geocoding)
- [Mapbox](https://docs.mapbox.com/mapbox-gl-js/)

**For Node.js/Strapi:**
- [zipcodes npm package](https://www.npmjs.com/package/zipcodes)
- [node-geocoder](https://www.npmjs.com/package/node-geocoder)
- [Google Maps API](https://cloud.google.com/maps-platform)

## Integrating with Strapi

### 1. Create a Custom Service

```javascript
// api/audience/services/audience.js

module.exports = {
  async identifyAudience(lat, lng, timestamp) {
    const { identifyAudience } = 
      require('@mrazakassar/audience-identifier-core');
    
    // ZIP code is auto-detected worldwide!
    return await identifyAudience(lat, lng, timestamp);
  },

  async batchIdentify(records) {
    const { batchIdentifyAudiences } =
      require('@mrazakassar/audience-identifier-core');
    
    return await batchIdentifyAudiences(records);
  }
};
```

### 2. Create a POST Controller

```javascript
// api/audience/controllers/audience.js

module.exports = {
  async identify(ctx) {
    const { lat, lng, timestamp } = ctx.request.body;
    
    // That's it! No ZIP code needed
    const result = await strapi
      .service('api::audience.audience')
      .identifyAudience(lat, lng, timestamp);
    
    ctx.body = result;
  }
};
```

### 3. Route Configuration

```javascript
// api/audience/routes/audience.js

module.exports = {
  routes: [
    {
      method: 'POST',
      path: '/audience/identify',
      handler: 'audience.identify',
      config: { policies: [] }
    }
  ]
};
```

### 4. Test the API

```bash
curl -X POST http://localhost:1337/api/audience/identify \
  -H "Content-Type: application/json" \
  -d '{
    "lat": 40.7128,
    "lng": -74.0060,
    "timestamp": "2026-02-05T18:45:00Z"
  }'
```

Response:
```json
{
  "success": true,
  "coordinates": {
    "lat": 40.7128,
    "lng": -74.0060,
    "zipCode": "10000"
  },
  "geohashID": "dr5regw",
  "daypart": {
    "name": "Evening Peak"
  },
  "audiences": [
    {
      "id": "AUD-06",
      "name": "Financial / Work / Business"
    }
  ]
}
```

## NYC Geo Targeting

Three-level geo targeting for New York City: **DMA** (dissolved NYC boundary), **Borough** (5 boroughs), and **Neighborhood** (NYC Planning NTA polygons). Usable from Node/Express/Koa/Strapi and from React Native via your backend.

### Works immediately after install (no init, no sync)

The package ships with **full NYC geo data** inside the package. **You do not need to run sync or write any files.** Install the package and `matchPoint` works for all of NYC:

- **1 DMA** (dissolved NYC boundary)
- **5 boroughs** (Manhattan, Brooklyn, Queens, The Bronx, Staten Island)
- **32 Manhattan neighborhoods** (NTAs, code prefix `MN`)

```javascript
const { matchPoint } = require('@mrazakassar/audience-identifier-core');

// No setup – works right after npm install for all of NYC
const result = await matchPoint({ lat: 40.758, lng: -73.9855 });
// → { dma: { id: 'NYC', ... }, borough: { id: 'Manhattan', ... }, neighborhood: { id: 'MN0502', ... } }

const brooklyn = await matchPoint({ lat: 40.6942, lng: -73.9866 });
// → { dma: { id: 'NYC', ... }, borough: { id: 'Brooklyn', ... } }

const outside = await matchPoint({ lat: 34.05, lng: -118.24 });
// → {} (point outside NYC)
```

- **Default:** Reads from bundled data (full NYC). No `dataDir`, no `syncNYCData()`, no init.
- **Optional:** Run `syncNYCData()` only if you want to refresh data from NYC Open Data and persist to `./.geo/nyc`; then pass `{ dataDir: './.geo/nyc' }` to `matchPoint` to use that copy.

**DMA meaning (NYC):** For NYC, **DMA** is a logical top-level region defined as the dissolved boundary of all five NYC boroughs (one polygon). This is **not** Nielsen DMA data—it is our internal geographic hierarchy for targeting.

**Neighborhood scope:** Neighborhood layer is **Manhattan NTAs only** (32 areas); other boroughs return `dma` + `borough` only.

### Architecture (text overview)

```
┌─────────────────────────────────────────────────────────────────┐
│  Data sources (ArcGIS REST → GeoJSON)                            │
│  NYC_BOROUGH_GEOJSON_URL, NYC_NTA_GEOJSON_URL                   │
└───────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  syncNYCData()                                                   │
│  • Fetch Borough + NTA GeoJSON                                   │
│  • Filter NTAs to Manhattan (prefix MN) for MVP                  │
│  • Dissolve 5 boroughs → 1 NYC DMA polygon (@turf/union)         │
│  • Persist: .geo/nyc/index.json, dma/, borough/, neighborhood/    │
└───────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Query API (file storage default)                               │
│  listGeos(layer, filter) │ getGeo │ getGeometry                  │
│  matchPoint({lat,lng}) → bbox prefilter → booleanPointInPolygon  │
│  validateTargeting({dmaId, boroughId, neighborhoodId})            │
│  In-memory LRU geometry cache                                    │
└─────────────────────────────────────────────────────────────────┘
```

### Setup (env vars for data sources)

ArcGIS REST endpoints return GeoJSON. Set these optional env vars; if unset, the package uses built-in placeholder URLs (replace with your ArcGIS query URLs that return GeoJSON):

```bash
export NYC_BOROUGH_GEOJSON_URL="https://.../FeatureServer/0/query?where=1%3D1&outFields=*&outSR=4326&f=geojson"
export NYC_NTA_GEOJSON_URL="https://.../FeatureServer/0/query?where=1%3D1&outFields=*&outSR=4326&f=geojson"
```

Placeholder defaults are in `src/geo/nyc/config.js`; document where to paste real URLs there or in your `.env`.

### Run sync (download and persist data)

Sync downloads Borough + NTA GeoJSON, normalizes properties, **filters NTAs to Manhattan only (prefix `MN`)** for MVP, computes the NYC DMA boundary (union of boroughs via @turf/union), and writes file-based storage.

```javascript
const { syncNYCData } = require('@mrazakassar/audience-identifier-core');

(async () => {
  const result = await syncNYCData();
  // { counts: { dma: 1, borough: 5, neighborhood: ~27 (Manhattan NTAs) }, updatedAt, dataDir, elapsedMs }
  console.log(result);
})();
```

Optional: pass `dataDir`, `boroughGeoJsonUrl`, `ntaGeoJsonUrl`:

```javascript
await syncNYCData({ dataDir: './.geo/nyc', boroughGeoJsonUrl: process.env.NYC_BOROUGH_GEOJSON_URL });
```

### Example usage in Node (Express/Koa)

```javascript
const express = require('express');
const { listGeos, getGeometry, matchPoint, nycGeoRoutes } = require('@mrazakassar/audience-identifier-core');

const app = express();
app.use(express.json());

// Mount sample routes under /geo/nyc
app.use('/geo/nyc', nycGeoRoutes());

// Or use the APIs directly:
app.get('/geo/dmas', async (req, res) => {
  const list = await listGeos('dma');
  res.json(list);
});
app.get('/geo/boroughs', async (req, res) => {
  const list = await listGeos('borough', { dmaId: 'NYC' });
  res.json(list);
});
app.get('/geo/neighborhoods', async (req, res) => {
  const list = await listGeos('neighborhood', { boroughId: req.query.borough || 'Manhattan' });
  res.json(list);
});
app.get('/geo/geometry/:layer/:id', async (req, res) => {
  const geom = await getGeometry(req.params.layer, req.params.id);
  if (!geom) return res.status(404).json({ error: 'Not found' });
  res.json(geom);
});
app.post('/geo/matchPoint', async (req, res) => {
  const { lat, lng } = req.body;
  const result = await matchPoint({ lat, lng });
  res.json(result);
});
```

### Example from React Native (calling backend)

Call your backend that uses this package; do not call sync or file I/O from RN.

```javascript
// In React Native: call your backend
const matchNYCPoint = async (lat, lng) => {
  const res = await fetch('https://your-api.com/geo/nyc/matchPoint', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ lat, lng }),
  });
  return res.json();
};
// Result: { dma?: { id, name, layer }, borough?: { id, name, layer, parentId }, neighborhood?: { id, name, layer, parentId } }
```

### Data directory (file storage)

- **Default (no options):** The package uses **bundled full NYC data** inside the package (`data/geo-nyc`): 1 DMA, 5 boroughs, 32 Manhattan neighborhoods. No files to create; `matchPoint({ lat, lng })` works for all of NYC as soon as you install.
- **Optional custom data:** Run `syncNYCData()` only to refresh from NYC Open Data; pass `{ dataDir: './.geo/nyc' }` to use that copy. Structure under `dataDir`:
  - `index.json` – metadata, counts, list of items per layer with `bbox`.
  - `dma/NYC.geojson` – dissolved NYC boundary.
  - `borough/<BoroughId>.geojson` – one per borough.
  - `neighborhood/<NTAId>.geojson` – Manhattan NTAs (e.g. MN01, MN02, …).

### API summary

| Function | Description |
|----------|-------------|
| `syncNYCData(options?)` | Download Borough + NTA GeoJSON, normalize, dissolve DMA, persist. Idempotent. |
| `listGeos(layer, filter?, options?)` | `layer`: `"dma"` \| `"borough"` \| `"neighborhood"`. Filter: `dmaId`, `boroughId`, `prefix` (e.g. `"MN"`). |
| `getGeo(layer, id, options?)` | Metadata for one id (no geometry). |
| `getGeometry(layer, id, options?)` | GeoJSON Feature for map/backend geo ops. |
| `matchPoint({ lat, lng }, options?)` | Point-in-polygon → `{ dma?: {id,name,layer}, borough?: {id,name,layer,parentId}, neighborhood?: {id,name,layer,parentId} }`. Uses bbox prefilter and in-memory LRU geometry cache. |
| `validateTargeting({ dmaId?, boroughId?, neighborhoodId? }, options?)` | Returns `{ valid, errors }` for hierarchy consistency. |
| `nycGeoRoutes()` | Express router: GET `/dmas`, `/boroughs`, `/neighborhoods`, `/geometry/:layer/:id`, POST `/matchPoint`, `/validate`. |

### Extending to other cities/layers

- Keep the same layer pattern: `dma` → `borough` → `neighborhood` (or city-specific equivalents).
- Add a new config module (e.g. `src/geo/la/config.js`) and reuse `listGeos`/`getGeometry`/`matchPoint` patterns with a different `dataDir` and sync that writes the same index + geometry file layout.
- Optional PostGIS: use `sql/nyc_geo_postgis.sql` as a template; implement a storage adapter that reads/writes from `geo_dma`/`geo_borough`/`geo_neighborhood` when `config.storage === 'db'`. File storage remains the default and works without PostGIS.

### Testing NYC Geo Targeting

```bash
npm run test:geo-nyc
```

Tests cover: sync (1 DMA, 5 boroughs, Manhattan NTA count > 20 when endpoints available), `listGeos` with filters, `getGeo`/`getGeometry`, `matchPoint` for a known Manhattan point (40.7580, -73.9855) and outside NYC (34.0522, -118.2437), and `validateTargeting` (valid hierarchy and invalid borough/neighborhood mismatch). Without network, tests use a minimal fixture so the suite still passes.

---

## Testing

```bash
# Run basic audience identification tests
npm test

# Run batch processing tests
npm run test:batch

# Test worldwide reverse geocoding (19 global locations)
npm run test:geocoding

# Test geohash generation
npm run test:geohash

# Test automatic geohash with real queries
npm run test:auto-geohash

# NYC Geo Targeting (sync, listGeos, matchPoint, validateTargeting)
npm run test:geo-nyc
```

## Performance

- **Single Query:** ~5-15ms (with cache hits faster)
- **Batch (1000 records):** ~10-20 seconds
- **Caching:** ZIP-to-zone lookups cached; reverse geocode responses cached (TTL 24h); NYC geometry LRU cache for matchPoint
- **Nominatim:** Use server-side only; cache reduces repeated calls for same coordinates

## Troubleshooting

### No audiences matched?
- Ensure your ZIP code is correct
- Check if ZIP code is supported (must be in supported cities)
- Review the `geoZones` array - should not be empty

### Wrong daypart returned?
- Timestamps must be in valid ISO format or Date objects
- Check timezone: internally uses UTC
- Verify daypart times in exported `dayparts` array

### Commuter audience not matching?
- Requires `requiresWeekday: true` (Monday-Friday only)
- Requires BOTH startZone and endZone
- Start must be residential, end must be work zone
- Must be during Early Morning (5-9 AM) or Evening Peak (4-7 PM)

## License

MIT


**Version:** 1.3.2 
**Last Updated:** February 2026  
**Highlights:** Worldwide reverse geocoding, auto geohash generation, zero-config setup