Services Reporting Examples
This guide provides complete SQL queries for building comprehensive worship and volunteer reports. These examples are designed for use in BI tools like Power BI, Tableau, or custom dashboards.Ready for Production Use
These queries are structured for direct use in reporting tools. They include all necessary joins, calculations, and formatting for professional ministry reports.
Query Requirements
Schema Prefix
IMPORTANT: All tables in the Planning Center Services module live in theplanning_center
schema. Always prefix table names with planning_center.
in your reports.
✅ CORRECT: SELECT * FROM planning_center.services_plan_people
❌ INCORRECT: SELECT * FROM services_plan_people
Row Level Security (RLS)
Row Level Security automatically manages:- tenant_organization_id – isolates results to your organization
- system_status – active records returned by default
- ❌
WHERE tenant_organization_id = 1
- ❌
WHERE system_status = 'active'
Volunteer Management Dashboard
Complete Volunteer Overview
Copy
-- Comprehensive volunteer metrics for dashboard
WITH volunteer_base AS (
SELECT
p.person_id,
p.full_name,
p.first_name,
p.last_name,
p.photo_thumbnail_url,
p.preferred_max_plans_per_month,
p.archived,
p.passed_background_check,
STRING_AGG(DISTINCT t.name, ', ') as teams,
COUNT(DISTINCT t.team_id) as team_count
FROM planning_center.services_people p
LEFT JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
LEFT JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE p.archived = false
GROUP BY p.person_id, p.full_name, p.first_name, p.last_name,
p.photo_thumbnail_url, p.preferred_max_plans_per_month,
p.archived, p.passed_background_check
),
recent_activity AS (
SELECT
pp.person_id,
COUNT(DISTINCT pl.plan_id) as services_last_90_days,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as confirmed_last_90,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as declined_last_90,
MAX(pl.sort_date) as last_scheduled,
MIN(pl.sort_date) FILTER (WHERE pl.sort_date >= CURRENT_DATE) as next_scheduled
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '90 days'
AND pl.sort_date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY pp.person_id
),
blockout_summary AS (
SELECT
b.person_id,
COUNT(DISTINCT bd.date) as blocked_days_next_30
FROM planning_center.services_blockouts b
JOIN planning_center.services_blockout_dates bd ON b.blockout_id = bd.blockout_id
WHERE bd.date >= CURRENT_DATE
AND bd.date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY b.person_id
)
SELECT
vb.full_name,
vb.first_name,
vb.last_name,
vb.photo_thumbnail_url,
vb.teams,
vb.team_count,
COALESCE(ra.services_last_90_days, 0) as services_last_90_days,
COALESCE(ra.confirmed_last_90, 0) as confirmed_count,
COALESCE(ra.declined_last_90, 0) as declined_count,
CASE
WHEN ra.services_last_90_days > 0
THEN ROUND(ra.confirmed_last_90::numeric * 100 / ra.services_last_90_days, 1)
ELSE 0
END as confirmation_rate,
ra.last_scheduled,
ra.next_scheduled,
CURRENT_DATE - ra.last_scheduled as days_since_served,
vb.preferred_max_plans_per_month as monthly_limit,
COALESCE(bs.blocked_days_next_30, 0) as unavailable_days,
vb.passed_background_check,
CASE
WHEN ra.services_last_90_days = 0 OR ra.last_scheduled < CURRENT_DATE - INTERVAL '90 days'
THEN 'Inactive'
WHEN ra.services_last_90_days >= 12 THEN 'Very Active'
WHEN ra.services_last_90_days >= 6 THEN 'Active'
WHEN ra.services_last_90_days >= 3 THEN 'Occasional'
ELSE 'Rare'
END as activity_level,
CASE
WHEN ra.next_scheduled IS NOT NULL THEN 'Scheduled'
WHEN bs.blocked_days_next_30 > 15 THEN 'Mostly Unavailable'
WHEN bs.blocked_days_next_30 > 0 THEN 'Partially Available'
ELSE 'Available'
END as current_status
FROM volunteer_base vb
LEFT JOIN recent_activity ra ON vb.person_id = ra.person_id
LEFT JOIN blockout_summary bs ON vb.person_id = bs.person_id
ORDER BY ra.services_last_90_days DESC NULLS LAST, vb.full_name;
Team Roster Report
Copy
-- Detailed team roster with positions and availability
SELECT
t.name as team_name,
t.schedule_to as scheduling_type,
tp.name as position,
p.full_name,
p.photo_thumbnail_url,
CASE
WHEN p.passed_background_check = true THEN 'Yes'
WHEN t.secure_team = true THEN 'Required'
ELSE 'N/A'
END as background_check,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as recent_schedules,
STRING_AGG(
DISTINCT pl.short_dates || ' (' || pp.status || ')',
', ' ORDER BY pl.sort_date DESC
) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as recent_services,
MAX(bd.date) FILTER (
WHERE bd.date >= CURRENT_DATE
) as next_blocked_date,
CASE
WHEN COUNT(bd.date) FILTER (WHERE bd.date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '14 days') > 7
THEN 'Mostly Unavailable'
WHEN COUNT(bd.date) FILTER (WHERE bd.date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '14 days') > 0
THEN 'Partially Available'
ELSE 'Available'
END as two_week_availability
FROM planning_center.services_teams t
JOIN planning_center.services_team_positions tp ON t.team_id = tp.team_id
JOIN planning_center.services_person_team_position_assignments pa
ON tp.team_position_id = pa.team_position_id
JOIN planning_center.services_people p ON pa.person_id = p.person_id
LEFT JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
AND pp.team_id = t.team_id
LEFT JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
LEFT JOIN planning_center.services_blockout_dates bd ON p.person_id = bd.person_id
WHERE t.archived_at IS NULL
AND p.archived = false
GROUP BY t.name, t.schedule_to, t.secure_team, tp.name, p.person_id,
p.full_name, p.photo_thumbnail_url, p.passed_background_check
ORDER BY t.name, tp.name, p.full_name;
Worship Planning Analytics
Song Usage Report
Copy
-- Complete song analytics for worship planning
WITH song_metrics AS (
SELECT
s.song_id,
s.title,
s.author,
s.copyright,
s.ccli_number,
s.themes,
s.hidden,
COUNT(DISTINCT i.plan_id) as total_uses,
COUNT(DISTINCT i.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as uses_last_3_months,
COUNT(DISTINCT i.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 year'
) as uses_last_year,
MIN(pl.sort_date) as first_used,
MAX(pl.sort_date) as last_used,
STRING_AGG(DISTINCT a.chord_chart_key, ', ') as keys_used,
AVG(i.length)::INTEGER as avg_length_seconds,
COUNT(DISTINCT st.service_type_id) as service_types_used_in
FROM planning_center.services_songs s
LEFT JOIN planning_center.services_items i ON s.song_id = i.song_id
LEFT JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
LEFT JOIN planning_center.services_arrangements a ON i.arrangement_id = a.arrangement_id
LEFT JOIN planning_center.services_service_types st ON pl.service_type_id = st.service_type_id
WHERE i.item_type = 'song' OR i.item_type IS NULL
GROUP BY s.song_id, s.title, s.author, s.copyright, s.ccli_number, s.themes, s.hidden
),
arrangement_counts AS (
SELECT
song_id,
COUNT(*) as arrangement_count,
STRING_AGG(name || ' (' || chord_chart_key || ')', ', ') as arrangements
FROM planning_center.services_arrangements
WHERE archived_at IS NULL
GROUP BY song_id
)
SELECT
sm.title,
sm.author,
sm.copyright,
sm.ccli_number,
sm.themes,
CASE WHEN sm.hidden = true THEN 'Hidden' ELSE 'Active' END as status,
COALESCE(sm.total_uses, 0) as total_uses,
COALESCE(sm.uses_last_3_months, 0) as recent_uses,
COALESCE(sm.uses_last_year, 0) as yearly_uses,
sm.first_used,
sm.last_used,
CURRENT_DATE - sm.last_used as days_since_used,
sm.keys_used,
ac.arrangement_count,
ac.arrangements,
sm.avg_length_seconds / 60.0 as avg_length_minutes,
sm.service_types_used_in,
CASE
WHEN sm.last_used IS NULL THEN 'Never Used'
WHEN sm.last_used < CURRENT_DATE - INTERVAL '1 year' THEN 'Not Recently Used'
WHEN sm.uses_last_3_months >= 10 THEN 'High Rotation'
WHEN sm.uses_last_3_months >= 5 THEN 'Regular Rotation'
WHEN sm.uses_last_3_months >= 2 THEN 'Occasional'
ELSE 'Rarely Used'
END as usage_category,
CASE
WHEN sm.last_used < CURRENT_DATE - INTERVAL '2 months'
AND sm.uses_last_year >= 6 THEN 'Consider Scheduling'
WHEN sm.uses_last_3_months >= 8 THEN 'Recently Overused'
ELSE 'Normal'
END as recommendation
FROM song_metrics sm
LEFT JOIN arrangement_counts ac ON sm.song_id = ac.song_id
ORDER BY sm.uses_last_3_months DESC, sm.title;
Service Flow Analysis
Copy
-- Analyze service structure and timing patterns
WITH service_details AS (
SELECT
st.name as service_type,
pl.plan_id,
pl.title,
pl.series_title,
pl.sort_date,
pl.total_length / 60.0 as total_minutes,
pl.plan_people_count as volunteers,
COUNT(i.item_id) as item_count,
COUNT(CASE WHEN i.item_type = 'song' THEN 1 END) as song_count,
COUNT(CASE WHEN i.item_type = 'header' THEN 1 END) as header_count,
COUNT(CASE WHEN i.item_type = 'media' THEN 1 END) as media_count,
SUM(CASE WHEN i.item_type = 'song' THEN i.length ELSE 0 END) / 60.0 as music_minutes,
SUM(CASE WHEN i.item_type != 'song' THEN i.length ELSE 0 END) / 60.0 as non_music_minutes,
STRING_AGG(
CASE WHEN i.item_type = 'header' THEN i.title END,
' > ' ORDER BY i.sequence
) as section_flow
FROM planning_center.services_plans pl
JOIN planning_center.services_service_types st ON pl.service_type_id = st.service_type_id
LEFT JOIN planning_center.services_items i ON pl.plan_id = i.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY st.name, pl.plan_id, pl.title, pl.series_title, pl.sort_date,
pl.total_length, pl.plan_people_count
)
SELECT
service_type,
title,
series_title,
sort_date,
total_minutes,
volunteers,
item_count,
song_count,
header_count,
media_count,
ROUND(music_minutes, 1) as worship_minutes,
ROUND(non_music_minutes, 1) as other_minutes,
ROUND(music_minutes * 100 / NULLIF(total_minutes, 0), 1) as worship_percentage,
ROUND(volunteers::numeric / NULLIF(item_count, 0), 1) as volunteers_per_item,
section_flow,
CASE
WHEN total_minutes < 45 THEN 'Short Service'
WHEN total_minutes BETWEEN 45 AND 75 THEN 'Standard Service'
WHEN total_minutes BETWEEN 75 AND 90 THEN 'Extended Service'
ELSE 'Long Service'
END as service_length_category,
CASE
WHEN song_count = 0 THEN 'No Music'
WHEN song_count <= 3 THEN 'Light Music'
WHEN song_count <= 5 THEN 'Standard Music'
ELSE 'Music Heavy'
END as music_emphasis
FROM service_details
ORDER BY sort_date DESC;
Ministry Effectiveness Metrics
Volunteer Engagement Score
Copy
-- Calculate comprehensive engagement scores for volunteers
WITH engagement_metrics AS (
SELECT
p.person_id,
p.full_name,
-- Participation metrics
COUNT(DISTINCT pp.plan_id) as total_scheduled,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'C') as confirmed_services,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'D') as declined_services,
COUNT(DISTINCT t.team_id) as teams_serving,
COUNT(DISTINCT DATE_TRUNC('month', pl.sort_date)) as months_active,
-- Timing metrics
AVG(EXTRACT(DAYS FROM pl.sort_date - pp.notification_sent_at)) as avg_notice_days,
AVG(EXTRACT(DAYS FROM pl.sort_date - pp.status_updated_at)) as avg_response_days,
-- Reliability metrics
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pp.status = 'C' AND pp.status_updated_at > pl.sort_date - INTERVAL '7 days'
) as early_confirmations,
-- Recent activity
MAX(pl.sort_date) as last_served,
MIN(pl.sort_date) FILTER (WHERE pl.sort_date >= CURRENT_DATE) as next_scheduled
FROM planning_center.services_people p
JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '6 months'
AND p.archived = false
GROUP BY p.person_id, p.full_name
HAVING COUNT(DISTINCT pp.plan_id) >= 3 -- Minimum activity threshold
)
SELECT
full_name,
total_scheduled,
confirmed_services,
declined_services,
ROUND(confirmed_services::numeric * 100 / NULLIF(total_scheduled, 0), 1) as confirmation_rate,
teams_serving,
months_active,
ROUND(total_scheduled::numeric / NULLIF(months_active, 0), 1) as services_per_month,
ROUND(avg_notice_days, 1) as avg_notice_days,
ROUND(avg_response_days, 1) as avg_response_days,
ROUND(early_confirmations::numeric * 100 / NULLIF(confirmed_services, 0), 1) as early_confirm_rate,
last_served,
next_scheduled,
-- Calculate engagement score (0-100)
ROUND(
(
-- Confirmation rate (40% weight)
(confirmed_services::numeric / NULLIF(total_scheduled, 0) * 40) +
-- Activity consistency (30% weight)
(LEAST(months_active / 6.0, 1.0) * 30) +
-- Response time (20% weight)
(CASE
WHEN avg_response_days <= 2 THEN 20
WHEN avg_response_days <= 5 THEN 15
WHEN avg_response_days <= 7 THEN 10
ELSE 5
END) +
-- Team diversity (10% weight)
(LEAST(teams_serving / 3.0, 1.0) * 10)
), 1
) as engagement_score,
CASE
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.9
AND months_active >= 5 THEN 'Champion'
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.75
AND months_active >= 3 THEN 'Reliable'
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.6 THEN 'Developing'
ELSE 'Needs Support'
END as engagement_category
FROM engagement_metrics
ORDER BY engagement_score DESC;
Team Health Dashboard
Copy
-- Comprehensive team health metrics
WITH team_stats AS (
SELECT
t.team_id,
t.name,
t.rehearsal_team,
t.secure_team,
COUNT(DISTINCT pp.person_id) as active_members,
COUNT(DISTINCT pp.plan_id) as total_schedules,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'C') as confirmed_schedules,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'D') as declined_schedules,
COUNT(DISTINCT np.plan_id) as plans_with_needs,
SUM(np.quantity) as total_positions_needed
FROM planning_center.services_teams t
LEFT JOIN planning_center.services_plan_people pp ON t.team_id = pp.team_id
LEFT JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
LEFT JOIN planning_center.services_needed_positions np ON t.team_id = np.team_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND t.archived_at IS NULL
GROUP BY t.team_id, t.name, t.rehearsal_team, t.secure_team
),
position_coverage AS (
SELECT
t.team_id,
COUNT(DISTINCT tp.team_position_id) as position_count,
COUNT(DISTINCT pa.person_id) as qualified_people,
STRING_AGG(DISTINCT tp.name, ', ') as positions
FROM planning_center.services_teams t
LEFT JOIN planning_center.services_team_positions tp ON t.team_id = tp.team_id
LEFT JOIN planning_center.services_person_team_position_assignments pa
ON tp.team_position_id = pa.team_position_id
GROUP BY t.team_id
),
recent_trends AS (
SELECT
pp.team_id,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as schedules_last_month,
COUNT(DISTINCT pp.person_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as people_last_month
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
GROUP BY pp.team_id
)
SELECT
ts.name as team_name,
CASE WHEN ts.rehearsal_team THEN 'Yes' ELSE 'No' END as rehearsal_required,
CASE WHEN ts.secure_team THEN 'Yes' ELSE 'No' END as background_check_required,
ts.active_members,
pc.position_count,
pc.qualified_people,
ROUND(pc.qualified_people::numeric / NULLIF(pc.position_count, 0), 1) as people_per_position,
ts.total_schedules as schedules_3_month,
rt.schedules_last_month,
rt.people_last_month,
ts.confirmed_schedules,
ROUND(ts.confirmed_schedules::numeric * 100 / NULLIF(ts.total_schedules, 0), 1) as confirmation_rate,
ts.declined_schedules,
ts.plans_with_needs,
ts.total_positions_needed,
ROUND(ts.total_positions_needed::numeric / NULLIF(ts.plans_with_needs, 0), 1) as avg_needs_per_plan,
pc.positions,
-- Health score calculation
CASE
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.9
AND pc.qualified_people >= pc.position_count * 2
AND ts.plans_with_needs = 0 THEN 'Excellent'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.8
AND pc.qualified_people >= pc.position_count * 1.5 THEN 'Good'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.7
AND pc.qualified_people >= pc.position_count THEN 'Fair'
ELSE 'Needs Attention'
END as health_status,
CASE
WHEN ts.plans_with_needs > ts.total_schedules * 0.2 THEN 'Understaffed - Recruit More'
WHEN pc.qualified_people < pc.position_count * 1.5 THEN 'Low Depth - Train Backups'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) < 0.7 THEN 'Low Engagement - Connect with Team'
ELSE 'Healthy'
END as primary_recommendation
FROM team_stats ts
LEFT JOIN position_coverage pc ON ts.team_id = pc.team_id
LEFT JOIN recent_trends rt ON ts.team_id = rt.team_id
ORDER BY ts.active_members DESC;
Executive Summary Report
Ministry Overview Dashboard
Copy
-- High-level metrics for leadership
WITH summary_metrics AS (
SELECT
-- People metrics
(SELECT COUNT(*) FROM planning_center.services_people WHERE archived = false) as total_volunteers,
(SELECT COUNT(DISTINCT person_id)
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans p ON pp.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months') as active_volunteers,
-- Team metrics
(SELECT COUNT(*) FROM planning_center.services_teams WHERE archived_at IS NULL) as total_teams,
-- Service metrics
(SELECT COUNT(*)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE - INTERVAL '3 months') as services_last_quarter,
(SELECT COUNT(*)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE
AND sort_date <= CURRENT_DATE + INTERVAL '1 month') as services_next_month,
-- Song metrics
(SELECT COUNT(*) FROM planning_center.services_songs WHERE hidden = false) as active_songs,
(SELECT COUNT(DISTINCT song_id)
FROM planning_center.services_items i
JOIN planning_center.services_plans p ON i.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND i.item_type = 'song') as songs_used_recently,
-- Scheduling metrics
(SELECT AVG(plan_people_count)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE - INTERVAL '3 months') as avg_volunteers_per_service,
(SELECT SUM(quantity)
FROM planning_center.services_needed_positions np
JOIN planning_center.services_plans p ON np.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE
AND p.sort_date <= CURRENT_DATE + INTERVAL '2 weeks') as open_positions_two_weeks
)
SELECT
total_volunteers,
active_volunteers,
ROUND(active_volunteers::numeric * 100 / NULLIF(total_volunteers, 0), 1) as volunteer_engagement_rate,
total_teams,
services_last_quarter,
services_next_month,
active_songs,
songs_used_recently,
ROUND(songs_used_recently::numeric * 100 / NULLIF(active_songs, 0), 1) as song_utilization_rate,
ROUND(avg_volunteers_per_service, 1) as avg_volunteers_per_service,
COALESCE(open_positions_two_weeks, 0) as urgent_scheduling_needs,
CASE
WHEN open_positions_two_weeks > 10 THEN 'Critical - Many Open Positions'
WHEN open_positions_two_weeks > 5 THEN 'Warning - Some Gaps'
ELSE 'Healthy - Well Staffed'
END as scheduling_status
FROM summary_metrics;
Export-Ready Formats
CSV Export for Volunteer Contact
Copy
-- Volunteer contact list for mail merge or communication
SELECT
p.full_name,
p.first_name,
p.last_name,
STRING_AGG(DISTINCT t.name, '; ') as teams,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as services_last_3_months,
MAX(pl.sort_date) FILTER (
WHERE pl.sort_date < CURRENT_DATE
) as last_served,
MIN(pl.sort_date) FILTER (
WHERE pl.sort_date >= CURRENT_DATE
) as next_scheduled,
CASE
WHEN p.archived = true THEN 'Inactive'
WHEN COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) = 0 THEN 'No Recent Activity'
ELSE 'Active'
END as status
FROM planning_center.services_people p
LEFT JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
LEFT JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
LEFT JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE p.archived = false
GROUP BY p.person_id, p.full_name, p.first_name, p.last_name, p.archived
ORDER BY p.last_name, p.first_name;
Tips for Report Building
Performance: For large datasets, consider creating materialized views for complex calculations that don’t need real-time updates.
Visualization: These queries return data optimized for charts. Use the categorical fields for grouping and numerical fields for metrics.
Privacy: Always respect privacy when sharing reports. Consider removing personally identifiable information for broad distribution.
Next Steps
- Import these queries into your BI tool of choice
- Set up automated refresh schedules for dashboards
- Create drill-down capabilities from summary to detail views
- Combine with other Planning Center modules for comprehensive insights
- Export key metrics for leadership meetings and planning sessions