Skip to main content

max / goingson

GO-33-6: move event "template" rule to Rust (EventResponse.is_template) The events view derives which rows are recurring-series templates in JS. Add a pre-computed EventResponse.is_template (recurrence != None && !is_recurring_ instance) so JS just filters e.isTemplate instead of re-deriving the business rule. +3 unit tests over EventResponse::from. Along the way: the old JS guard `e.recurrence !== 'None'` was dead — Recurrence:: None serializes to "" (already falsy), so the truthiness check was the real gate. The Rust condition is the faithful, non-redundant form. 913 tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 15:30 UTC
Commit: 1237aebdadc4a00d0fbfb8d19b79c10ea3140699
Parent: edb5555
2 files changed, +66 insertions, -4 deletions
@@ -316,10 +316,10 @@
316 316
317 317 // Recurring section shows TEMPLATES only (the parent rule), not each expanded
318 318 // occurrence — so users can find the rule itself without scrolling past 50
319 - // weekly instances. Templates are identified by recurrence !== 'None' and
320 - // !isRecurringInstance (the parent row, not a generated copy).
321 - const recurring = events.filter(e => e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance);
322 - const nonTemplate = events.filter(e => !(e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance));
319 + // weekly instances. `isTemplate` is pre-computed in Rust (EventResponse):
320 + // recurrence !== 'None' && !isRecurringInstance.
321 + const recurring = events.filter(e => e.isTemplate);
322 + const nonTemplate = events.filter(e => !e.isTemplate);
323 323
324 324 GoingsOn.state.set('recurringEvents', recurring);
325 325 GoingsOn.state.set('upcomingEvents', nonTemplate.filter(e => !e.isPast));
@@ -83,6 +83,10 @@ pub struct EventResponse {
83 83 pub recurrence_rule: Option<RecurrenceRule>,
84 84 pub recurrence_display: String,
85 85 pub is_recurring_instance: bool,
86 + /// True if this is the parent rule of a recurring series (recurrence set and
87 + /// not itself a generated instance). The events view lists templates in their
88 + /// own section; pre-computed here so JS never re-derives the rule.
89 + pub is_template: bool,
86 90 pub contact_id: Option<ContactId>,
87 91 pub contact_name: Option<String>,
88 92 pub block_type: Option<String>,
@@ -184,6 +188,10 @@ impl From<Event> for EventResponse {
184 188 let is_snoozed = e.is_snoozed();
185 189 let snoozed_until = e.snoozed_until;
186 190 let reminder_offsets_seconds = e.reminder_offsets_seconds.clone();
191 + // Template = the parent rule row of a recurring series, not a generated
192 + // occurrence. Mirrors the old JS rule, which gated on `e.recurrence` being
193 + // truthy — i.e. a set recurrence (Recurrence::None serializes to "").
194 + let is_template = e.recurrence != Recurrence::None && !e.is_recurring_instance;
187 195
188 196 EventResponse {
189 197 id: e.id,
@@ -200,6 +208,7 @@ impl From<Event> for EventResponse {
200 208 recurrence_display,
201 209 recurrence_rule: e.recurrence_rule,
202 210 is_recurring_instance: e.is_recurring_instance,
211 + is_template,
203 212 contact_id: e.contact_id,
204 213 contact_name: e.contact_name,
205 214 block_type: e.block_type.as_ref().map(|b| b.db_value().to_string()),
@@ -670,3 +679,56 @@ mod sanitize_tests {
670 679 assert!(sanitize_reminder_offsets(&[]).is_empty());
671 680 }
672 681 }
682 +
683 + #[cfg(test)]
684 + mod is_template_tests {
685 + use super::EventResponse;
686 + use chrono::Utc;
687 + use goingson_core::{Event, EventId, Recurrence};
688 +
689 + fn event(recurrence: Recurrence, is_recurring_instance: bool) -> Event {
690 + Event {
691 + id: EventId::new(),
692 + user_id: None,
693 + project_id: None,
694 + project_name: None,
695 + contact_id: None,
696 + contact_name: None,
697 + title: "E".to_string(),
698 + description: String::new(),
699 + start_time: Utc::now(),
700 + end_time: None,
701 + location: None,
702 + linked_task_id: None,
703 + recurrence,
704 + recurrence_rule: None,
705 + recurrence_parent_id: None,
706 + is_recurring_instance,
707 + block_type: None,
708 + external_source: None,
709 + external_id: None,
710 + is_read_only: false,
711 + snoozed_until: None,
712 + reminder_offsets_seconds: Vec::new(),
713 + }
714 + }
715 +
716 + #[test]
717 + fn recurring_parent_rule_is_a_template() {
718 + let r = EventResponse::from(event(Recurrence::Weekly, false));
719 + assert!(r.is_template);
720 + }
721 +
722 + #[test]
723 + fn generated_instance_is_not_a_template() {
724 + // A recurring series expands into instances; those are not the rule row.
725 + let r = EventResponse::from(event(Recurrence::Weekly, true));
726 + assert!(!r.is_template);
727 + }
728 +
729 + #[test]
730 + fn non_recurring_event_is_not_a_template() {
731 + let r = EventResponse::from(event(Recurrence::None, false));
732 + assert!(!r.is_template);
733 + }
734 + }