Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .well-known/signage-app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifestVersion": "1",
"id": "missing-children",
"name": "Missing Children",
"description": "A full-screen missing-child alert for any display. Each time it loads it shows one child picked at random from the open cases at the National Center for Missing & Exploited Children, nationwide or for one US state, with a photo, the child's age, when and where they went missing, a QR code for the official poster, and the 24-hour 1-800-THE-LOST hotline.",
"description": "A full-screen missing-child alert for any display. Each time it loads it shows one child picked at random from the open cases at the National Center for Missing & Exploited Children, nationwide or for one US state, with a photo, the child's age, when and where they went missing, a QR code for the official poster, and the 24-hour 1-800-THE-LOST hotline. While an AMBER Alert is active for the area, the child from that alert is shown first.",
"summary": "Help bring missing children home from any screen.",
"vendor": "Screenly",
"tags": [
Expand Down
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ HTML/CSS/JS built through `@screenly-labs/signage-kit`. Like `quotes` and
and no refresh timer. The player's reload brings the next child. Keep it that
way; don't add a client-side loop or anything pushed from a server.

Active **AMBER Alerts** come first. On each load the browser asks NCMEC for
active alerts (`…/JSONDataServlet?action=amberAlert&missState=XX`, empty for
nationwide). If there are any, it shows a child from one at random, flagged
"AMBER Alert" with an "Alert issued" date, and fetches the alert's detail record
(`action=amberDetail&amberId=…`) for the photo and age. Posters are
`/poster/AMBER/{amberId}/{personId}`. No alerts, or a failed check, falls
through to the random open case below; an AMBER lookup failure must never
blank the board. `toAmberChild` drops any record that names an abductor or
companion: only the child is ever shown.

Data flow: the browser reads the launch settings (`?state=`), fetches page 1 of
NCMEC's public search
(`https://api.missingkids.org/missingkids/servlet/JSONDataServlet?action=publicSearch&search=new&subjToSearch=child[&missState=XX][&goToPage=N]`,
Expand Down Expand Up @@ -67,7 +77,8 @@ bun run lint # biome lint --error-on-warnings
`?v=__ASSET_VERSION__`, stamped at build.
- `assets/static/js/cases.ts`: **pure, exported, unit-tested** helpers: settings
parsing (`readSettings`, `normalizeState`), `searchUrl`, the response parser
(`parseSearch`/`toChild`/`parseTotalPages`), random selection
(`parseSearch`/`toChild`/`parseTotalPages`), the AMBER Alert parsers
(`amberUrl`/`parseAmber`/`toAmberChild`/`parseAmberDetail`), random selection
(`pickRandomIndex`/`pickRandomPage`/`pickChild`), NCMEC date parsing (hand-rolled;
`Date.parse` of `"Oct 13, 2026 12:00:00 AM"` is engine-specific), name/city
casing (`tidyCase`), the poster-link UTM tags (`UTM`, `withUtm`), and display
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ An unknown `state` falls back to nationwide.

## Where the data comes from

**Active AMBER Alerts come first.** Each load checks NCMEC's active
[AMBER Alerts](https://www.missingkids.org/gethelpnow/amber) for the configured
state (or nationwide):

```
https://api.missingkids.org/missingkids/servlet/JSONDataServlet?action=amberAlert&missState=CA
```

While an alert is active, the board shows a child from it, flagged **AMBER
Alert**, with the alert's photo and poster. AMBER Alerts are usually resolved
within hours; with none active (the usual case) the app shows a random open
case as below.

The page calls the public JSON search behind
[missingkids.org](https://www.missingkids.org/gethelpnow/search):

Expand Down
106 changes: 104 additions & 2 deletions assets/static/js/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export const US_STATES: Record<string, string> = {
VI: 'U.S. Virgin Islands'
}

// Active AMBER Alerts live under this pseudo org prefix: their posters are
// `/poster/AMBER/{amberId}/{personId}`.
export const AMBER_PREFIX = 'AMBER'

// Roles an AMBER Alert record can name besides the child. Such a record is
// never shown as "Have you seen this child?".
const NOT_A_CHILD = /abductor|companion|suspect/i

// Org prefixes whose records are unidentified remains, not missing children.
export const UNIDENTIFIED_PREFIXES = new Set(['NCMU'])

Expand Down Expand Up @@ -116,6 +124,8 @@ export interface MissingChild {
photoUrl: string | null
/** The public NCMEC poster for this case. */
posterUrl: string
/** True for a child in an active AMBER Alert. */
amber: boolean
}

export interface Settings {
Expand Down Expand Up @@ -152,13 +162,36 @@ export const searchUrl = (state: string | null, page = 1): string => {
return `${API_BASE}${SEARCH_PATH}?${params.toString()}`
}

/** NCMEC's active AMBER Alerts, optionally scoped to one state. */
export const amberUrl = (state: string | null): string => {
const params = new URLSearchParams({
action: 'amberAlert',
missState: state ?? '',
LanguageId: 'en_US'
})
return `${API_BASE}${SEARCH_PATH}?${params.toString()}`
}

/** The detail record for one AMBER Alert, which carries the photo. */
export const amberDetailUrl = (amberId: string): string => {
const params = new URLSearchParams({ action: 'amberDetail', amberId, LanguageId: 'en_US' })
return `${API_BASE}${SEARCH_PATH}?${params.toString()}`
}

/**
* Parses NCMEC's `"Oct 13, 2026 12:00:00 AM"` into `YYYY-MM-DD`. Done by hand
* Parses NCMEC's `"Oct 13, 2026 12:00:00 AM"` (or the AMBER list's `10/13/2026`)
* into `YYYY-MM-DD`. Done by hand
* because `Date.parse` of that format is engine-specific, and the date is a
* calendar date with no meaningful time or zone.
*/
export const parseMissingDate = (raw: unknown): string | null => {
if (typeof raw !== 'string') return null
const numeric = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\b/.exec(raw.trim())
if (numeric) {
const [month, day] = [Number(numeric[1]), Number(numeric[2])]
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${numeric[3]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
const m = /^([A-Za-z]{3})[a-z]*\.? +(\d{1,2}), +(\d{4})/.exec(raw.trim())
if (!m) return null
const month = MONTHS_SHORT.findIndex((s) => s.toLowerCase() === m[1].toLowerCase())
Expand Down Expand Up @@ -234,7 +267,76 @@ export const toChild = (raw: unknown): MissingChild | null => {
city: tidyCase(str(p.missingCity)),
state: str(p.missingState).toUpperCase(),
photoUrl: p.hasThumbnail !== false && image.startsWith('/') ? `${API_BASE}${image}` : null,
posterUrl: `${POSTER_BASE}/${encodeURIComponent(orgPrefix)}/${encodeURIComponent(caseNumber)}/${seq}`
posterUrl: `${POSTER_BASE}/${encodeURIComponent(orgPrefix)}/${encodeURIComponent(caseNumber)}/${seq}`,
amber: false
}
}

/** A photo path from NCMEC, made absolute; null for anything off-origin. */
const ncmecPhoto = (raw: unknown): string | null => {
const path = str(raw)
return path.startsWith('/') && !path.startsWith('//') ? `${API_BASE}${path}` : null
}

/**
* Validates and normalizes one entry of the amberAlert response's `persons`.
* The field names follow NCMEC's own AMBER widget, which accepts either spelling
* of the id, name and place. The list carries no photo (see `amberPhotoUrl`).
*/
export const toAmberChild = (raw: unknown): MissingChild | null => {
if (!raw || typeof raw !== 'object') return null
const p = raw as Record<string, unknown>
const id = str(p.amberId) || str(p.caseNumber)
const name = str(p.fullName) || [str(p.firstName), str(p.lastName)].filter(Boolean).join(' ')
if (!id || !name) return null
if (p.isChild === false) return null
for (const role of [p.personType, p.role, p.relationship, p.subjectType]) {
if (typeof role === 'string' && NOT_A_CHILD.test(role)) return null
}
const seq = typeof p.personId === 'number' || typeof p.personId === 'string' ? p.personId : 1
// `missingFrom` is "City ST"; otherwise separate city/state fields.
const from = /^(.+?),?\s+([A-Za-z]{2})$/.exec(str(p.missingFrom))
return {
caseNumber: id,
name: tidyCase(name),
age: typeof p.age === 'number' && p.age > 0 ? p.age : null,
missingDate:
parseMissingDate(p.alertDate) ?? parseMissingDate(p.missingDate) ?? parseMissingDate(p.date),
city: tidyCase(from ? from[1] : str(p.city) || str(p.missingCity)),
state: (from ? from[2] : str(p.state) || str(p.missingState)).toUpperCase(),
photoUrl: ncmecPhoto(p.imageUrl),
posterUrl: `${POSTER_BASE}/${AMBER_PREFIX}/${encodeURIComponent(id)}/${encodeURIComponent(String(seq))}`,
amber: true
}
}

/** Parses an amberAlert response into the children in active alerts. */
export const parseAmber = (data: unknown): MissingChild[] => {
if (!data || typeof data !== 'object') return []
const persons = (data as { persons?: unknown }).persons
if (!Array.isArray(persons)) return []
const seen = new Set<string>()
const out: MissingChild[] = []
for (const raw of persons) {
const child = toAmberChild(raw)
if (!child || seen.has(child.posterUrl)) continue
seen.add(child.posterUrl)
out.push(child)
}
return out
}

/**
* The photo and age from an amberDetail response's `childBean`, which the
* alert list lacks. Anything missing or malformed comes back null.
*/
export const parseAmberDetail = (data: unknown): Pick<MissingChild, 'photoUrl' | 'age'> => {
const bean = data && typeof data === 'object' ? (data as { childBean?: unknown }).childBean : null
if (!bean || typeof bean !== 'object') return { photoUrl: null, age: null }
const b = bean as Record<string, unknown>
return {
photoUrl: ncmecPhoto(b.imageUrl),
age: typeof b.age === 'number' && b.age > 0 ? b.age : null
}
}

Expand Down
48 changes: 44 additions & 4 deletions assets/static/js/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
// child. Picking at random across every page, not just the newest 20, spreads
// the exposure over all open cases.
//
// Flow: read the launch settings → fetch page 1 (which also reports how many
// An active AMBER Alert comes first: while NCMEC lists one for this area, every
// load shows a child from it (flagged "AMBER Alert"). Only when there is none,
// or the lookup fails, does the board fall back to a random open case.
//
// Flow: read the launch settings → check for active AMBER Alerts → fetch page 1 (which also reports how many
// pages there are) → fetch one random page → pick a child at random. If the
// random page fails, fall back to a child from page 1. If nothing loads at all,
// or the filter has no open cases, the standing appeal stays up. There is
Expand All @@ -25,14 +29,19 @@ import qrcode from 'qrcode-generator'
import {
type MissingChild,
US_STATES,
amberDetailUrl,
amberUrl,
daysSince,
displayUrl,
formatDate,
formatDuration,
formatPlace,
parseAmber,
parseAmberDetail,
parseSearch,
parseTotalPages,
pickChild,
pickRandomIndex,
pickRandomPage,
readSettings,
searchUrl,
Expand Down Expand Up @@ -132,6 +141,11 @@ const fitCard = (): void => {

const render = (child: MissingChild): void => {
const today = new Date()
if (child.amber) {
document.documentElement.dataset.alert = 'amber'
text('mc-flag', 'AMBER Alert')
text('mc-date-label', 'Alert issued')
}
text('mc-name', child.name)
text('mc-age', child.age === null ? 'Unknown' : String(child.age))
text('mc-place', formatPlace(child) || 'Unknown')
Expand All @@ -155,11 +169,11 @@ const render = (child: MissingChild): void => {
if (document.fonts?.ready) document.fonts.ready.then(fitCard)
}

const fetchPage = async (page: number): Promise<unknown> => {
const fetchJson = async (url: string): Promise<unknown> => {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(searchUrl(settings.state, page), {
const res = await fetch(url, {
credentials: 'omit',
signal: controller.signal
})
Expand All @@ -170,6 +184,31 @@ const fetchPage = async (page: number): Promise<unknown> => {
}
}

const fetchPage = (page: number): Promise<unknown> => fetchJson(searchUrl(settings.state, page))

// A child from an active AMBER Alert for this area, or null when there is none
// or NCMEC can't be reached (the random-case path then runs as usual). The list
// has no photo or age, so the alert's detail record is fetched for them; without
// it the initials card stands in.
const loadAmber = async (): Promise<MissingChild | null> => {
let alerts: MissingChild[]
try {
alerts = parseAmber(await fetchJson(amberUrl(settings.state)))
} catch (error) {
console.error('Missing children: AMBER Alert check failed —', error)
return null
}
if (alerts.length === 0) return null
const child = alerts[pickRandomIndex(alerts.length)]
try {
const detail = parseAmberDetail(await fetchJson(amberDetailUrl(child.caseNumber)))
return { ...child, photoUrl: child.photoUrl ?? detail.photoUrl, age: child.age ?? detail.age }
} catch (error) {
console.error('Missing children: AMBER Alert photo failed —', error)
return child
}
}

// The page count from the last load, per filter. Remembering it lets the next
// load fetch one random page straight away instead of page 1 first, halving
// the time to first content. It is only a number, never a case: every case
Expand Down Expand Up @@ -248,7 +287,8 @@ const init = (): void => {
fitCard()
})
})
loadChild()
loadAmber()
.then((alert) => alert ?? loadChild())
.then((child) => {
if (child) render(child)
else showAppeal() // no open cases for this filter
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<main class="board">
<header class="masthead">
<p class="eyebrow">
<span class="eyebrow__flag">Missing</span>
<span class="eyebrow__flag" id="mc-flag">Missing</span>
<span id="mc-region">United States</span>
</p>
</header>
Expand All @@ -56,7 +56,7 @@ <h1 class="details__name" id="mc-name"></h1>
<dd id="mc-age"></dd>
</div>
<div class="facts__row">
<dt>Missing since</dt>
<dt id="mc-date-label">Missing since</dt>
<dd id="mc-date"></dd>
</div>
<div class="facts__row">
Expand Down
Loading
Loading