Query Requirements
Schema Prefix
IMPORTANT: All tables in the Planning Center Publishing module live in theplanning_center schema. Always prefix table names with planning_center. in your reports.
✅ CORRECT: SELECT * FROM planning_center.publishing_episodes
❌ INCORRECT: SELECT * FROM publishing_episodes
Row Level Security (RLS)
Row Level Security automatically governs:- tenant_organization_id – restricts results to your organization
- system_status – active records returned by default
- ❌
WHERE tenant_organization_id = 1 - ❌
WHERE system_status = 'active'
Executive Dashboard Reports
Monthly Publishing Executive Summary
Complete monthly overview for leadership.-- Monthly Executive Publishing Summary Report
WITH current_month_metrics AS (
SELECT
DATE_TRUNC('month', CURRENT_DATE) as report_month,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT ser_er.relationship_id) as active_series,
COUNT(DISTINCT spr.relationship_id) as unique_speakers,
COUNT(DISTINCT ch_er.relationship_id) as active_channels,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode,
COUNT(DISTINCT res_er.relationship_id) as resources_created
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_episodes_relationships ch_er
ON ch_er.episode_id = e.episode_id AND ch_er.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
),
previous_month_metrics AS (
SELECT
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE)
),
year_ago_metrics AS (
SELECT
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 year')
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 year') + INTERVAL '1 month'
),
top_episodes AS (
SELECT
e.title,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as watch_count,
s.title as series_title
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
ORDER BY watch_count DESC NULLS LAST
LIMIT 3
)
SELECT
TO_CHAR(cm.report_month, 'FMMonth YYYY') as reporting_period,
'=== CONTENT PRODUCTION ===' as section_1,
cm.episodes_published as episodes_published_this_month,
pm.episodes_published as episodes_published_last_month,
ROUND(((cm.episodes_published::numeric - pm.episodes_published) /
NULLIF(pm.episodes_published, 0)) * 100, 1) as episode_change_pct,
cm.active_series as active_series_count,
cm.unique_speakers as unique_speakers_count,
cm.resources_created as resources_created_count,
'=== ENGAGEMENT METRICS ===' as section_2,
cm.total_watches as total_watches_this_month,
pm.total_watches as total_watches_last_month,
ROUND(((cm.total_watches::numeric - pm.total_watches) /
NULLIF(pm.total_watches, 0)) * 100, 1) as watch_growth_mom_pct,
ya.total_watches as total_watches_year_ago,
ROUND(((cm.total_watches::numeric - ya.total_watches) /
NULLIF(ya.total_watches, 0)) * 100, 1) as watch_growth_yoy_pct,
ROUND(cm.avg_watches_per_episode::numeric, 0) as avg_watches_per_episode,
'=== TOP PERFORMING CONTENT ===' as section_3,
(SELECT STRING_AGG(title || ' (' || COALESCE(watch_count::text, 'N/A') || ' watches)', ', '
ORDER BY watch_count DESC NULLS LAST) FROM top_episodes) as top_3_episodes,
'=== DISTRIBUTION ===' as section_4,
cm.active_channels as active_channel_count
FROM current_month_metrics cm
CROSS JOIN previous_month_metrics pm
CROSS JOIN year_ago_metrics ya;
Quarterly Publishing Performance Report
Comprehensive quarterly analysis for board meetings.-- Quarterly Publishing Performance Report
WITH quarterly_data AS (
SELECT
DATE_TRUNC('quarter', e.published_live_at) as quarter,
COUNT(DISTINCT e.episode_id) as episodes,
COUNT(DISTINCT ser_er.relationship_id) as series,
COUNT(DISTINCT spr.relationship_id) as speakers,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
COUNT(DISTINCT res_er.relationship_id) as resources
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE e.published_live_at >= DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '1 year')
GROUP BY quarter
),
current_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE)
),
previous_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '3 months')
),
year_ago_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '1 year')
)
SELECT
'=== QUARTERLY PUBLISHING REPORT ===' as report_header,
TO_CHAR(cq.quarter, 'Q YYYY') as current_quarter,
'' as blank_line_1,
'--- Content Production ---' as production_header,
cq.episodes as episodes_this_quarter,
pq.episodes as episodes_last_quarter,
yaq.episodes as episodes_year_ago_quarter,
ROUND(((cq.episodes::numeric - pq.episodes) / NULLIF(pq.episodes, 0)) * 100, 1) as episode_qoq_change_pct,
ROUND(((cq.episodes::numeric - yaq.episodes) / NULLIF(yaq.episodes, 0)) * 100, 1) as episode_yoy_change_pct,
'' as blank_line_2,
'--- Engagement Metrics ---' as engagement_header,
cq.watches as total_watches_this_quarter,
ROUND(cq.avg_watches::numeric, 0) as avg_watches_per_episode,
ROUND(((cq.watches::numeric - pq.watches) / NULLIF(pq.watches, 0)) * 100, 1) as watch_qoq_change_pct,
ROUND(((cq.watches::numeric - yaq.watches) / NULLIF(yaq.watches, 0)) * 100, 1) as watch_yoy_change_pct,
'' as blank_line_3,
'--- Resource Metrics ---' as resource_header,
cq.series as active_series_count,
cq.speakers as unique_speakers_count,
cq.resources as resources_created_count,
ROUND(cq.resources::numeric / NULLIF(cq.episodes, 0), 2) as resources_per_episode,
'' as blank_line_4,
'--- Performance Indicators ---' as performance_header,
CASE
WHEN cq.watches > pq.watches * 1.1 THEN 'Strong Growth'
WHEN cq.watches > pq.watches THEN 'Moderate Growth'
WHEN cq.watches > pq.watches * 0.9 THEN 'Stable'
ELSE 'Declining'
END as quarter_performance_rating
FROM current_quarter cq
CROSS JOIN previous_quarter pq
CROSS JOIN year_ago_quarter yaq;
Content Analysis Reports
Sermon Series Performance Report
Detailed analysis of each series.-- Sermon Series Performance Analysis Report
WITH series_metrics AS (
SELECT
s.series_id,
s.title as series_title,
s.description,
s.started_at,
s.ended_at,
s.episodes_count as planned_episodes,
COUNT(DISTINCT e.episode_id) as actual_episodes,
MIN(e.published_live_at) as first_episode_date,
MAX(e.published_live_at) as last_episode_date,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode,
MAX(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as best_episode_watches,
MIN(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as worst_episode_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_stddev,
COUNT(DISTINCT spr.relationship_id) as speaker_count,
STRING_AGG(DISTINCT sp.formatted_name, ', ' ORDER BY sp.formatted_name) as speakers,
COUNT(DISTINCT res_er.relationship_id) as total_resources
FROM planning_center.publishing_series s
LEFT JOIN planning_center.publishing_episodes_relationships ep_er
ON ep_er.relationship_id = s.series_id AND ep_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes e ON e.episode_id = ep_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE s.published = true
GROUP BY s.series_id, s.title, s.description, s.started_at,
s.ended_at, s.episodes_count
),
series_rankings AS (
SELECT
*,
RANK() OVER (ORDER BY total_watches DESC) as watch_rank,
RANK() OVER (ORDER BY avg_watches_per_episode DESC) as avg_watch_rank,
CASE
WHEN ended_at IS NULL THEN 'Ongoing'
WHEN ended_at > CURRENT_DATE THEN 'Upcoming'
ELSE 'Completed'
END as series_status,
EXTRACT(DAY FROM (COALESCE(ended_at, CURRENT_DATE) - started_at)) as duration_days
FROM series_metrics
)
SELECT
series_title,
series_status,
TO_CHAR(started_at, 'Mon DD, YYYY') as start_date,
TO_CHAR(ended_at, 'Mon DD, YYYY') as end_date,
actual_episodes || '/' || planned_episodes as episode_progress,
duration_days as series_duration_days,
speakers,
speaker_count,
total_watches,
watch_rank as watch_ranking,
ROUND(avg_watches_per_episode::numeric, 0) as avg_watches,
avg_watch_rank as avg_watch_ranking,
best_episode_watches,
worst_episode_watches,
ROUND(watch_stddev::numeric, 0) as watch_consistency_score,
total_resources,
ROUND(total_resources::numeric / NULLIF(actual_episodes, 0), 1) as resources_per_episode,
CASE
WHEN avg_watches_per_episode > 100 THEN 'High Engagement'
WHEN avg_watches_per_episode > 50 THEN 'Good Engagement'
WHEN avg_watches_per_episode > 20 THEN 'Moderate Engagement'
ELSE 'Low Engagement'
END as performance_rating
FROM series_rankings
ORDER BY
CASE series_status
WHEN 'Ongoing' THEN 1
WHEN 'Upcoming' THEN 2
ELSE 3
END,
started_at DESC;
Biblical Text Coverage Report
Track scripture coverage across teaching.-- Biblical Text Coverage Analysis Report
-- Note: This assumes you track scripture references in episode descriptions or a separate table
WITH scripture_extraction AS (
SELECT
e.episode_id,
e.title,
e.description,
e.published_live_at,
s.title as series_title,
-- Extract potential scripture references from description
-- This is a simplified pattern - enhance based on your needs
REGEXP_MATCHES(
e.description,
'(Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|Samuel|Kings|Chronicles|Ezra|Nehemiah|Esther|Job|Psalms|Proverbs|Ecclesiastes|Song|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|Matthew|Mark|Luke|John|Acts|Romans|Corinthians|Galatians|Ephesians|Philippians|Colossians|Thessalonians|Timothy|Titus|Philemon|Hebrews|James|Peter|John|Jude|Revelation)\s+\d+',
'gi'
) as scripture_refs
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
AND e.description IS NOT NULL
),
scripture_summary AS (
SELECT
CASE
WHEN scripture_refs[1] IN ('Genesis','Exodus','Leviticus','Numbers','Deuteronomy') THEN 'Pentateuch'
WHEN scripture_refs[1] IN ('Joshua','Judges','Ruth','Samuel','Kings','Chronicles','Ezra','Nehemiah','Esther') THEN 'Historical Books'
WHEN scripture_refs[1] IN ('Job','Psalms','Proverbs','Ecclesiastes','Song') THEN 'Wisdom Literature'
WHEN scripture_refs[1] IN ('Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi') THEN 'Prophets'
WHEN scripture_refs[1] IN ('Matthew','Mark','Luke','John') THEN 'Gospels'
WHEN scripture_refs[1] = 'Acts' THEN 'Acts'
WHEN scripture_refs[1] IN ('Romans','Corinthians','Galatians','Ephesians','Philippians','Colossians','Thessalonians','Timothy','Titus','Philemon') THEN 'Pauline Epistles'
WHEN scripture_refs[1] IN ('Hebrews','James','Peter','John','Jude') THEN 'General Epistles'
WHEN scripture_refs[1] = 'Revelation' THEN 'Revelation'
ELSE 'Other'
END as book_category,
scripture_refs[1] as book_name,
COUNT(DISTINCT episode_id) as episode_count,
STRING_AGG(DISTINCT series_title, ', ') as series_using_book
FROM scripture_extraction
WHERE scripture_refs IS NOT NULL
GROUP BY book_category, scripture_refs[1]
)
SELECT
book_category,
COUNT(DISTINCT book_name) as books_covered,
SUM(episode_count) as total_episodes,
STRING_AGG(book_name || ' (' || episode_count || ')', ', ' ORDER BY episode_count DESC) as books_and_frequency,
ROUND(SUM(episode_count)::numeric / 52, 1) as episodes_per_week_avg
FROM scripture_summary
GROUP BY book_category
ORDER BY total_episodes DESC;
Speaker Reports
Speaker Performance Dashboard
Comprehensive speaker metrics and analysis.-- Speaker Performance Dashboard Report
WITH speaker_base_metrics AS (
SELECT
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as total_episodes,
COUNT(DISTINCT ser_er.relationship_id) as series_count,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as latest_episode,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_consistency
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
WHERE e.published_live_at IS NOT NULL
GROUP BY sp.speaker_id, sp.formatted_name
),
speaker_recent_metrics AS (
SELECT
sp.speaker_id,
COUNT(DISTINCT e.episode_id) as recent_episodes,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as recent_avg_watches,
STRING_AGG(e.title, ', ' ORDER BY e.published_live_at DESC) as recent_episode_titles
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY sp.speaker_id
),
speaker_rankings AS (
SELECT
sbm.*,
srm.recent_episodes,
srm.recent_avg_watches,
srm.recent_episode_titles,
RANK() OVER (ORDER BY sbm.total_episodes DESC) as episode_rank,
RANK() OVER (ORDER BY sbm.avg_watches DESC NULLS LAST) as watch_rank,
RANK() OVER (ORDER BY sbm.total_watches DESC NULLS LAST) as total_watch_rank,
EXTRACT(MONTH FROM AGE(CURRENT_DATE, sbm.latest_episode)) as months_since_last_episode
FROM speaker_base_metrics sbm
LEFT JOIN speaker_recent_metrics srm
ON sbm.speaker_id = srm.speaker_id
)
SELECT
speaker_name,
CASE
WHEN months_since_last_episode = 0 THEN 'Active'
WHEN months_since_last_episode <= 3 THEN 'Recent'
WHEN months_since_last_episode <= 6 THEN 'Occasional'
ELSE 'Inactive'
END as speaker_status,
total_episodes,
episode_rank as episode_ranking,
series_count,
TO_CHAR(first_episode, 'Mon YYYY') as teaching_since,
TO_CHAR(latest_episode, 'Mon DD, YYYY') as last_taught,
months_since_last_episode,
COALESCE(recent_episodes, 0) as episodes_last_3_months,
ROUND(avg_watches::numeric, 0) as avg_watches,
watch_rank as avg_watch_ranking,
total_watches,
total_watch_rank as total_watch_ranking,
ROUND(watch_consistency::numeric, 0) as consistency_score,
CASE
WHEN avg_watches > 50 THEN 'High Engagement'
WHEN avg_watches > 20 THEN 'Good Engagement'
WHEN avg_watches > 5 THEN 'Moderate Engagement'
ELSE 'Developing'
END as performance_tier,
LEFT(recent_episode_titles, 100) as recent_episodes_sample
FROM speaker_rankings
ORDER BY
CASE
WHEN months_since_last_episode = 0 THEN 1
WHEN months_since_last_episode <= 3 THEN 2
ELSE 3
END,
total_episodes DESC;
Speaker Schedule Report
Track speaking frequency and patterns.-- Speaker Schedule and Rotation Report
WITH speaker_calendar AS (
SELECT
DATE_TRUNC('month', e.published_live_at) as month,
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as episodes_in_month,
STRING_AGG(
TO_CHAR(e.published_live_at, 'DD') || ': ' ||
LEFT(e.title, 30),
'; ' ORDER BY e.published_live_at
) as episode_schedule
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 months')
GROUP BY month, sp.speaker_id, sp.formatted_name
),
speaker_summary AS (
SELECT
speaker_id,
speaker_name,
COUNT(DISTINCT month) as months_active,
SUM(episodes_in_month) as total_episodes,
AVG(episodes_in_month) as avg_episodes_per_active_month,
MAX(episodes_in_month) as max_episodes_in_month,
STRING_AGG(
TO_CHAR(month, 'Mon') || '(' || episodes_in_month || ')',
', ' ORDER BY month DESC
) as monthly_distribution
FROM speaker_calendar
GROUP BY speaker_id, speaker_name
)
SELECT
TO_CHAR(sc.month, 'FMMonth YYYY') as month,
sc.speaker_name,
sc.episodes_in_month as episodes,
ROUND(sc.episodes_in_month::numeric * 100.0 /
SUM(sc.episodes_in_month) OVER (PARTITION BY sc.month), 1) as pct_of_month,
ss.total_episodes as six_month_total,
ss.avg_episodes_per_active_month as avg_per_month,
sc.episode_schedule as schedule_details,
CASE
WHEN sc.episodes_in_month >= 3 THEN 'Heavy'
WHEN sc.episodes_in_month = 2 THEN 'Moderate'
ELSE 'Light'
END as workload_level
FROM speaker_calendar sc
JOIN speaker_summary ss
ON sc.speaker_id = ss.speaker_id
ORDER BY sc.month DESC, sc.episodes_in_month DESC;
Channel and Distribution Reports
Multi-Channel Performance Report
Compare content performance across distribution channels.-- Multi-Channel Distribution Performance Report
WITH channel_metrics AS (
SELECT
c.channel_id,
c.name as channel_name,
COUNT(DISTINCT er_ch.episode_id) as total_episodes,
COUNT(DISTINCT CASE
WHEN e.published_live_at >= CURRENT_DATE - INTERVAL '30 days'
THEN e.episode_id
END) as recent_episodes,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
COUNT(DISTINCT et.episode_time_id) as scheduled_publishes,
MIN(e.published_live_at) as first_publish,
MAX(e.published_live_at) as latest_publish
FROM planning_center.publishing_channels c
LEFT JOIN planning_center.publishing_episodes_relationships er_ch
ON er_ch.relationship_id = c.channel_id AND er_ch.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_episodes e ON e.episode_id = er_ch.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_et
ON er_et.episode_id = e.episode_id AND er_et.relationship_type = 'episode_times'
LEFT JOIN planning_center.publishing_episode_times et ON et.episode_time_id = er_et.relationship_id
GROUP BY c.channel_id, c.name
),
channel_rankings AS (
SELECT
*,
RANK() OVER (ORDER BY total_watches DESC NULLS LAST) as watch_rank,
CASE
WHEN latest_publish >= CURRENT_DATE - INTERVAL '7 days' THEN 'Active'
WHEN latest_publish >= CURRENT_DATE - INTERVAL '30 days' THEN 'Recent'
ELSE 'Inactive'
END as channel_status
FROM channel_metrics
)
SELECT
channel_name,
channel_status,
total_episodes,
recent_episodes as episodes_last_30_days,
TO_CHAR(first_publish, 'Mon YYYY') as active_since,
TO_CHAR(latest_publish, 'Mon DD, YYYY') as last_publish,
ROUND(avg_watches::numeric, 0) as avg_watches,
watch_rank,
total_watches,
scheduled_publishes as future_scheduled,
CASE
WHEN total_watches > 1000 THEN 'High Impact'
WHEN total_watches > 500 THEN 'Growing'
WHEN total_watches > 100 THEN 'Moderate'
ELSE 'Developing'
END as channel_tier
FROM channel_rankings
ORDER BY channel_status, total_watches DESC NULLS LAST;
Engagement and Growth Reports
Audience Growth Trajectory Report
Track audience growth over time.-- Audience Growth Trajectory Report
WITH monthly_audience AS (
SELECT
DATE_TRUNC('month', e.published_live_at) as month,
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '24 months'
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')
GROUP BY month
),
growth_metrics AS (
SELECT
month,
episodes_published,
total_watches,
avg_watches_per_episode,
LAG(total_watches, 1) OVER (ORDER BY month) as prev_month_watches,
LAG(total_watches, 12) OVER (ORDER BY month) as year_ago_watches,
AVG(total_watches) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) as three_month_avg,
AVG(total_watches) OVER (ORDER BY month ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) as twelve_month_avg
FROM monthly_audience
)
SELECT
TO_CHAR(month, 'Mon YYYY') as month,
episodes_published,
total_watches,
ROUND(((total_watches::numeric - prev_month_watches) /
NULLIF(prev_month_watches, 0)) * 100, 1) as mom_growth_pct,
ROUND(((total_watches::numeric - year_ago_watches) /
NULLIF(year_ago_watches, 0)) * 100, 1) as yoy_growth_pct,
ROUND(avg_watches_per_episode::numeric, 0) as avg_watches_per_episode,
ROUND(three_month_avg::numeric, 0) as three_month_rolling_avg,
ROUND(twelve_month_avg::numeric, 0) as twelve_month_rolling_avg,
CASE
WHEN total_watches > prev_month_watches * 1.2 THEN 'Strong Growth'
WHEN total_watches > prev_month_watches THEN 'Moderate Growth'
WHEN total_watches > prev_month_watches * 0.9 THEN 'Stable'
ELSE 'Declining'
END as trend,
CASE
WHEN month = DATE_TRUNC('month', CURRENT_DATE) THEN '*** CURRENT MONTH ***'
ELSE ''
END as note
FROM growth_metrics
ORDER BY month DESC
LIMIT 24;
Weekly Publishing Cadence Report
Analyze your publishing consistency.-- Weekly Publishing Cadence and Consistency Report
WITH weekly_publishing AS (
SELECT
DATE_TRUNC('week', e.published_live_at) as week,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT ser_er.relationship_id) as active_series,
COUNT(DISTINCT spr.relationship_id) as speakers_used,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as weekly_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
ARRAY_AGG(DISTINCT EXTRACT(DOW FROM e.published_live_at)::int ORDER BY EXTRACT(DOW FROM e.published_live_at)::int) as publishing_days,
STRING_AGG(e.title, '; ' ORDER BY e.published_live_at) as episode_titles
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY week
),
weekly_analysis AS (
SELECT
week,
episodes_published,
active_series,
speakers_used,
weekly_watches,
avg_watches,
publishing_days,
CASE
WHEN 0 = ANY(publishing_days) THEN 'Sunday'
WHEN 1 = ANY(publishing_days) THEN 'Monday'
WHEN 2 = ANY(publishing_days) THEN 'Tuesday'
WHEN 3 = ANY(publishing_days) THEN 'Wednesday'
WHEN 4 = ANY(publishing_days) THEN 'Thursday'
WHEN 5 = ANY(publishing_days) THEN 'Friday'
WHEN 6 = ANY(publishing_days) THEN 'Saturday'
END as primary_publish_day,
ARRAY_LENGTH(publishing_days, 1) as days_with_content,
LEFT(episode_titles, 100) as episode_sample
FROM weekly_publishing
)
SELECT
TO_CHAR(week, 'Week of Mon DD, YYYY') as week,
episodes_published,
CASE
WHEN episodes_published = 0 THEN '❌ No Content'
WHEN episodes_published = 1 THEN '✅ Standard'
WHEN episodes_published = 2 THEN '⭐ Above Average'
ELSE '🔥 High Output'
END as publishing_level,
active_series,
speakers_used,
weekly_watches,
ROUND(avg_watches::numeric, 0) as avg_watches,
days_with_content,
primary_publish_day,
episode_sample
FROM weekly_analysis
ORDER BY week DESC;
Export and Automation Tips
Scheduling These Reports
- Daily Reports: Executive Dashboard (for high-activity churches)
- Weekly Reports: Publishing Cadence, Speaker Schedule
- Monthly Reports: Monthly Executive Summary, Series Performance
- Quarterly Reports: Quarterly Performance, Biblical Coverage
Export Formats
- CSV: Best for Excel analysis and pivot tables
- PDF: Best for board presentations
- JSON: Best for dashboard integrations
- HTML: Best for email reports
Automation Ideas
Your Parable database connection is read-only. You cannot create views,
materialized views, or indexes through it. Save this query in your BI tool
(as a dataset or extract) or as a Parable report instead.
-- Weekly publishing summary — save as a scheduled report or BI dataset
SELECT
DATE_TRUNC('week', CURRENT_DATE) as report_week,
COUNT(DISTINCT e.episode_id) as episodes_this_week,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
COUNT(DISTINCT spr.relationship_id) as speakers,
COUNT(DISTINCT ser_er.relationship_id) as series
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
WHERE e.published_live_at >= DATE_TRUNC('week', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week';
Next Steps
- Customize these reports with your church’s specific KPIs
- Add filters for specific campuses or ministries
- Create dashboards using these queries as data sources
- Set up alerts for unusual patterns or milestones
Transform your publishing data into actionable insights. These reports help you make data-driven decisions about your content strategy.