feat: validate user availability

This commit is contained in:
Mann Patel
2025-09-09 10:42:24 -06:00
parent 287068becf
commit 144436bbf3
13 changed files with 544 additions and 704 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,4 +1,16 @@
# Poll-system # Poll-system
- TODO: Reports Generation, Export csv, Print Pdf, Show Charts - TODO: volunteer Available
- TODO: VOlunteer Schedual or avilablity - TODO: Update assign address func to take into account availability
- 18'' Metal Ruler
- Sketching Material (dollaram)(sketch)
- Adhesive (staples/dollaram)
Done:
- Exacto
- A Large Cutting Mat
- Black Construction Paper
- And a lock for your locker
- White Foam Core or Cardstock Paper (dollaram)

BIN
app/.DS_Store vendored

Binary file not shown.

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"fmt"
"github.com/patel-mann/poll-system/app/internal/models" "github.com/patel-mann/poll-system/app/internal/models"
"github.com/patel-mann/poll-system/app/internal/utils" "github.com/patel-mann/poll-system/app/internal/utils"
@@ -303,10 +304,13 @@ func AssignAddressHandler(w http.ResponseWriter, r *http.Request) {
} }
// Parse and validate the start time // Parse and validate the start time
_, err = time.Parse("15:04", startTime) parsedTime, err := time.Parse("15:04", startTime)
if err != nil { is_valid := ValidatedFreeTime(parsedDate, parsedTime, userID)
http.Error(w, "Invalid start time format", http.StatusBadRequest) if is_valid != true {
http.Error(w, "User is not availabile", http.StatusBadRequest)
return return
}else{
fmt.Print("hello")
} }
// Verify the user exists and is associated with the current admin // Verify the user exists and is associated with the current admin

View File

@@ -177,577 +177,425 @@ func getAllReportDefinitions() map[string][]ReportDefinition {
return map[string][]ReportDefinition{ return map[string][]ReportDefinition{
"users": { "users": {
{ {
ID: "volunteer_participation_rate", // get all the appointment(done, notdone, total) poll(done, not doen, total) ID: "volunteer_participation_rate",
Name: "Volunteer participation rate", Name: "Volunteer Participation Rate",
Description: "Count of users grouped by their role", Description: "Poll responses and donations collected by each volunteer/team lead",
SQL: `SELECT
u.user_id,
u.first_name,
u.last_name,
COUNT(p.poll_id) AS total_polls,
COUNT(a.user_id) AS total_appointments,
case
WHEN COUNT(a.user_id) = 0 THEN NULL -- avoid division by zero
ELSE ROUND(CAST(COUNT(p.poll_id) AS numeric) / COUNT(a.user_id), 2)
END AS poll_to_appointment_rate
from users u
LEFT JOIN poll p ON u.user_id = p.user_id
LEFT JOIN appointment a ON u.user_id = a.user_id
GROUP BY u.user_id, u.first_name, u.last_name;`,
},
{
ID: "volunteer_activity",
Name: "Volunteer Activity Summary",
Description: "Summary of volunteer activities including appointments and polls",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name, u.first_name || ' ' || u.last_name as volunteer_name,
u.email, u.email,
COUNT(DISTINCT a.sched_id) as appointments_count, r.name as role,
COUNT(DISTINCT p.poll_id) as polls_created,
u.created_at as joined_date
FROM users u
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
WHERE u.role_id = 2
GROUP BY u.user_id, u.first_name, u.last_name, u.email, u.created_at
ORDER BY appointments_count DESC, polls_created DESC`,
},
{
ID: "team_performance",
Name: "Team Performance Report",
Description: "Performance metrics for each team",
SQL: `SELECT
t.team_id,
ul.first_name || ' ' || ul.last_name as team_lead,
uv.first_name || ' ' || uv.last_name as volunteer,
COUNT(DISTINCT a.sched_id) as appointments,
COUNT(DISTINCT p.poll_id) as polls_created,
t.created_at as team_formed
FROM team t
LEFT JOIN users ul ON t.team_lead_id = ul.user_id
LEFT JOIN users uv ON t.volunteer_id = uv.user_id
LEFT JOIN appointment a ON uv.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
LEFT JOIN poll p ON uv.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
GROUP BY t.team_id, ul.first_name, ul.last_name, uv.first_name, uv.last_name, t.created_at
ORDER BY appointments DESC`,
},
{
ID: "admin_workload",
Name: "Admin Workload Analysis",
Description: "Workload distribution across admins",
SQL: `SELECT
u.first_name || ' ' || u.last_name as admin_name,
u.email,
COUNT(DISTINCT t.team_id) as teams_managed,
COUNT(DISTINCT p.poll_id) as polls_created, COUNT(DISTINCT p.poll_id) as polls_created,
COUNT(DISTINCT pr.poll_response_id) as responses_collected,
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
COUNT(DISTINCT a.sched_id) as appointments_scheduled COUNT(DISTINCT a.sched_id) as appointments_scheduled
FROM users u FROM users u
LEFT JOIN team t ON u.user_id = t.team_lead_id JOIN role r ON u.role_id = r.role_id
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2 LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2 LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
WHERE u.role_id = 1 WHERE u.role_id IN (2, 3)
GROUP BY u.user_id, u.first_name, u.last_name, u.email GROUP BY u.user_id, u.first_name, u.last_name, u.email, r.name
ORDER BY teams_managed DESC, polls_created DESC`, ORDER BY responses_collected DESC, total_donations DESC`,
}, },
{ {
ID: "inactive_users", ID: "top_performing_volunteers",
Name: "Inactive Users Report", Name: "Top-Performing Volunteers & Team Leads",
Description: "Users with no recent activity", Description: "Volunteers ranked by responses collected and donations secured",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as user_name, u.first_name || ' ' || u.last_name as volunteer_name,
u.email, r.name as role,
CASE COUNT(DISTINCT pr.poll_response_id) as responses_collected,
WHEN u.role_id = 1 THEN 'Admin' COALESCE(SUM(pr.question6_donation_amount), 0) as donations_secured,
WHEN u.role_id = 2 THEN 'Volunteer' COUNT(DISTINCT p.poll_id) as polls_created,
ELSE 'Unknown' AVG(pr.question6_donation_amount) as avg_donation_per_poll
END as role,
u.created_at as joined_date,
COALESCE(MAX(a.created_at), MAX(p.created_at)) as last_activity
FROM users u FROM users u
LEFT JOIN appointment a ON u.user_id = a.user_id JOIN role r ON u.role_id = r.role_id
LEFT JOIN poll p ON u.user_id = p.user_id JOIN poll p ON u.user_id = p.user_id
GROUP BY u.user_id, u.first_name, u.last_name, u.email, u.role_id, u.created_at LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
HAVING COALESCE(MAX(a.created_at), MAX(p.created_at)) < ?1 OR COALESCE(MAX(a.created_at), MAX(p.created_at)) IS NULL WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
ORDER BY last_activity DESC`, GROUP BY u.user_id, u.first_name, u.last_name, r.name
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
ORDER BY responses_collected DESC, donations_secured DESC
LIMIT 20`,
},
{
ID: "response_donation_ratio",
Name: "Response-to-Donation Ratio per Volunteer",
Description: "Efficiency measure showing donation amount per response collected",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
COUNT(DISTINCT pr.poll_response_id) as total_responses,
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
CASE
WHEN COUNT(DISTINCT pr.poll_response_id) > 0
THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
ELSE 0
END as donation_per_response,
CASE
WHEN COALESCE(SUM(pr.question6_donation_amount), 0) > 0
THEN COUNT(DISTINCT pr.poll_response_id) / COALESCE(SUM(pr.question6_donation_amount), 1)
ELSE COUNT(DISTINCT pr.poll_response_id)
END as responses_per_dollar
FROM users u
JOIN poll p ON u.user_id = p.user_id
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
WHERE u.role_id IN (2, 3) AND p.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name
HAVING COUNT(DISTINCT pr.poll_response_id) > 0
ORDER BY donation_per_response DESC`,
},
{
ID: "user_address_coverage",
Name: "User Address Coverage",
Description: "Number of unique addresses covered by each volunteer/team lead",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
COUNT(DISTINCT p.address_id) as unique_addresses_polled,
COUNT(DISTINCT a.address_id) as unique_addresses_appointed,
COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) as total_unique_addresses,
COUNT(DISTINCT pr.poll_response_id) as total_responses
FROM users u
LEFT JOIN poll p ON u.user_id = p.user_id AND p.created_at BETWEEN ?1 AND ?2
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
WHERE u.role_id IN (2, 3)
GROUP BY u.user_id, u.first_name, u.last_name
HAVING COUNT(DISTINCT COALESCE(p.address_id, a.address_id)) > 0
ORDER BY total_unique_addresses DESC, total_responses DESC`,
}, },
}, },
"addresses": { "addresses": {
{ {
ID: "coverage_by_area", ID: "poll_responses_by_address",
Name: "Coverage by Area", Name: "Total Poll Responses by Address",
Description: "Address coverage statistics by geographical area", Description: "Shows engagement hotspots - addresses with most poll responses",
SQL: `SELECT SQL: `SELECT
COALESCE(NULLIF(TRIM(SPLIT_PART(address, ',', -1)), ''), 'Unknown') as area, ad.address,
COUNT(*) as total_addresses, ad.postal_code,
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited_count, ad.street_quadrant,
ROUND(COUNT(CASE WHEN visited_validated = true THEN 1 END) * 100.0 / COUNT(*), 2) as coverage_percentage COUNT(DISTINCT pr.poll_response_id) as total_responses,
FROM address_database COUNT(DISTINCT p.poll_id) as polls_conducted,
WHERE created_at BETWEEN ?1 AND ?2 COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
GROUP BY area CASE
ORDER BY total_addresses DESC`, WHEN COUNT(DISTINCT pr.poll_response_id) > 0
}, THEN COALESCE(SUM(pr.question6_donation_amount), 0) / COUNT(DISTINCT pr.poll_response_id)
{ ELSE 0
ID: "visits_by_postal", END as avg_donation_per_response
Name: "Visits by Postal Code", FROM address_database ad
Description: "Visit statistics grouped by postal code", JOIN poll p ON ad.address_id = p.address_id
SQL: `SELECT LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
COALESCE(NULLIF(TRIM(SUBSTRING(address FROM '[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]')), ''), 'No Postal Code') as postal_code, WHERE p.created_at BETWEEN ?1 AND ?2
COUNT(*) as addresses, GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
COUNT(CASE WHEN visited_validated = true THEN 1 END) as visited, ORDER BY total_responses DESC, total_donations DESC
COUNT(CASE WHEN visited_validated = false THEN 1 END) as unvisited
FROM address_database
WHERE created_at BETWEEN ?1 AND ?2
GROUP BY postal_code
ORDER BY addresses DESC
LIMIT 50`, LIMIT 50`,
}, },
{ {
ID: "unvisited_addresses", ID: "donations_by_address",
Name: "Unvisited Addresses", Name: "Total Donations by Address",
Description: "List of addresses that haven't been visited", Description: "Shows financially supportive areas - addresses with highest donations",
SQL: `SELECT SQL: `SELECT
address_id, ad.address,
address, ad.postal_code,
latitude, ad.street_quadrant,
longitude, COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
created_at as added_date COUNT(DISTINCT p.poll_id) as polls_conducted,
FROM address_database COUNT(DISTINCT pr.poll_response_id) as total_responses,
WHERE visited_validated = false AVG(pr.question6_donation_amount) as avg_donation_per_poll
AND created_at BETWEEN ?1 AND ?2 FROM address_database ad
ORDER BY created_at DESC JOIN poll p ON ad.address_id = p.address_id
LIMIT 100`, LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
}, WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
{ GROUP BY ad.address_id, ad.address, ad.postal_code, ad.street_quadrant
ID: "donations_by_location",
Name: "Donations by Location",
Description: "Donation amounts grouped by address location",
SQL: `SELECT
a.address,
COUNT(p.poll_id) as total_polls,
COALESCE(SUM(p.amount_donated), 0) as total_donations,
COALESCE(AVG(p.amount_donated), 0) as avg_donation
FROM address_database a
LEFT JOIN poll p ON a.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
GROUP BY a.address_id, a.address
HAVING COUNT(p.poll_id) > 0
ORDER BY total_donations DESC ORDER BY total_donations DESC
LIMIT 50`, LIMIT 50`,
}, },
{ {
ID: "address_validation_status", ID: "street_level_breakdown",
Name: "Address Validation Status", Name: "Street-Level Breakdown (Responses & Donations)",
Description: "Status of address validation across the database", Description: "Granular view for targeting - responses and donations by street",
SQL: `SELECT SQL: `SELECT
CASE ad.street_name,
WHEN visited_validated = true THEN 'Validated' ad.street_type,
WHEN visited_validated = false THEN 'Not Validated' ad.street_quadrant,
ELSE 'Unknown' COUNT(DISTINCT ad.address_id) as unique_addresses,
END as validation_status, COUNT(DISTINCT pr.poll_response_id) as total_responses,
COUNT(*) as address_count, COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM address_database), 2) as percentage COUNT(DISTINCT p.poll_id) as polls_conducted
FROM address_database FROM address_database ad
WHERE created_at BETWEEN ?1 AND ?2 LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
GROUP BY visited_validated LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
ORDER BY address_count DESC`, WHERE ad.street_name IS NOT NULL
GROUP BY ad.street_name, ad.street_type, ad.street_quadrant
HAVING COUNT(DISTINCT pr.poll_response_id) > 0 OR COALESCE(SUM(pr.question6_donation_amount), 0) > 0
ORDER BY total_responses DESC, total_donations DESC`,
},
{
ID: "quadrant_summary",
Name: "Quadrant-Level Summary (NE, NW, SE, SW)",
Description: "Higher-level trend view by city quadrants",
SQL: `SELECT
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
COUNT(DISTINCT ad.address_id) as unique_addresses,
COUNT(DISTINCT p.poll_id) as polls_conducted,
COUNT(DISTINCT pr.poll_response_id) as total_responses,
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
AVG(pr.question6_donation_amount) as avg_donation_per_poll,
COUNT(DISTINCT a.sched_id) as appointments_scheduled
FROM address_database ad
LEFT JOIN poll p ON ad.address_id = p.address_id AND p.created_at BETWEEN ?1 AND ?2
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
LEFT JOIN appointment a ON ad.address_id = a.address_id AND a.created_at BETWEEN ?1 AND ?2
GROUP BY ad.street_quadrant
ORDER BY total_responses DESC, total_donations DESC`,
}, },
}, },
"appointments": { "appointments": {
{ {
ID: "appointments_by_day", ID: "upcoming_appointments",
Name: "Appointments by Day", Name: "Upcoming Appointments per Volunteer/Team Lead",
Description: "Daily breakdown of appointment scheduling", Description: "Scheduling load - upcoming appointments by user",
SQL: `SELECT
appointment_date,
COUNT(*) as appointments_scheduled,
COUNT(DISTINCT user_id) as unique_volunteers,
COUNT(DISTINCT address_id) as unique_addresses
FROM appointment
WHERE appointment_date BETWEEN ?1 AND ?2
GROUP BY appointment_date
ORDER BY appointment_date DESC`,
},
{
ID: "completion_rates",
Name: "Completion Rates",
Description: "Appointment completion statistics by volunteer",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name, u.first_name || ' ' || u.last_name as volunteer_name,
COUNT(a.sched_id) as total_appointments, r.name as role,
COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) as completed_visits, COUNT(*) as upcoming_appointments,
ROUND(COUNT(CASE WHEN ad.visited_validated = true THEN 1 END) * 100.0 / COUNT(a.sched_id), 2) as completion_rate MIN(a.appointment_date) as earliest_appointment,
MAX(a.appointment_date) as latest_appointment,
COUNT(CASE WHEN a.appointment_date = CURRENT_DATE THEN 1 END) as today_appointments,
COUNT(CASE WHEN a.appointment_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as week_appointments
FROM appointment a
JOIN users u ON a.user_id = u.user_id
JOIN role r ON u.role_id = r.role_id
WHERE a.appointment_date >= CURRENT_DATE AND a.appointment_date BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name, r.name
ORDER BY upcoming_appointments DESC`,
},
{
ID: "missed_vs_completed",
Name: "Missed vs Completed Appointments",
Description: "Reliability metric - appointment completion rates",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
COUNT(*) as total_appointments,
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past_appointments,
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming_appointments,
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
AND DATE(p.created_at) = a.appointment_date
) THEN 1 END) as completed_appointments,
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND NOT EXISTS (
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
AND DATE(p.created_at) = a.appointment_date
) THEN 1 END) as missed_appointments,
CASE
WHEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) > 0
THEN COUNT(CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
AND DATE(p.created_at) = a.appointment_date
) THEN 1 END) * 100.0 / COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END)
ELSE 0
END as completion_rate_percent
FROM appointment a FROM appointment a
JOIN users u ON a.user_id = u.user_id JOIN users u ON a.user_id = u.user_id
LEFT JOIN address_database ad ON a.address_id = ad.address_id
WHERE a.created_at BETWEEN ?1 AND ?2 WHERE a.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name GROUP BY u.user_id, u.first_name, u.last_name
HAVING COUNT(a.sched_id) > 0 ORDER BY completion_rate_percent DESC, total_appointments DESC`,
ORDER BY completion_rate DESC, total_appointments DESC`,
}, },
{ {
ID: "volunteer_schedules", ID: "appointments_by_quadrant",
Name: "Volunteer Schedules", Name: "Appointments by Quadrant/Region",
Description: "Current volunteer scheduling overview", Description: "Geographic distribution of appointments",
SQL: `SELECT
COALESCE(ad.street_quadrant, 'Unknown') as quadrant,
COUNT(*) as total_appointments,
COUNT(CASE WHEN a.appointment_date >= CURRENT_DATE THEN 1 END) as upcoming,
COUNT(CASE WHEN a.appointment_date < CURRENT_DATE THEN 1 END) as past,
COUNT(DISTINCT a.user_id) as unique_volunteers,
COUNT(DISTINCT a.address_id) as unique_addresses
FROM appointment a
JOIN address_database ad ON a.address_id = ad.address_id
WHERE a.created_at BETWEEN ?1 AND ?2
GROUP BY ad.street_quadrant
ORDER BY total_appointments DESC`,
},
{
ID: "scheduling_lead_time",
Name: "Average Lead Time (Scheduled vs Actual Date)",
Description: "Scheduling efficiency - time between scheduling and appointment",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name, u.first_name || ' ' || u.last_name as volunteer_name,
a.appointment_date, COUNT(*) as total_appointments,
a.appointment_time, AVG(a.appointment_date - DATE(a.created_at)) as avg_lead_time_days,
ad.address, MIN(a.appointment_date - DATE(a.created_at)) as min_lead_time_days,
a.created_at as scheduled_date MAX(a.appointment_date - DATE(a.created_at)) as max_lead_time_days,
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) < 1 THEN 1 END) as same_day_appointments,
COUNT(CASE WHEN a.appointment_date - DATE(a.created_at) BETWEEN 1 AND 7 THEN 1 END) as week_ahead_appointments
FROM appointment a FROM appointment a
JOIN users u ON a.user_id = u.user_id JOIN users u ON a.user_id = u.user_id
JOIN address_database ad ON a.address_id = ad.address_id WHERE a.created_at BETWEEN ?1 AND ?2
WHERE a.appointment_date BETWEEN ?1 AND ?2 GROUP BY u.user_id, u.first_name, u.last_name
ORDER BY a.appointment_date, a.appointment_time`, HAVING COUNT(*) > 0
}, ORDER BY avg_lead_time_days ASC`,
{
ID: "missed_appointments",
Name: "Missed Appointments",
Description: "Appointments that were scheduled but addresses remain unvisited",
SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name,
a.appointment_date,
a.appointment_time,
ad.address,
CASE
WHEN a.appointment_date < CURRENT_DATE THEN 'Overdue'
ELSE 'Upcoming'
END as status
FROM appointment a
JOIN users u ON a.user_id = u.user_id
JOIN address_database ad ON a.address_id = ad.address_id
WHERE ad.visited_validated = false
AND a.appointment_date BETWEEN ?1 AND ?2
ORDER BY a.appointment_date DESC`,
},
{
ID: "peak_hours",
Name: "Peak Activity Hours",
Description: "Most popular appointment times",
SQL: `SELECT
appointment_time,
COUNT(*) as appointment_count,
COUNT(DISTINCT user_id) as unique_volunteers
FROM appointment
WHERE appointment_date BETWEEN ?1 AND ?2
GROUP BY appointment_time
ORDER BY appointment_count DESC`,
}, },
}, },
"polls": { "polls": {
{ {
ID: "poll_creation_stats", ID: "response_distribution",
Name: "Poll Creation Statistics", Name: "Response Distribution (Yes/No/Neutral)",
Description: "Overview of poll creation activity", Description: "Outcome summary - distribution of poll responses",
SQL: `SELECT
u.first_name || ' ' || u.last_name as creator_name,
COUNT(p.poll_id) as polls_created,
COUNT(CASE WHEN p.is_active = true THEN 1 END) as active_polls,
COALESCE(SUM(p.amount_donated), 0) as total_donations,
COALESCE(AVG(p.amount_donated), 0) as avg_donation_per_poll
FROM poll p
JOIN users u ON p.user_id = u.user_id
WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name
ORDER BY polls_created DESC`,
},
{
ID: "donation_analysis",
Name: "Donation Analysis",
Description: "Detailed analysis of donation patterns",
SQL: `SELECT SQL: `SELECT
CASE CASE
WHEN amount_donated = 0 THEN 'No Donation' WHEN question1_voted_before = true AND question2_vote_again = true THEN 'Previous Voter - Will Vote Again'
WHEN amount_donated BETWEEN 0.01 AND 25 THEN '$1 - $25' WHEN question1_voted_before = true AND question2_vote_again = false THEN 'Previous Voter - Will Not Vote Again'
WHEN amount_donated BETWEEN 25.01 AND 50 THEN '$26 - $50' WHEN question1_voted_before = false AND question2_vote_again = true THEN 'New Voter - Will Vote'
WHEN amount_donated BETWEEN 50.01 AND 100 THEN '$51 - $100' WHEN question1_voted_before = false AND question2_vote_again = false THEN 'New Voter - Will Not Vote'
ELSE 'Over $100' WHEN question1_voted_before IS NULL OR question2_vote_again IS NULL THEN 'Incomplete Response'
END as donation_range, END as response_category,
COUNT(*) as poll_count,
COALESCE(SUM(amount_donated), 0) as total_amount,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll WHERE created_at BETWEEN ?1 AND ?2), 2) as percentage
FROM poll
WHERE created_at BETWEEN ?1 AND ?2
GROUP BY donation_range
ORDER BY
CASE donation_range
WHEN 'No Donation' THEN 1
WHEN '$1 - $25' THEN 2
WHEN '$26 - $50' THEN 3
WHEN '$51 - $100' THEN 4
WHEN 'Over $100' THEN 5
END`,
},
{
ID: "active_vs_inactive",
Name: "Active vs Inactive Polls",
Description: "Comparison of active and inactive polls",
SQL: `SELECT
CASE
WHEN is_active = true THEN 'Active'
ELSE 'Inactive'
END as poll_status,
COUNT(*) as poll_count,
COALESCE(SUM(amount_donated), 0) as total_donations,
COALESCE(AVG(amount_donated), 0) as avg_donation
FROM poll
WHERE created_at BETWEEN ?1 AND ?2
GROUP BY is_active
ORDER BY poll_count DESC`,
},
{
ID: "poll_trends",
Name: "Poll Activity Trends",
Description: "Poll creation trends over time",
SQL: `SELECT
DATE(created_at) as creation_date,
COUNT(*) as polls_created,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_polls,
COALESCE(SUM(amount_donated), 0) as daily_donations
FROM poll
WHERE created_at BETWEEN ?1 AND ?2
GROUP BY DATE(created_at)
ORDER BY creation_date DESC`,
},
{
ID: "creator_performance",
Name: "Creator Performance",
Description: "Performance metrics for poll creators",
SQL: `SELECT
u.first_name || ' ' || u.last_name as creator_name,
u.email,
COUNT(p.poll_id) as total_polls,
COALESCE(SUM(p.amount_donated), 0) as total_raised,
COALESCE(MAX(p.amount_donated), 0) as highest_donation,
COUNT(CASE WHEN p.is_active = true THEN 1 END) as active_polls
FROM users u
JOIN poll p ON u.user_id = p.user_id
WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name, u.email
ORDER BY total_raised DESC, total_polls DESC`,
},
},
"responses": {
{
ID: "voter_status",
Name: "Voter Status Report",
Description: "Analysis of voter status from poll responses",
SQL: `SELECT
voter_before as voted_before,
COUNT(*) as response_count, COUNT(*) as response_count,
COUNT(CASE WHEN will_vote_again = true THEN 1 END) as will_vote_again_count, COUNT(*) * 100.0 / (SELECT COUNT(*) FROM poll_response pr2
ROUND(COUNT(CASE WHEN will_vote_again = true THEN 1 END) * 100.0 / COUNT(*), 2) as vote_again_percentage JOIN poll p2 ON pr2.poll_id = p2.poll_id
WHERE p2.created_at BETWEEN ?1 AND ?2) as percentage
FROM poll_response pr FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY voter_before GROUP BY response_category
ORDER BY response_count DESC`, ORDER BY response_count DESC`,
}, },
{ {
ID: "sign_requests", ID: "average_poll_response",
Name: "Sign Requests Summary", Name: "Average Poll Response (Yes/No %)",
Description: "Summary of lawn sign and banner requests", Description: "Overall sentiment - percentage breakdown of responses",
SQL: `SELECT SQL: `SELECT
'Lawn Signs' as sign_type, 'Previous Voters' as voter_type,
SUM(lawn_sign) as total_requested, COUNT(*) as total_responses,
SUM(CASE WHEN lawn_sign_status = 'delivered' THEN lawn_sign ELSE 0 END) as delivered, COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
SUM(CASE WHEN lawn_sign_status = 'cancelled' THEN lawn_sign ELSE 0 END) as cancelled COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
FROM poll_response pr FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = true
UNION ALL UNION ALL
SELECT SELECT
'Banner Signs' as sign_type, 'New Voters' as voter_type,
SUM(banner_sign) as total_requested, COUNT(*) as total_responses,
SUM(CASE WHEN banner_sign_status = 'delivered' THEN banner_sign ELSE 0 END) as delivered, COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
SUM(CASE WHEN banner_sign_status = 'cancelled' THEN banner_sign ELSE 0 END) as cancelled COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 AND question1_voted_before = false
UNION ALL
SELECT
'Overall' as voter_type,
COUNT(*) as total_responses,
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) as positive_responses,
COUNT(CASE WHEN question2_vote_again = false THEN 1 END) as negative_responses,
COUNT(CASE WHEN question2_vote_again = true THEN 1 END) * 100.0 / COUNT(*) as positive_percentage
FROM poll_response pr FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2`, WHERE p.created_at BETWEEN ?1 AND ?2`,
}, },
{ {
ID: "feedback_analysis", ID: "donations_by_poll",
Name: "Feedback Analysis", Name: "Donations by Poll",
Description: "Analysis of open-text feedback from responses", Description: "Which polls drive donations - donation amounts per poll",
SQL: `SELECT SQL: `SELECT
LENGTH(thoughts) as feedback_length_category, p.poll_id,
COUNT(*) as response_count u.first_name || ' ' || u.last_name as creator_name,
ad.address,
pr.question6_donation_amount,
COUNT(pr.poll_response_id) as response_count,
CASE
WHEN COUNT(pr.poll_response_id) > 0
THEN pr.question6_donation_amount / COUNT(pr.poll_response_id)
ELSE 0
END as donation_per_response,
p.created_at as poll_date
FROM poll p
JOIN users u ON p.user_id = u.user_id
JOIN address_database ad ON p.address_id = ad.address_id
LEFT JOIN poll_response pr ON p.poll_id = pr.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 AND pr.question6_donation_amount > 0
GROUP BY p.poll_id, u.first_name, u.last_name, ad.address, pr.question6_donation_amount, p.created_at
ORDER BY pr.question6_donation_amount DESC, response_count DESC`,
},
{
ID: "response_donation_correlation",
Name: "Response-to-Donation Correlation",
Description: "Are positive responses linked to donations?",
SQL: `SELECT
CASE
WHEN question2_vote_again = true THEN 'Will Vote Again'
WHEN question2_vote_again = false THEN 'Will Not Vote Again'
ELSE 'No Response'
END as response_type,
COUNT(*) as response_count,
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) as responses_with_donations,
COALESCE(SUM(pr.question6_donation_amount), 0) as total_donations,
AVG(pr.question6_donation_amount) as avg_donation,
COUNT(CASE WHEN pr.question6_donation_amount > 0 THEN 1 END) * 100.0 / COUNT(*) as donation_rate_percent
FROM poll_response pr FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2 WHERE p.created_at BETWEEN ?1 AND ?2
AND thoughts IS NOT NULL GROUP BY question2_vote_again
AND TRIM(thoughts) != '' ORDER BY total_donations DESC`,
GROUP BY
CASE
WHEN LENGTH(thoughts) <= 50 THEN 'Short (1-50 chars)'
WHEN LENGTH(thoughts) <= 150 THEN 'Medium (51-150 chars)'
ELSE 'Long (150+ chars)'
END
ORDER BY response_count DESC`,
},
{
ID: "response_trends",
Name: "Response Trends",
Description: "Poll response trends over time",
SQL: `SELECT
DATE(pr.created_at) as response_date,
COUNT(*) as responses,
COUNT(CASE WHEN voter_before = true THEN 1 END) as returning_voters,
COUNT(CASE WHEN will_vote_again = true THEN 1 END) as committed_future_voters
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE pr.created_at BETWEEN ?1 AND ?2
GROUP BY DATE(pr.created_at)
ORDER BY response_date DESC`,
},
{
ID: "repeat_voters",
Name: "Repeat Voters Analysis",
Description: "Analysis of voters who have responded to multiple polls",
SQL: `SELECT
pr.name,
pr.email,
COUNT(DISTINCT pr.poll_id) as polls_responded,
SUM(CASE WHEN voter_before = true THEN 1 ELSE 0 END) as times_voted_before,
SUM(CASE WHEN will_vote_again = true THEN 1 ELSE 0 END) as times_will_vote_again
FROM poll_response pr
JOIN poll p ON pr.poll_id = p.poll_id
WHERE p.created_at BETWEEN ?1 AND ?2
GROUP BY pr.name, pr.email
HAVING COUNT(DISTINCT pr.poll_id) > 1
ORDER BY polls_responded DESC`,
},
},
"posts": {
{
ID: "posts_by_user",
Name: "Posts by User",
Description: "Post creation statistics by user",
SQL: `SELECT
u.first_name || ' ' || u.last_name as author_name,
u.email,
COUNT(po.post_id) as total_posts,
MIN(po.created_at) as first_post,
MAX(po.created_at) as latest_post
FROM users u
JOIN posts po ON u.user_id = po.user_id
WHERE po.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name, u.email
ORDER BY total_posts DESC`,
},
{
ID: "engagement_timeline",
Name: "Engagement Timeline",
Description: "Post creation timeline",
SQL: `SELECT
DATE(created_at) as post_date,
COUNT(*) as posts_created,
COUNT(DISTINCT user_id) as active_users
FROM posts
WHERE created_at BETWEEN ?1 AND ?2
GROUP BY DATE(created_at)
ORDER BY post_date DESC`,
},
{
ID: "content_analysis",
Name: "Content Analysis",
Description: "Analysis of post content length and characteristics",
SQL: `SELECT
CASE
WHEN LENGTH(content) <= 100 THEN 'Short (1-100 chars)'
WHEN LENGTH(content) <= 300 THEN 'Medium (101-300 chars)'
ELSE 'Long (300+ chars)'
END as content_length,
COUNT(*) as post_count,
ROUND(AVG(LENGTH(content)), 2) as avg_length
FROM posts
WHERE created_at BETWEEN ?1 AND ?2
AND content IS NOT NULL
GROUP BY content_length
ORDER BY post_count DESC`,
},
{
ID: "post_frequency",
Name: "Post Frequency Report",
Description: "Posting frequency patterns",
SQL: `SELECT
u.first_name || ' ' || u.last_name as author_name,
COUNT(*) as total_posts,
ROUND(COUNT(*) * 1.0 / GREATEST(1, EXTRACT(days FROM (?2::date - ?1::date))), 2) as posts_per_day,
DATE(MIN(po.created_at)) as first_post,
DATE(MAX(po.created_at)) as last_post
FROM posts po
JOIN users u ON po.user_id = u.user_id
WHERE po.created_at BETWEEN ?1 AND ?2
GROUP BY u.user_id, u.first_name, u.last_name
HAVING COUNT(*) > 1
ORDER BY posts_per_day DESC`,
}, },
}, },
"availability": { "availability": {
{ {
ID: "volunteer_availability", ID: "volunteer_availability_schedule",
Name: "Volunteer Availability", Name: "Volunteer Availability by Date Range",
Description: "Current volunteer availability schedules", Description: "Who can work when - current volunteer availability schedules",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name, u.first_name || ' ' || u.last_name as volunteer_name,
u.email,
av.day_of_week, av.day_of_week,
av.start_time, av.start_time,
av.end_time, av.end_time,
EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600 as hours_available,
av.created_at as schedule_updated av.created_at as schedule_updated
FROM volunteer_availability av FROM availability av
JOIN users u ON av.user_id = u.user_id JOIN users u ON av.user_id = u.user_id
WHERE av.created_at BETWEEN ?1 AND ?2 WHERE u.role_id IN (2, 3) AND av.created_at BETWEEN ?1 AND ?2
ORDER BY u.first_name, u.last_name, av.day_of_week, av.start_time`, ORDER BY u.first_name, u.last_name,
CASE av.day_of_week
WHEN 'Monday' THEN 1
WHEN 'Tuesday' THEN 2
WHEN 'Wednesday' THEN 3
WHEN 'Thursday' THEN 4
WHEN 'Friday' THEN 5
WHEN 'Saturday' THEN 6
WHEN 'Sunday' THEN 7
END, av.start_time`,
}, },
{ {
ID: "peak_availability", ID: "volunteer_fulfillment",
Name: "Peak Availability Times", Name: "Volunteer Fulfillment (Available vs Actually Worked)",
Description: "Times when most volunteers are available", Description: "Reliability measure - comparing availability to actual appointments",
SQL: `SELECT
day_of_week,
start_time,
end_time,
COUNT(*) as volunteers_available
FROM volunteer_availability av
JOIN users u ON av.user_id = u.user_id
WHERE av.created_at BETWEEN ?1 AND ?2
GROUP BY day_of_week, start_time, end_time
ORDER BY volunteers_available DESC, day_of_week, start_time`,
},
{
ID: "coverage_gaps",
Name: "Coverage Gaps",
Description: "Time periods with limited volunteer availability",
SQL: `SELECT
day_of_week,
start_time,
end_time,
COUNT(*) as volunteers_available
FROM volunteer_availability av
WHERE av.created_at BETWEEN ?1 AND ?2
GROUP BY day_of_week, start_time, end_time
HAVING COUNT(*) <= 2
ORDER BY volunteers_available ASC, day_of_week, start_time`,
},
{
ID: "schedule_conflicts",
Name: "Schedule Conflicts",
Description: "Appointments scheduled outside volunteer availability",
SQL: `SELECT SQL: `SELECT
u.first_name || ' ' || u.last_name as volunteer_name, u.first_name || ' ' || u.last_name as volunteer_name,
a.appointment_date, COUNT(DISTINCT av.availability_id) as availability_slots,
a.appointment_time, SUM(EXTRACT(EPOCH FROM (av.end_time - av.start_time))/3600) as total_hours_available,
ad.address, COUNT(DISTINCT a.sched_id) as appointments_scheduled,
'No availability recorded' as conflict_reason COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) as past_appointments,
FROM appointment a COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
JOIN users u ON a.user_id = u.user_id SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
JOIN address_database ad ON a.address_id = ad.address_id AND DATE(p.created_at) = a.appointment_date
LEFT JOIN volunteer_availability av ON u.user_id = av.user_id ) THEN a.sched_id END) as completed_appointments,
AND EXTRACT(dow FROM a.appointment_date) = av.day_of_week CASE
AND a.appointment_time BETWEEN av.start_time AND av.end_time WHEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END) > 0
WHERE a.appointment_date BETWEEN ?1 AND ?2 THEN COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE AND EXISTS (
AND av.user_id IS NULL SELECT 1 FROM poll p WHERE p.user_id = a.user_id AND p.address_id = a.address_id
ORDER BY a.appointment_date, a.appointment_time`, AND DATE(p.created_at) = a.appointment_date
) THEN a.sched_id END) * 100.0 / COUNT(DISTINCT CASE WHEN a.appointment_date < CURRENT_DATE THEN a.sched_id END)
ELSE 0
END as fulfillment_rate_percent
FROM users u
LEFT JOIN availability av ON u.user_id = av.user_id AND av.created_at BETWEEN ?1 AND ?2
LEFT JOIN appointment a ON u.user_id = a.user_id AND a.created_at BETWEEN ?1 AND ?2
WHERE u.role_id IN (2, 3)
GROUP BY u.user_id, u.first_name, u.last_name
HAVING COUNT(DISTINCT av.availability_id) > 0 OR COUNT(DISTINCT a.sched_id) > 0
ORDER BY fulfillment_rate_percent DESC, total_hours_available DESC`,
}, },
}, },
} }

View File

@@ -2,10 +2,33 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "time"
"github.com/patel-mann/poll-system/app/internal/models"
) )
func VolunteerSchedualHandler(w *http.ResponseWriter, r http.Request) { func ValidatedFreeTime(parsedDate time.Time, assignTime time.Time, userID int) (bool) {
var startTime, endTime time.Time
fmt.Print("Not Implementated Yet!!!") dateOnly := parsedDate.Format("2006-01-02")
err := models.DB.QueryRow(
`SELECT start_time, end_time
FROM availability
WHERE user_id = $1 AND day = $2`,
userID, dateOnly,
).Scan(&startTime, &endTime)
if err != nil {
fmt.Printf("Database query failed: %v\n", err)
return false
}
if assignTime.After(startTime) && assignTime.Before(endTime) {
return true
}else{
return false
}
return false
} }

View File

@@ -21,7 +21,7 @@
<button <button
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors" class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors rounded-lg"
onclick="window.location.href='/addresses/upload-csv'" onclick="window.location.href='/addresses/upload-csv'"
> >
<i class="fas fa-file-import mr-2"></i>Import Data <i class="fas fa-file-import mr-2"></i>Import Data
@@ -141,8 +141,9 @@
<button <button
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed" class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
disabled disabled
hidden
> >
Assigned <i class="fa-solid fa-plus text-orange-400"></i>
</button> </button>
<form action="/remove_assigned_address" method="POST" class="inline-block"> <form action="/remove_assigned_address" method="POST" class="inline-block">
<input type="hidden" name="address_id" value="{{ .AddressID }}" /> <input type="hidden" name="address_id" value="{{ .AddressID }}" />
@@ -151,16 +152,17 @@
type="submit" type="submit"
class="text-red-400 hover:text-red-600 p-1" class="text-red-400 hover:text-red-600 p-1"
title="Remove assignment" title="Remove assignment"
> >
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</form> </form>
{{ else }} {{ else }}
<button <button
class="px-3 py-1 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors" class="px-3 py-1 bg-blue-100 text-white text-sm rounded-md hover:bg-blue-600 transition-colors"
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')" onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
> >
Assign <i class="fa-solid fa-plus text-blue-500"></i>
</button> </button>
{{ end }} {{ end }}
</div> </div>

View File

@@ -27,7 +27,6 @@
#single-map { #single-map {
height: 50vh; /* Smaller height on mobile */ height: 50vh; /* Smaller height on mobile */
} }
body.sidebar-open .map-controls { body.sidebar-open .map-controls {
display: none; display: none;
} }
@@ -98,7 +97,6 @@
.dashboard-container { .dashboard-container {
flex-direction: row; flex-direction: row;
} }
#single-map { #single-map {
height: 100%; height: 100%;
} }
@@ -134,12 +132,6 @@
<button class="control-button" onclick="refreshMap()" title="Refresh Map"> <button class="control-button" onclick="refreshMap()" title="Refresh Map">
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
</button> </button>
<button class="control-button" onclick="fitAllMarkers()" title="Fit All Markers">
<i class="fas fa-expand-arrows-alt"></i>
</button>
<button class="control-button" onclick="clearAllMarkers()" title="Clear All Markers">
<i class="fas fa-trash"></i>
</button>
</div> </div>
<div id="single-map"></div> <div id="single-map"></div>
@@ -202,26 +194,17 @@
</div> </div>
<script> <script>
// Global variables - only one set
let theMap = null; let theMap = null;
let markerLayer = null; let markerLayer = null;
let popup = null; let popup = null;
let initialized = false; let initialized = false;
// Clean initialization
function initializeMap() { function initializeMap() {
if (initialized || !window.ol) { if (initialized || !window.ol) return;
console.log("Map already initialized or OpenLayers not ready");
return;
}
console.log("Initializing single map...");
try { try {
// Calgary coordinates
const center = ol.proj.fromLonLat([-114.0719, 51.0447]); const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
// Create the ONE AND ONLY map
theMap = new ol.Map({ theMap = new ol.Map({
target: "single-map", target: "single-map",
layers: [ layers: [
@@ -235,7 +218,6 @@
}), }),
}); });
// Create popup
popup = new ol.Overlay({ popup = new ol.Overlay({
element: document.getElementById("popup"), element: document.getElementById("popup"),
positioning: "bottom-center", positioning: "bottom-center",
@@ -244,13 +226,11 @@
}); });
theMap.addOverlay(popup); theMap.addOverlay(popup);
// Close popup handler
document.getElementById("popup-closer").onclick = function () { document.getElementById("popup-closer").onclick = function () {
popup.setPosition(undefined); popup.setPosition(undefined);
return false; return false;
}; };
// Create marker layer
markerLayer = new ol.layer.Vector({ markerLayer = new ol.layer.Vector({
source: new ol.source.Vector(), source: new ol.source.Vector(),
style: new ol.style.Style({ style: new ol.style.Style({
@@ -264,15 +244,8 @@
}); });
theMap.addLayer(markerLayer); theMap.addLayer(markerLayer);
// Click handler
theMap.on("click", function (event) { theMap.on("click", function (event) {
const feature = theMap.forEachFeatureAtPixel( const feature = theMap.forEachFeatureAtPixel(event.pixel, f => f);
event.pixel,
function (feature) {
return feature;
}
);
if (feature && feature.get("address_data")) { if (feature && feature.get("address_data")) {
const data = feature.get("address_data"); const data = feature.get("address_data");
document.getElementById("popup-content").innerHTML = ` document.getElementById("popup-content").innerHTML = `
@@ -282,8 +255,7 @@
<p><strong>House #:</strong> ${data.house_number}</p> <p><strong>House #:</strong> ${data.house_number}</p>
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p> <p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
<p><strong>ID:</strong> ${data.address_id}</p> <p><strong>ID:</strong> ${data.address_id}</p>
</div> </div>`;
`;
popup.setPosition(event.coordinate); popup.setPosition(event.coordinate);
} else { } else {
popup.setPosition(undefined); popup.setPosition(undefined);
@@ -291,45 +263,32 @@
}); });
initialized = true; initialized = true;
console.log("Map initialized successfully");
// Load markers
setTimeout(loadMarkers, 500); setTimeout(loadMarkers, 500);
} catch (error) { } catch (error) {
console.error("Map initialization error:", error); console.error("Map initialization error:", error);
} }
} }
// Load validated addresses
async function loadMarkers() { async function loadMarkers() {
try { try {
const response = await fetch("/api/validated-addresses"); const response = await fetch("/api/validated-addresses");
const addresses = await response.json(); const addresses = await response.json();
console.log(`Loading ${addresses.length} addresses`);
document.getElementById("marker-count").textContent = `${addresses.length} on map`; document.getElementById("marker-count").textContent = `${addresses.length} on map`;
// Clear existing markers
markerLayer.getSource().clear(); markerLayer.getSource().clear();
const features = addresses
// Add new markers .filter(addr => addr.longitude && addr.latitude)
const features = []; .map(addr => new ol.Feature({
addresses.forEach((addr) => { geometry: new ol.geom.Point(ol.proj.fromLonLat([addr.longitude, addr.latitude])),
if (addr.longitude && addr.latitude) {
const coords = ol.proj.fromLonLat([addr.longitude, addr.latitude]);
const feature = new ol.Feature({
geometry: new ol.geom.Point(coords),
address_data: addr, address_data: addr,
}); }));
features.push(feature);
}
});
markerLayer.getSource().addFeatures(features); markerLayer.getSource().addFeatures(features);
if (features.length > 0) { if (features.length > 0) {
const extent = markerLayer.getSource().getExtent(); const extent = markerLayer.getSource().getExtent();
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] }); theMap.getView(extent, { padding: [20, 20, 20, 20] });
} }
} catch (error) { } catch (error) {
console.error("Error loading markers:", error); console.error("Error loading markers:", error);
@@ -337,45 +296,23 @@
} }
} }
// Control functions
function refreshMap() { function refreshMap() {
const view = theMap.getView();
const calgaryCenter = ol.proj.fromLonLat([-114.0719, 51.0447]); // Downtown Calgary
view.setCenter(calgaryCenter);
view.setZoom(11); // your default zoom leve
loadMarkers(); loadMarkers();
} }
function fitAllMarkers() { document.addEventListener("DOMContentLoaded", () => {
if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
const extent = markerLayer.getSource().getExtent();
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
}
function clearAllMarkers() {
if (markerLayer) {
markerLayer.getSource().clear();
}
if (popup) {
popup.setPosition(undefined);
}
}
// Initialize when ready
document.addEventListener("DOMContentLoaded", function () {
setTimeout(initializeMap, 1000); setTimeout(initializeMap, 1000);
}); });
// Listen for sidebar state changes to manage map controls visibility
function handleSidebarToggle() { function handleSidebarToggle() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const body = document.body; document.body.classList.toggle('sidebar-open', sidebar && sidebar.classList.contains('active'));
if (sidebar && sidebar.classList.contains('active')) {
body.classList.add('sidebar-open');
} else {
body.classList.remove('sidebar-open');
}
} }
// Override the original toggleSidebar function to handle map controls
if (typeof window.toggleSidebar === 'function') { if (typeof window.toggleSidebar === 'function') {
const originalToggleSidebar = window.toggleSidebar; const originalToggleSidebar = window.toggleSidebar;
window.toggleSidebar = function() { window.toggleSidebar = function() {

View File

@@ -167,11 +167,6 @@
</a> </a>
{{ end }} {{ end }}
<a href="/profile" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "profile"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
<i class="fas fa-user-circle w-5 {{if eq .ActiveSection "profile"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
<span {{if eq .ActiveSection "profile"}}class="font-medium"{{end}}>Profile</span>
</a>
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group"> <a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i> <i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
<span>Logout</span> <span>Logout</span>
@@ -183,7 +178,7 @@
<!-- Main Content Container --> <!-- Main Content Container -->
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray"> <div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
<!-- Top Header --> <!-- Top Header -->
<div class="bg-white border-b border-border-gray px-4 md:px-6 py-4"> <div class="fixed top-0 left-0 right-0 z-20 bg-white border-b border-border-gray px-4 md:px-6 py-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<!-- Hamburger (left aligned with consistent spacing) --> <!-- Hamburger (left aligned with consistent spacing) -->
<div class="flex items-center"> <div class="flex items-center">
@@ -195,9 +190,9 @@
<!-- Right side --> <!-- Right side -->
<div class="flex items-center space-x-2 md:space-x-4"> <div class="flex items-center space-x-2 md:space-x-4">
<!-- Dark mode --> <!-- Dark mode -->
<button class="text-text-secondary hover:text-text-primary p-2"> <a href="/logout" class="text-text-secondary hover:text-text-primary p-2">
<i class="fas fa-moon text-lg"></i> <i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
</button> </a>
<!-- Profile (hover dropdown on desktop, click on mobile) --> <!-- Profile (hover dropdown on desktop, click on mobile) -->
<div class="relative group cursor-pointer"> <div class="relative group cursor-pointer">
@@ -211,7 +206,7 @@
<!-- Dropdown --> <!-- Dropdown -->
<div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50"> <div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a> <a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a>
<a href="#" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a> <a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a>
<a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a> <a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a>
</div> </div>
</div> </div>
@@ -220,7 +215,7 @@
</div> </div>
<!-- Page Content --> <!-- Page Content -->
<div class="flex-1"> <div class="flex-1 mt-20">
{{ template "content" . }} {{ template "content" . }}
</div> </div>
</div> </div>
@@ -417,14 +412,35 @@
} }
} }
// Profile menu toggle
function toggleProfileMenu() { function toggleProfileMenu() {
const menu = document.getElementById('profile-menu'); const menu = document.getElementById('profile-menu');
if (menu) { if (menu) {
menu.classList.toggle('opacity-0'); const isVisible = !menu.classList.contains('invisible');
menu.classList.toggle('invisible'); if (isVisible) {
menu.classList.toggle('opacity-100'); // Hide it
menu.classList.toggle('visible'); menu.classList.add('opacity-0', 'invisible');
menu.classList.remove('opacity-100', 'visible');
document.removeEventListener('click', outsideClickListener);
} else {
// Show it
menu.classList.remove('opacity-0', 'invisible');
menu.classList.add('opacity-100', 'visible');
setTimeout(() => {
document.addEventListener('click', outsideClickListener);
}, 0);
}
}
}
function outsideClickListener(event) {
const menu = document.getElementById('profile-menu');
const trigger = event.target.closest('.group'); // The profile button wrapper
if (menu && !menu.contains(event.target) && !trigger) {
// Hide menu
menu.classList.add('opacity-0', 'invisible');
menu.classList.remove('opacity-100', 'visible');
document.removeEventListener('click', outsideClickListener);
} }
} }

View File

@@ -16,7 +16,7 @@
> >
<option value="">Select Category</option> <option value="">Select Category</option>
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option> <option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
<option value="address" {{if eq .Category "address"}}selected{{end}}>Addresses</option> <option value="addresses" {{if eq .Category "addresses"}}selected{{end}}>Addresses</option>
<option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option> <option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option> <option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option> <option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
@@ -60,12 +60,12 @@
<div class="text-gray-600"> <div class="text-gray-600">
<span>{{.Result.Count}} results</span> <span>{{.Result.Count}} results</span>
</div> </div>
<button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors"> <!-- <button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
<i class="fas fa-download mr-1"></i>Export CSV <i class="fas fa-download mr-1"></i>Export CSV
</button> </button>
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors"> <button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
<i class="fas fa-print mr-1"></i>Print <i class="fas fa-print mr-1"></i>Print
</button> </button> -->
</div> </div>
{{end}} {{end}}
</div> </div>
@@ -146,34 +146,32 @@
<script> <script>
const reportDefinitions = { const reportDefinitions = {
users: [ users: [
{ id: 'participation', name: 'Volunteer Participation Rate' }, { id: 'volunteer_participation_rate', name: 'Volunteer Participation Rate' },
{ id: 'top_performers', name: 'Top Performing Volunteers' }, { id: 'top_performing_volunteers', name: 'Top-Performing Volunteers & Team Leads' },
{ id: 'efficiency', name: 'Response-to-Donation Ratio' }, { id: 'response_donation_ratio', name: 'Response-to-Donation Ratio per Volunteer' },
{ id: 'coverage', name: 'User Address Coverage' } { id: 'user_address_coverage', name: 'User Address Coverage' }
], ],
address: [ addresses: [
{ id: 'responses_by_address', name: 'Total Responses by Address' }, { id: 'poll_responses_by_address', name: 'Total Poll Responses by Address' },
{ id: 'donations_by_address', name: 'Total Donations by Address' }, { id: 'donations_by_address', name: 'Total Donations by Address' },
{ id: 'street_breakdown', name: 'Street-Level Breakdown' }, { id: 'street_level_breakdown', name: 'Street-Level Breakdown (Responses & Donations)' },
{ id: 'quadrant_summary', name: 'Quadrant Summary' } { id: 'quadrant_summary', name: 'Quadrant-Level Summary (NE, NW, SE, SW)' }
], ],
appointments: [ appointments: [
{ id: 'upcoming', name: 'Upcoming Appointments' }, { id: 'upcoming_appointments', name: 'Upcoming Appointments per Volunteer/Team Lead' },
{ id: 'completion', name: 'Appointments Completion Rate' }, { id: 'missed_vs_completed', name: 'Missed vs Completed Appointments' },
{ id: 'geo_distribution', name: 'Appointments by Quadrant' }, { id: 'appointments_by_quadrant', name: 'Appointments by Quadrant/Region' },
{ id: 'lead_time', name: 'Average Lead Time' } { id: 'scheduling_lead_time', name: 'Average Lead Time (Scheduled vs Actual Date)' }
], ],
polls: [ polls: [
{ id: 'distribution', name: 'Response Distribution' }, { id: 'response_distribution', name: 'Response Distribution (Yes/No/Neutral)' },
{ id: 'average', name: 'Average Poll Response' }, { id: 'average_poll_response', name: 'Average Poll Response (Yes/No %)' },
{ id: 'donations_by_poll', name: 'Donations by Poll' }, { id: 'donations_by_poll', name: 'Donations by Poll' },
{ id: 'correlation', name: 'Response-to-Donation Correlation' } { id: 'response_donation_correlation', name: 'Response-to-Donation Correlation' }
], ],
availability: [ availability: [
{ id: 'by_date', name: 'Volunteer Availability by Date' }, { id: 'volunteer_availability_schedule', name: 'Volunteer Availability by Date Range' },
{ id: 'gaps', name: 'Coverage Gaps' }, { id: 'volunteer_fulfillment', name: 'Volunteer Fulfillment (Available vs Actually Worked)' }
{ id: 'overlaps', name: 'Volunteer Overlaps' },
{ id: 'fulfillment', name: 'Volunteer Fulfillment' }
] ]
}; };

View File

@@ -153,6 +153,7 @@ func main() {
http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler)) http.HandleFunc("/addresses/upload-csv", adminMiddleware(handlers.CSVUploadHandler))
http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler)) http.HandleFunc("/reports", adminMiddleware(handlers.ReportsHandler))
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler)) http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler) http.HandleFunc("/api/validated-addresses", handlers.GetValidatedAddressesHandler)

View File

@@ -1 +0,0 @@
exit status 1