doxa-maps/plugin-designs/wall-of-people-groups/src/stores/peopleGroupsStore.js
Doxa Maps e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00

642 lines
30 KiB
JavaScript

// People Groups Store - Manages data from the Doxa prayer API
// Moved 2026-09-25 from the wall-of-people-groups no-build SPA: Pinia now comes from the package
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
// cannot collide with the host page's own keys, and the source list is the one live API.
import { defineStore } from 'pinia'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
const LS = (k) => 'wall-pg:' + k
export const usePeopleGroupsStore = (pinia) => {
return defineStore('peopleGroups', {
state: () => ({
// The page's profile-config, set by the wall before it fetches: its people-group LIST.
profileConfig: null,
peopleGroups: [],
loading: false,
error: null,
prayers: {}, // Track prayers per people group ID
comments: {}, // Track comments per people group ID
dataSource: 'PRAY_API', // 'PRAY_API', 'DOXA_API', or 'CSV'
// The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV fallback was a
// 2.6 MB copy of what the live API already serves, so neither ships.
availableSources: [
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
]
}),
getters: {
totalGroups: (state) => state.peopleGroups.length,
getPrayerCount: (state) => (groupId) => {
return state.prayers[groupId] || 0;
},
getComments: (state) => (groupId) => {
return state.comments[groupId] || [];
},
currentSource: (state) => {
return state.availableSources.find(s => s.id === state.dataSource);
}
},
actions: {
async fetchPeopleGroups() {
this.loading = true;
this.error = null;
console.log(`🔄 Fetching people groups from ${this.dataSource}...`);
try {
if (this.dataSource === 'PRAY_API') {
await this.fetchFromPrayAPI();
} else if (this.dataSource === 'DOXA_API') {
await this.fetchFromAPI();
} else if (this.dataSource === 'CSV') {
await this.fetchFromCSV();
}
console.log(`✅ Successfully loaded ${this.peopleGroups.length} people groups from ${this.dataSource}!`);
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
} catch (error) {
// warn, not error: the wall now SAYS so on screen (PeopleGroupsWall's offline notice)
// and offers a retry; an unreachable API is an expected state, not a crash.
console.warn(`Could not fetch people groups from ${this.dataSource}:`, error && error.message);
this.error = error.message;
this.peopleGroups = [];
} finally {
this.loading = false;
}
},
async fetchFromPrayAPI() {
const source = this.availableSources.find(s => s.id === 'PRAY_API');
const apiUrl = source.url;
console.log('🙏 Prayer.Tools API URL:', apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Guard against HTML responses (bot protection, Nuxt SPA shell)
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
}
const data = await response.json();
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []), this.profileConfig);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
this.peopleGroups = [];
return;
}
// Placeholder for groups without photos
const noPhotoPlaceholder = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
this.peopleGroups = posts.map(group => {
// Extract labels from nested value/label objects
const regionLabel = group.wagf_region?.label || group.wagf_region || '';
const blockLabel = group.wagf_block?.label || group.wagf_block || '';
const countryLabel = group.country_code?.label || group.country_code || '';
const countryCode = group.country_code?.value || group.country_code || '';
const religionLabel = group.religion?.label || group.religion || '';
const religionCode = group.religion?.value || '';
const affinityLabel = group.rop1?.label || '';
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
const imageUrl = group.image_url || noPhotoPlaceholder;
return {
// Standard fields
ID: group.slug || `pray-${Math.random().toString(36).slice(2)}`,
post_title: group.name || 'Unknown',
name: group.name || 'Unknown',
slug: group.slug || '',
status: '',
country: countryLabel,
countryCode: countryCode,
region: regionLabel,
wagfBlock: blockLabel,
population: group.population || '',
religion: religionLabel,
religionCode: religionCode,
affinityBlock: affinityLabel,
latitude: group.latitude || null,
longitude: group.longitude || null,
description: group.location_description || '',
// Prayer & engagement data (THE KEY FIELDS!)
peoplePraying: Number(group.people_praying || 0),
peopleCommitted: Number(group.people_committed || 0),
adoptedByChurches: Number(group.adopted_by_churches || 0),
// Image fields
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.picture_credit || null,
imageStatus: hasPhoto ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log prayer & engagement statistics
const withPrayer = this.peopleGroups.filter(g => g.peoplePraying > 0).length;
const withCommitted = this.peopleGroups.filter(g => g.peopleCommitted > 0).length;
const withAdoption = this.peopleGroups.filter(g => g.adoptedByChurches > 0).length;
const withImages = this.peopleGroups.filter(g => g.hasPhoto).length;
console.log(`📊 Prayer.Tools API stats:`);
console.log(` Total groups: ${this.peopleGroups.length}`);
console.log(` 🙏 With prayer: ${withPrayer}`);
console.log(` 👥 With committed workers: ${withCommitted}`);
console.log(` ⛪ Adopted by churches: ${withAdoption}`);
console.log(` 📸 With photos: ${withImages}`);
// Start background image testing
this.testImagesInBackground();
},
async fetchFromAPI() {
const source = this.availableSources.find(s => s.id === 'DOXA_API');
const apiUrl = source.url;
console.log('API URL:', apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Guard against HTML responses (bot protection, error pages)
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
}
const data = await response.json();
// Placeholder image for groups without photos
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
// The API returns { "posts": [...] }
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []), this.profileConfig);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
this.peopleGroups = [];
return;
}
this.peopleGroups = posts.map(group => {
// Determine image URL from API data
const hasPhoto = group.imb_has_photo === '1' || group.imb_has_photo === 1 || group.imb_has_photo === true;
const apiImageUrl = group.imb_picture_url || null;
const imageUrl = (hasPhoto && apiImageUrl) ? apiImageUrl : placeholderUrl;
return {
// Standard fields mapped from API
ID: group.dt_id || group.ID || `api-${Math.random().toString(36).slice(2)}`,
post_title: group.imb_display_name || group.post_title || 'Unknown',
name: group.imb_display_name || group.post_title || 'Unknown',
slug: group.slug || '',
status: group.status || '',
country: group.imb_isoalpha3 || '',
region: group.doxa_wagf_region || '',
wagfBlock: group.doxa_wagf_block || '',
population: group.imb_population || '',
religion: group.imb_reg_of_religion || '',
religionCode: group.imb_reg_of_religion_3 || '',
latitude: group.imb_lat || null,
longitude: group.imb_lng || null,
description: group.imb_people_description || '',
// Image fields
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.imb_picture_credit_html || '',
imageStatus: hasPhoto && apiImageUrl ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log image statistics
const withImages = this.peopleGroups.filter(g => g.hasPhoto && g.imb_picture_url).length;
const withPlaceholder = this.peopleGroups.length - withImages;
console.log(`📸 Image stats: ${withImages} with photos, ${withPlaceholder} using placeholder`);
// Start background image testing for groups that have URLs
this.testImagesInBackground();
},
async fetchFromCSV() {
const source = this.availableSources.find(s => s.id === 'CSV');
const csvUrl = source.url;
console.log('CSV URL:', csvUrl);
const response = await fetch(csvUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const csvText = await response.text();
const parsedData = this.parseCSV(csvText);
// Map all groups to standard format
const allGroups = parsedData.map(group => ({
ID: group.PEID || group.PeopleID3 || `csv-${Math.random()}`,
post_title: group.Name || group.PplNm,
name: group.Name || group.PplNm,
status: group.Status || 'Active',
country: group.Ctry,
region: group.Regn,
population: group.Pop,
language: group.Lang,
religion: group.Rlgn,
peopleID3: group.PeopleID3 && group.PeopleID3.trim() !== '' ? group.PeopleID3.trim() : null,
imageUrl: group.PeopleID3 && group.PeopleID3.trim() !== '' ? this.generateImageUrl(group.PeopleID3.trim()) : null,
imageStatus: 'pending', // 'pending', 'loading', 'loaded', 'failed', 'error'
// Include all original CSV data
...group
}));
// Count cluster statistics
const clusterStats = {};
allGroups.forEach(group => {
if (group.peopleID3) {
if (!clusterStats[group.peopleID3]) {
clusterStats[group.peopleID3] = { total: 0 };
}
clusterStats[group.peopleID3].total++;
}
});
// Initial sort by cluster size
this.peopleGroups = allGroups.sort((a, b) => {
const aHasID = a.peopleID3 ? 1 : 0;
const bHasID = b.peopleID3 ? 1 : 0;
// Groups with PeopleID3 come before groups without
if (aHasID !== bHasID) {
return bHasID - aHasID;
}
// Both have PeopleID3: sort by cluster size
if (aHasID && bHasID) {
const aCluster = clusterStats[a.peopleID3];
const bCluster = clusterStats[b.peopleID3];
if (aCluster.total !== bCluster.total) {
return bCluster.total - aCluster.total;
}
return a.peopleID3.localeCompare(b.peopleID3);
}
return 0;
});
const withPeopleID3 = this.peopleGroups.filter(g => g.peopleID3).length;
const withoutPeopleID3 = this.peopleGroups.length - withPeopleID3;
const uniqueClusters = Object.keys(clusterStats).length;
console.log(`✅ Parsed ${parsedData.length} total rows`);
console.log(` ${withPeopleID3} have PeopleID3, ${withoutPeopleID3} without`);
console.log(` ${uniqueClusters} unique PeopleID3 clusters`);
console.log('Top 5 largest clusters:',
Object.entries(clusterStats)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 5)
.map(([id, stats]) => `PeopleID3 ${id}: ${stats.total} groups`));
// Start background image testing (non-blocking)
this.testImagesInBackground();
},
async testImagesInBackground() {
console.log('🔍 Starting background image verification...');
// Get groups that need image testing (have a real imageUrl, not placeholder)
const groupsToTest = this.peopleGroups.filter(g =>
g.imageUrl && g.imageStatus === 'pending'
);
// Deduplicate by imageUrl
const uniqueTests = new Map();
groupsToTest.forEach(group => {
if (!uniqueTests.has(group.imageUrl)) {
uniqueTests.set(group.imageUrl, {
url: group.imageUrl,
groupIds: [group.ID],
status: 'pending'
});
} else {
uniqueTests.get(group.imageUrl).groupIds.push(group.ID);
}
});
const totalToTest = uniqueTests.size;
let tested = 0;
let loaded = 0;
let failed = 0;
let errors = 0;
console.log(` Testing ${totalToTest} unique images...`);
// Test images SLOWLY - only 3 at a time with delays
const batchSize = 3; // Small batch size to avoid rate limiting
const delayBetweenBatches = 1000; // 1 second delay between batches
const maxRetries = 3; // Retry failed images up to 3 times
const entries = Array.from(uniqueTests.entries());
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
for (let i = 0; i < entries.length; i += batchSize) {
const batch = entries.slice(i, i + batchSize);
const promises = batch.map(([imageUrl, info]) =>
this.testImageWithRetry(info.url, null, maxRetries).then(result => ({
imageUrl,
groupIds: info.groupIds,
...result
}))
);
const results = await Promise.all(promises);
// Update groups with results
results.forEach(result => {
const { groupIds, status, attempts } = result;
this.peopleGroups.forEach(group => {
if (groupIds.includes(group.ID)) {
group.imageStatus = status;
group.imageAttempts = attempts;
if (status === 'failed' || status === 'error') {
// Fall back to placeholder
group.imageUrl = placeholderUrl;
group.imageStatus = 'placeholder';
}
}
});
tested++;
if (status === 'loaded') {
loaded++;
} else if (status === 'failed') {
failed++;
console.warn(` ❌ 404: ${result.imageUrl} (tried ${attempts} times)`);
} else if (status === 'error') {
errors++;
console.error(` ⚠️ Error: ${result.imageUrl} (tried ${attempts} times)`);
}
});
// Log progress every 10 batches
if ((i / batchSize) % 10 === 0) {
console.log(` Progress: ${tested}/${totalToTest} tested (${loaded} ✅, ${failed} ❌, ${errors} ⚠️)`);
}
// Delay between batches to avoid rate limiting
if (i + batchSize < entries.length) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
}
console.log(`🎉 Image testing complete!`);
console.log(` ✅ ${loaded} images loaded successfully`);
console.log(` ❌ ${failed} images not found (404) after retries`);
console.log(` ⚠️ ${errors} network/timeout errors after retries`);
console.log(` Note: Errors may be due to rate limiting or slow network`);
},
async testImageWithRetry(url, peopleID3, maxRetries = 3) {
let lastResult = null;
// First, try the primary URL (media.joshuaproject.net with /public/)
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await this.testImageLoadDetailed(url);
// If loaded successfully, return immediately
if (result.status === 'loaded') {
return { ...result, attempts: attempt, urlFormat: 'primary' };
}
lastResult = result;
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
const delay = 500 * attempt; // 500ms, 1000ms, 1500ms...
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Primary URL failed, try fallback URL format
const fallbackUrl = `https://joshuaproject.net/assets/media/profiles/photos/p${peopleID3}.jpg`;
console.log(` 🔄 Trying fallback URL for PeopleID3 ${peopleID3}...`);
const fallbackResult = await this.testImageLoadDetailed(fallbackUrl);
if (fallbackResult.status === 'loaded') {
// Update the group's imageUrl to use the fallback
this.peopleGroups.forEach(group => {
if (group.peopleID3 === peopleID3) {
group.imageUrl = fallbackUrl;
}
});
return { ...fallbackResult, attempts: maxRetries + 1, urlFormat: 'fallback' };
}
// Both primary and fallback failed
return { ...lastResult, attempts: maxRetries + 1, urlFormat: 'none' };
},
testImageLoadDetailed(url) {
return new Promise((resolve) => {
const img = new Image();
const timeout = setTimeout(() => {
img.src = ''; // Cancel loading
resolve({ status: 'error', statusCode: 'timeout', url });
}, 10000); // 10 second timeout (longer for slow networks)
img.onload = () => {
clearTimeout(timeout);
resolve({ status: 'loaded', statusCode: 200, url });
};
img.onerror = () => {
clearTimeout(timeout);
// Assume failed (could be 404 or network error)
resolve({ status: 'failed', statusCode: 404, url });
};
img.src = url;
});
},
parseCSV(csvText) {
// Split by newline but handle quoted fields with embedded newlines
const lines = [];
let currentLine = '';
let inQuotes = false;
for (let i = 0; i < csvText.length; i++) {
const char = csvText[i];
if (char === '"') {
inQuotes = !inQuotes;
currentLine += char;
} else if (char === '\n' && !inQuotes) {
if (currentLine.trim()) {
lines.push(currentLine);
}
currentLine = '';
} else {
currentLine += char;
}
}
// Add last line if exists
if (currentLine.trim()) {
lines.push(currentLine);
}
if (lines.length === 0) return [];
// Get headers from first line - parse CSV properly
const headers = this.parseCSVLine(lines[0]);
console.log('CSV Headers found:', headers.length, 'columns');
console.log('Key columns - PEID:', headers.indexOf('PEID'), 'Name:', headers.indexOf('Name'), 'PeopleID3:', headers.indexOf('PeopleID3'));
const data = [];
// Parse each line
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = this.parseCSVLine(line);
const row = {};
headers.forEach((header, index) => {
if (values[index] !== undefined) {
row[header] = values[index];
}
});
data.push(row);
}
return data;
},
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
// Add last field
result.push(current.trim());
return result;
},
generateImageUrl(peopleID3) {
if (!peopleID3 || peopleID3.trim() === '') {
return null;
}
// Primary URL format (works better): https://media.joshuaproject.net/public/assets/media/profiles/photos/p19007.jpg
// Fallback URL format: https://joshuaproject.net/assets/media/profiles/photos/p18194.jpg
return `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${peopleID3}.jpg`;
},
setDataSource(sourceId) {
if (this.dataSource !== sourceId) {
this.dataSource = sourceId;
// Save preference to localStorage
localStorage.setItem(LS('dataSource'), sourceId);
// Refetch data from new source
this.fetchPeopleGroups();
}
},
addPrayer(groupId) {
if (!this.prayers[groupId]) {
this.prayers[groupId] = 0;
}
this.prayers[groupId]++;
// Save to localStorage for persistence
localStorage.setItem(LS('prayers'), JSON.stringify(this.prayers));
},
addComment(groupId, comment) {
if (!this.comments[groupId]) {
this.comments[groupId] = [];
}
this.comments[groupId].push({
id: Date.now(),
text: comment,
timestamp: new Date().toISOString()
});
// Save to localStorage for persistence
localStorage.setItem(LS('comments'), JSON.stringify(this.comments));
},
loadFromLocalStorage() {
try {
const savedPrayers = localStorage.getItem(LS('prayers'));
const savedComments = localStorage.getItem(LS('comments'));
const savedSource = localStorage.getItem(LS('dataSource'));
if (savedPrayers) {
this.prayers = JSON.parse(savedPrayers);
}
if (savedComments) {
this.comments = JSON.parse(savedComments);
}
if (savedSource && ['PRAY_API'].includes(savedSource)) {
this.dataSource = savedSource;
}
} catch (error) {
console.error('Error loading from localStorage:', error);
}
}
}
})(pinia);
};