diff --git a/actix_mvc_app/src/controllers/governance.rs b/actix_mvc_app/src/controllers/governance.rs index 0a4d032..4762297 100644 --- a/actix_mvc_app/src/controllers/governance.rs +++ b/actix_mvc_app/src/controllers/governance.rs @@ -135,8 +135,8 @@ impl GovernanceController { /// Handles the proposal list page route pub async fn proposals( query: web::Query, - tmpl: web::Data, - session: Session + tmpl: web::Data, + session: Session, ) -> Result { let mut ctx = tera::Context::new(); ctx.insert("active_page", "governance"); @@ -176,8 +176,8 @@ impl GovernanceController { proposals = proposals .into_iter() .filter(|p| { - p.title.to_lowercase().contains(&search_term) || - p.description.to_lowercase().contains(&search_term) + p.title.to_lowercase().contains(&search_term) + || p.description.to_lowercase().contains(&search_term) }) .collect(); } @@ -185,7 +185,7 @@ impl GovernanceController { // Add the filtered proposals to the context ctx.insert("proposals", &proposals); - + // Add the filter values back to the context for form persistence ctx.insert("status_filter", &query.status); ctx.insert("search_filter", &query.search); @@ -224,7 +224,7 @@ impl GovernanceController { // Calculate voting results directly from the proposal let results = Self::calculate_voting_results_from_proposal(&proposal); ctx.insert("results", &results); - + // Check if vote_success parameter is present and add success message if vote_success { ctx.insert("success", "Your vote has been successfully recorded!"); @@ -349,7 +349,7 @@ impl GovernanceController { } }; ctx.insert("proposals", &proposals); - + // Add the required context variables for the proposals template ctx.insert("active_tab", "proposals"); ctx.insert("status_filter", &None::); @@ -400,10 +400,13 @@ impl GovernanceController { 1, // Default to 1 share form.comment.as_ref().map(|s| s.to_string()), // Pass the comment from the form ) { - Ok(updated_proposal) => { + Ok(_) => { // Redirect to the proposal detail page with a success message return Ok(HttpResponse::Found() - .append_header(("Location", format!("/governance/proposals/{}?vote_success=true", proposal_id))) + .append_header(( + "Location", + format!("/governance/proposals/{}?vote_success=true", proposal_id), + )) .finish()); } Err(e) => { @@ -454,7 +457,7 @@ impl GovernanceController { ctx.insert("total_yes_votes", &total_vote_counts.0); ctx.insert("total_no_votes", &total_vote_counts.1); ctx.insert("total_abstain_votes", &total_vote_counts.2); - + ctx.insert("votes", &user_votes); render_template(&tmpl, "governance/my_votes.html", &ctx) @@ -602,19 +605,31 @@ impl GovernanceController { } }; - // Create a Vote from the ballot - let vote = Vote::new( - proposal.base_data.id.to_string(), + let ballot_timestamp = + match chrono::DateTime::from_timestamp(ballot.base_data.created_at, 0) { + Some(dt) => dt, + None => { + println!( + "Warning: Invalid timestamp {} for ballot, using current time", + ballot.base_data.created_at + ); + Utc::now() + } + }; + + let vote = Vote { + id: uuid::Uuid::new_v4().to_string(), + proposal_id: proposal.base_data.id.to_string(), voter_id, - format!("User {}", voter_id), + voter_name: format!("User {}", voter_id), vote_type, - ballot.comment.clone(), // Use the comment from the ballot - ); + comment: ballot.comment.clone(), + created_at: ballot_timestamp, // This is already local time + updated_at: ballot_timestamp, // Same as created_at for votes + }; votes.push(vote); } - - println!("Extracted {} votes from proposal", votes.len()); votes } diff --git a/actix_mvc_app/src/db/proposals.rs b/actix_mvc_app/src/db/proposals.rs index 042a358..c12faa9 100644 --- a/actix_mvc_app/src/db/proposals.rs +++ b/actix_mvc_app/src/db/proposals.rs @@ -8,7 +8,7 @@ use heromodels::{ }; /// The path to the database file. Change this as needed for your environment. -pub const DB_PATH: &str = "/tmp/ourdb_governance6"; +pub const DB_PATH: &str = "/tmp/ourdb_governance"; /// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed. pub fn get_db(db_path: &str) -> Result { @@ -188,6 +188,15 @@ pub fn submit_vote_on_proposal( // Set the comment if provided ballot.comment = comment; + // Store the local time (EEST = UTC+3) as the vote timestamp + // This ensures the displayed time matches the user's local time + let utc_now = Utc::now(); + let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST + let local_time = utc_now.with_timezone(&local_offset); + + // Store the local time as a timestamp (this is what will be displayed) + ballot.base_data.created_at = local_time.timestamp(); + // Add the ballot to the proposal's ballots proposal.ballots.push(ballot); diff --git a/actix_mvc_app/src/utils/mod.rs b/actix_mvc_app/src/utils/mod.rs index 6bbaaa0..55961fe 100644 --- a/actix_mvc_app/src/utils/mod.rs +++ b/actix_mvc_app/src/utils/mod.rs @@ -30,6 +30,7 @@ impl std::error::Error for TemplateError {} pub fn register_tera_functions(tera: &mut tera::Tera) { tera.register_function("now", NowFunction); tera.register_function("format_date", FormatDateFunction); + tera.register_function("local_time", LocalTimeFunction); } /// Tera function to get the current date/time @@ -93,6 +94,52 @@ impl Function for FormatDateFunction { } } +/// Tera function to convert UTC datetime to local time +#[derive(Clone)] +pub struct LocalTimeFunction; + +impl Function for LocalTimeFunction { + fn call(&self, args: &std::collections::HashMap) -> tera::Result { + let datetime_value = match args.get("datetime") { + Some(val) => val, + None => return Err(tera::Error::msg("The 'datetime' argument is required")), + }; + + let format = match args.get("format") { + Some(val) => match val.as_str() { + Some(s) => s, + None => "%Y-%m-%d %H:%M", + }, + None => "%Y-%m-%d %H:%M", + }; + + // The datetime comes from Rust as a serialized DateTime + // We need to handle it properly + let utc_datetime = if let Some(dt_str) = datetime_value.as_str() { + // Try to parse as RFC3339 first + match DateTime::parse_from_rfc3339(dt_str) { + Ok(dt) => dt.with_timezone(&Utc), + Err(_) => { + // Try to parse as our standard format + match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") { + Ok(dt) => dt.with_timezone(&Utc), + Err(_) => return Err(tera::Error::msg("Invalid datetime string format")), + } + } + } + } else { + return Err(tera::Error::msg("Datetime must be a string")); + }; + + // Convert UTC to local time (EEST = UTC+3) + // In a real application, you'd want to get the user's timezone from their profile + let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST + let local_datetime = utc_datetime.with_timezone(&local_offset); + + Ok(Value::String(local_datetime.format(format).to_string())) + } +} + /// Formats a date for display #[allow(dead_code)] pub fn format_date(date: &DateTime, format: &str) -> String { diff --git a/actix_mvc_app/src/views/governance/proposal_detail.html b/actix_mvc_app/src/views/governance/proposal_detail.html index d899a1c..1ca2c23 100644 --- a/actix_mvc_app/src/views/governance/proposal_detail.html +++ b/actix_mvc_app/src/views/governance/proposal_detail.html @@ -329,7 +329,7 @@ - + {% if votes | length > 10 %}
@@ -362,7 +362,8 @@
- Showing 1-10 of {{ votes | length }} + Showing 1-10 of {{ votes | length }}
{% endif %} @@ -379,19 +380,19 @@ if (window.location.search.includes('vote_success=true')) { const newUrl = window.location.pathname; window.history.replaceState({}, document.title, newUrl); - + // Auto-hide the success alert after 5 seconds const successAlert = document.querySelector('.alert-success'); if (successAlert) { - setTimeout(function() { + setTimeout(function () { successAlert.classList.remove('show'); - setTimeout(function() { + setTimeout(function () { successAlert.remove(); }, 500); }, 5000); } } - + // Vote filtering using data-filter attributes const filterButtons = document.querySelectorAll('[data-filter]'); const voteRows = document.querySelectorAll('.vote-row'); @@ -399,11 +400,11 @@ // Filter votes by type filterButtons.forEach(button => { - button.addEventListener('click', function() { + button.addEventListener('click', function () { // Update active button filterButtons.forEach(btn => btn.classList.remove('active')); this.classList.add('active'); - + // Reset to first page and update pagination currentPage = 1; updatePagination(); @@ -412,26 +413,26 @@ // Search functionality if (searchInput) { - searchInput.addEventListener('input', function() { + searchInput.addEventListener('input', function () { const searchTerm = this.value.toLowerCase(); - + voteRows.forEach(row => { const voterName = row.querySelector('td:first-child').textContent.toLowerCase(); const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase(); - + if (voterName.includes(searchTerm) || comment.includes(searchTerm)) { row.style.display = ''; } else { row.style.display = 'none'; } }); - + // Reset pagination after search currentPage = 1; updatePagination(); }); } - + // Pagination functionality const rowsPerPageSelect = document.getElementById('rowsPerPage'); const paginationControls = document.getElementById('paginationControls'); @@ -441,24 +442,24 @@ const totalRowsElement = document.getElementById('totalRows'); const prevPageBtn = document.getElementById('prevPage'); const nextPageBtn = document.getElementById('nextPage'); - + let currentPage = 1; let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10; - + // Function to update pagination display function updatePagination() { if (!paginationControls) return; - + // Get all rows that match the current filter const currentFilter = document.querySelector('[data-filter].active'); const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; - + // Get rows that match the current filter and search term let filteredRows = Array.from(voteRows); if (filterType !== 'all') { filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); } - + // Apply search filter if there's a search term const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; if (searchTerm) { @@ -468,76 +469,76 @@ return voterName.includes(searchTerm) || comment.includes(searchTerm); }); } - + const totalRows = filteredRows.length; - + // Calculate total pages const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); - + // Ensure current page is valid if (currentPage > totalPages) { currentPage = totalPages; } - + // Update pagination controls if (paginationControls) { // Clear existing page links (except prev/next) const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)'); pageLinks.forEach(link => link.remove()); - + // Add new page links const maxVisiblePages = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); - + // Adjust if we're near the end if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } - + // Insert page links before the next button const nextPageElement = document.getElementById('nextPage'); for (let i = startPage; i <= endPage; i++) { const li = document.createElement('li'); li.className = `page-item ${i === currentPage ? 'active' : ''}`; - + const a = document.createElement('a'); a.className = 'page-link'; a.href = '#'; a.textContent = i; - a.addEventListener('click', function(e) { + a.addEventListener('click', function (e) { e.preventDefault(); currentPage = i; updatePagination(); }); - + li.appendChild(a); paginationControls.insertBefore(li, nextPageElement); } - + // Update prev/next buttons prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`; nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`; } - + // Show current page showCurrentPage(); } - + // Function to show current page function showCurrentPage() { if (!votesTableBody) return; - + // Get all rows that match the current filter const currentFilter = document.querySelector('[data-filter].active'); const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; - + // Get rows that match the current filter and search term let filteredRows = Array.from(voteRows); if (filterType !== 'all') { filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); } - + // Apply search filter if there's a search term const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; if (searchTerm) { @@ -547,25 +548,25 @@ return voterName.includes(searchTerm) || comment.includes(searchTerm); }); } - + // Hide all rows first voteRows.forEach(row => row.style.display = 'none'); - + // Calculate pagination const totalRows = filteredRows.length; const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); - + // Ensure current page is valid if (currentPage > totalPages) { currentPage = totalPages; } - + // Show only rows for current page const start = (currentPage - 1) * rowsPerPage; const end = start + rowsPerPage; - + filteredRows.slice(start, end).forEach(row => row.style.display = ''); - + // Update pagination info if (startRowElement && endRowElement && totalRowsElement) { startRowElement.textContent = totalRows > 0 ? start + 1 : 0; @@ -573,10 +574,10 @@ totalRowsElement.textContent = totalRows; } } - + // Event listeners for pagination if (prevPageBtn) { - prevPageBtn.addEventListener('click', function(e) { + prevPageBtn.addEventListener('click', function (e) { e.preventDefault(); if (currentPage > 1) { currentPage--; @@ -584,20 +585,20 @@ } }); } - + if (nextPageBtn) { - nextPageBtn.addEventListener('click', function(e) { + nextPageBtn.addEventListener('click', function (e) { e.preventDefault(); // Get all rows that match the current filter const currentFilter = document.querySelector('[data-filter].active'); const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all'; - + // Get rows that match the current filter and search term let filteredRows = Array.from(voteRows); if (filterType !== 'all') { filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType); } - + // Apply search filter if there's a search term const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; if (searchTerm) { @@ -607,25 +608,25 @@ return voterName.includes(searchTerm) || comment.includes(searchTerm); }); } - + const totalRows = filteredRows.length; const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); - + if (currentPage < totalPages) { currentPage++; updatePagination(); } }); } - + if (rowsPerPageSelect) { - rowsPerPageSelect.addEventListener('change', function() { + rowsPerPageSelect.addEventListener('change', function () { rowsPerPage = parseInt(this.value); currentPage = 1; // Reset to first page updatePagination(); }); } - + // Initialize pagination if (paginationControls) { updatePagination();