From 1f48922db696ac5a3f90debbbac765d3e16a161e Mon Sep 17 00:00:00 2001
From: Hussain Nagaria
Date: Thu, 11 Dec 2025 12:38:03 +0530
Subject: [PATCH 1/7] feat: enhanced event categories
---
.../event_category/event_category.json | 21 ++++++++++++++++++-
.../doctype/event_category/event_category.py | 11 ++++++++--
buzz/patches.txt | 1 +
.../populate_slug_in_event_category.py | 9 ++++++++
4 files changed, 39 insertions(+), 3 deletions(-)
create mode 100644 buzz/patches/populate_slug_in_event_category.py
diff --git a/buzz/events/doctype/event_category/event_category.json b/buzz/events/doctype/event_category/event_category.json
index 197633b7..12a3a4ea 100644
--- a/buzz/events/doctype/event_category/event_category.json
+++ b/buzz/events/doctype/event_category/event_category.json
@@ -7,6 +7,9 @@
"engine": "InnoDB",
"field_order": [
"enabled",
+ "slug",
+ "column_break_mrmh",
+ "banner_image",
"icon_svg"
],
"fields": [
@@ -21,12 +24,28 @@
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
+ },
+ {
+ "fieldname": "banner_image",
+ "fieldtype": "Attach Image",
+ "label": "Banner Image"
+ },
+ {
+ "fieldname": "slug",
+ "fieldtype": "Data",
+ "label": "Slug",
+ "unique": 1
+ },
+ {
+ "fieldname": "column_break_mrmh",
+ "fieldtype": "Column Break"
}
],
"grid_page_length": 50,
+ "image_field": "banner_image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2025-08-04 12:53:09.908371",
+ "modified": "2025-12-11 12:34:55.467301",
"modified_by": "Administrator",
"module": "Events",
"name": "Event Category",
diff --git a/buzz/events/doctype/event_category/event_category.py b/buzz/events/doctype/event_category/event_category.py
index 95894508..c740fb52 100644
--- a/buzz/events/doctype/event_category/event_category.py
+++ b/buzz/events/doctype/event_category/event_category.py
@@ -1,7 +1,7 @@
# Copyright (c) 2025, BWH Studios and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
from frappe.model.document import Document
@@ -14,8 +14,15 @@ class EventCategory(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ banner_image: DF.AttachImage | None
enabled: DF.Check
icon_svg: DF.Code | None
+ slug: DF.Data | None
# end: auto-generated types
- pass
+ def validate(self):
+ if not self.slug:
+ self.set_slug()
+
+ def set_slug(self):
+ self.slug = frappe.website.utils.cleanup_page_name(self.name).replace("_", "-")
diff --git a/buzz/patches.txt b/buzz/patches.txt
index 63b8b46b..dc92cb44 100644
--- a/buzz/patches.txt
+++ b/buzz/patches.txt
@@ -5,3 +5,4 @@ buzz.patches.rename_doctypes_for_buzz
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
+buzz.patches.populate_slug_in_event_category
\ No newline at end of file
diff --git a/buzz/patches/populate_slug_in_event_category.py b/buzz/patches/populate_slug_in_event_category.py
new file mode 100644
index 00000000..a2ce42ba
--- /dev/null
+++ b/buzz/patches/populate_slug_in_event_category.py
@@ -0,0 +1,9 @@
+import frappe
+
+
+def execute():
+ categories = frappe.db.get_all("Event Category", pluck="name")
+ for category in categories:
+ doc = frappe.get_cached_doc("Event Category", category)
+ doc.set_slug()
+ doc.save()
From 22a14ed5e60a4c7957290499361dfdf2a11ee318 Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Wed, 4 Mar 2026 13:30:24 +0530
Subject: [PATCH 2/7] feat: implement clone and recurrence functionality for
Buzz Events
---
buzz/events/doctype/buzz_event/buzz_event.js | 632 ++++++++++++++++++-
buzz/events/doctype/buzz_event/buzz_event.py | 82 +++
buzz/templates/clone_event_dialog.html | 47 ++
buzz/templates/recurrence_dialog.html | 47 ++
4 files changed, 794 insertions(+), 14 deletions(-)
create mode 100644 buzz/templates/clone_event_dialog.html
create mode 100644 buzz/templates/recurrence_dialog.html
diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js
index 1ef8db4a..ea5d1b77 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.js
+++ b/buzz/events/doctype/buzz_event/buzz_event.js
@@ -1,5 +1,8 @@
-// Copyright (c) 2025, BWH Studios and contributors
-// For license information, please see license.txt
+// Copyright (c) 2025, BWH Studios
+
+/* ─────────────────────────────────────────────────────────────
+ FORM CONTROLLER
+ ───────────────────────────────────────────────────────────── */
frappe.ui.form.on("Buzz Event", {
refresh(frm) {
@@ -21,21 +24,19 @@ frappe.ui.form.on("Buzz Event", {
frm.save();
});
+ // Clone Event button – only shown on saved documents
+ if (!frm.is_new()) {
+ frm.add_custom_button(__("Clone Event"), () => {
+ show_clone_event_dialog(frm);
+ });
+ }
+
frm.set_query("track", "schedule", (doc, cdt, cdn) => {
- return {
- filters: {
- event: doc.name,
- },
- };
+ return { filters: { event: doc.name } };
});
frm.set_query("default_ticket_type", (doc) => {
- return {
- filters: {
- event: doc.name,
- is_published: 1,
- },
- };
+ return { filters: { event: doc.name, is_published: 1 } };
});
frm.trigger("add_zoom_custom_actions");
@@ -60,9 +61,612 @@ frappe.ui.form.on("Buzz Event", {
method: "create_webinar_on_zoom",
btn,
freeze: true,
- }).then(({ message }) => {
+ }).then(() => {
frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active();
});
});
},
});
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ SHARED HELPERS
+ ───────────────────────────────────────────────────────────────────────────── */
+
+/** Format "HH:MM:SS" → "HH : MM AM/PM" */
+function buzz_fmt_time(t) {
+ if (!t) return "—";
+ const [hh, mm] = t.split(":");
+ let h = parseInt(hh);
+ const ampm = h >= 12 ? "PM" : "AM";
+ h = h % 12 || 12;
+ return `${String(h).padStart(2, "0")} : ${mm || "00"} ${ampm}`;
+}
+
+/** Format "YYYY-MM-DD" → "Mon, Mar 3" */
+function buzz_fmt_date(d) {
+ if (!d) return "—";
+ return frappe.datetime
+ .str_to_obj(d)
+ .toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ COMPUTE OCCURRENCES (pure JS, no library)
+ ───────────────────────────────────────────────────────────────────────────── */
+
+/**
+ * @param {string} start_date "YYYY-MM-DD"
+ * @param {string} start_time "HH:MM:SS"
+ * @param {string} repeat_type "Daily" | "Weekly" | "Monthly"
+ * @param {number[]} weekdays ISO indices 0=Mon … 6=Sun (only used for Weekly)
+ * @param {string} end_type "Until" | "For"
+ * @param {string} until_date "YYYY-MM-DD" (used when end_type === "Until")
+ * @param {number} for_count integer (used when end_type === "For")
+ */
+function buzz_compute_occurrences(
+ start_date,
+ start_time,
+ repeat_type,
+ weekdays,
+ end_type,
+ until_date,
+ for_count
+) {
+ const SAFETY = 500;
+ const results = [];
+ const cur = frappe.datetime.str_to_obj(start_date);
+
+ const limit_date =
+ end_type === "Until" && until_date ? frappe.datetime.str_to_obj(until_date) : null;
+ const count_limit = end_type === "For" ? Math.max(1, parseInt(for_count) || 1) : SAFETY;
+
+ // Hard fence: two years out
+ const fence = new Date(cur);
+ fence.setFullYear(fence.getFullYear() + 2);
+
+ let iters = 0;
+
+ while (iters < SAFETY) {
+ iters++;
+ if (cur > fence) break;
+ if (limit_date && cur > limit_date) break;
+ if (results.length >= count_limit) break;
+
+ const date_str = frappe.datetime.obj_to_str(cur);
+
+ if (repeat_type === "Daily") {
+ results.push({ start_date: date_str, start_time });
+ cur.setDate(cur.getDate() + 1);
+ } else if (repeat_type === "Weekly") {
+ // JS getDay(): 0=Sun … 6=Sat → convert to ISO 0=Mon … 6=Sun
+ const js_day = cur.getDay();
+ const iso_day = js_day === 0 ? 6 : js_day - 1;
+ if (weekdays.includes(iso_day)) {
+ results.push({ start_date: date_str, start_time });
+ }
+ cur.setDate(cur.getDate() + 1);
+ } else if (repeat_type === "Monthly") {
+ results.push({ start_date: date_str, start_time });
+ cur.setMonth(cur.getMonth() + 1);
+ }
+ }
+
+ return results;
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ CLONE EVENT DIALOG
+ ───────────────────────────────────────────────────────────────────────────── */
+
+function show_clone_event_dialog(frm) {
+ frappe.call({
+ method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_dialog_html",
+ args: {
+ template_name: "clone_event_dialog",
+ context: JSON.stringify({ title: frm.doc.title }),
+ },
+ callback(r) {
+ _build_clone_dialog(frm, r.message);
+ },
+ });
+}
+
+function _build_clone_dialog(frm, body_html) {
+ let selected_dates = [];
+
+ // Render the date-rows list inside #clone-dates-list
+ function render_dates() {
+ const $list = clone_dialog.$body.find("#clone-dates-list");
+ $list.empty();
+
+ if (!selected_dates.length) {
+ $list.html(
+ `
+ ${__("No times added yet.")}
+
`
+ );
+ } else {
+ selected_dates.forEach((entry, idx) => {
+ $list.append(`
+
+
+ ${frappe.utils.escape_html(buzz_fmt_date(entry.start_date))}
+
+
+
+ ${frappe.utils.escape_html(buzz_fmt_time(entry.start_time))}
+
+
+
+
+ `);
+ });
+
+ $list.find(".edit-clone-date").on("click", function () {
+ const idx = parseInt($(this).data("idx"));
+ const entry = selected_dates[idx];
+ show_add_time_dialog(entry.start_date, entry.start_time, (updated) => {
+ selected_dates[idx] = updated;
+ render_dates();
+ });
+ });
+
+ $list.find(".remove-clone-date").on("click", function () {
+ selected_dates.splice(parseInt($(this).data("idx")), 1);
+ render_dates();
+ sync_primary_label();
+ });
+ }
+ }
+
+ function sync_primary_label() {
+ const n = selected_dates.length;
+ clone_dialog.set_primary_action(
+ n > 0 ? __("Clone {0} Event(s)", [n]) : __("Clone Event"),
+ null
+ );
+ }
+
+ const clone_dialog = new frappe.ui.Dialog({
+ title: __("Clone Event"),
+ fields: [
+ {
+ fieldname: "body_html",
+ fieldtype: "HTML",
+ options: body_html,
+ },
+ {
+ label: __("Calendar"),
+ fieldname: "host",
+ fieldtype: "Link",
+ options: "Event Host",
+ default: frm.doc.host,
+ reqd: 1,
+ },
+ ],
+ primary_action_label: __("Clone Event"),
+ primary_action(values) {
+ if (!selected_dates.length) {
+ frappe.msgprint({
+ message: __("Please add at least one date."),
+ indicator: "orange",
+ });
+ return;
+ }
+ clone_dialog.disable_primary_action();
+ frappe.call({
+ method: "buzz.events.doctype.buzz_event.buzz_event.clone_buzz_event",
+ args: { name: frm.doc.name, dates: selected_dates, host: values.host },
+ callback(r) {
+ clone_dialog.hide();
+ if (r.message && r.message.length) {
+ const links = r.message
+ .map((n) => `#${n}`)
+ .join(", ");
+ frappe.msgprint({
+ title: __("Events Created"),
+ message: __("{0} Buzz Event(s) created: {1}", [
+ r.message.length,
+ links,
+ ]),
+ indicator: "green",
+ });
+ }
+ },
+ error() {
+ clone_dialog.enable_primary_action();
+ },
+ });
+ },
+ });
+
+ clone_dialog.show();
+ render_dates();
+
+ // Move the Calendar field above the HTML body (aesthetic preference)
+ const $host_field = clone_dialog.fields_dict.host.$wrapper;
+ const $body_html = clone_dialog.$body.find('[data-fieldname="body_html"]');
+ $body_html.before(
+ $(`${__("Calendar")}
`)
+ );
+ $body_html.before($host_field.detach());
+
+ clone_dialog.$body.on("click", "#clone-add-time-btn", () => {
+ show_add_time_dialog(
+ frm.doc.start_date || frappe.datetime.get_today(),
+ frm.doc.start_time || "15:00:00",
+ (entry) => {
+ selected_dates.push(entry);
+ render_dates();
+ sync_primary_label();
+ }
+ );
+ });
+
+ clone_dialog.$body.on("click", "#clone-recurrence-btn", () => {
+ show_recurrence_dialog(
+ frm.doc.start_date || frappe.datetime.get_today(),
+ frm.doc.start_time || "15:00:00",
+ (entries) => {
+ selected_dates = selected_dates.concat(entries);
+ render_dates();
+ sync_primary_label();
+ }
+ );
+ });
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ ADD SINGLE TIME DIALOG
+ ───────────────────────────────────────────────────────────────────────────── */
+
+function show_add_time_dialog(default_date, default_time, on_add) {
+ const d = new frappe.ui.Dialog({
+ title: __("Add Time"),
+ fields: [
+ {
+ label: __("Date"),
+ fieldname: "start_date",
+ fieldtype: "Date",
+ reqd: 1,
+ default: default_date,
+ },
+ {
+ label: __("Time"),
+ fieldname: "start_time",
+ fieldtype: "Time",
+ default: default_time,
+ },
+ ],
+ primary_action_label: __("Add"),
+ primary_action(values) {
+ on_add({ start_date: values.start_date, start_time: values.start_time });
+ d.hide();
+ },
+ });
+ d.show();
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ CHOOSE TIMES (RECURRENCE DIALOG)
+ ───────────────────────────────────────────────────────────────────────────── */
+
+function show_recurrence_dialog(default_date, default_time, on_add) {
+ frappe.call({
+ method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_dialog_html",
+ args: { template_name: "recurrence_dialog" },
+ callback(r) {
+ _build_recurrence_dialog(default_date, default_time, on_add, r.message);
+ },
+ });
+}
+
+function _build_recurrence_dialog(default_date, default_time, on_add, body_html) {
+ // ISO weekday index: 0=Mon … 6=Sun
+ const DAY_META = [
+ { idx: 0, label: "M", full: "Monday" },
+ { idx: 1, label: "T", full: "Tuesday" },
+ { idx: 2, label: "W", full: "Wednesday" },
+ { idx: 3, label: "T", full: "Thursday" },
+ { idx: 4, label: "F", full: "Friday" },
+ { idx: 5, label: "S", full: "Saturday" },
+ { idx: 6, label: "S", full: "Sunday" },
+ ];
+
+ // Pre-select the ISO weekday of the starting date
+ const init_obj = frappe.datetime.str_to_obj(default_date);
+ const js_day = init_obj.getDay(); // 0=Sun
+ let selected_weekdays = [js_day === 0 ? 6 : js_day - 1];
+
+ // JS-owned state for the end conditions (not Frappe fields)
+ let current_end_type = "For"; // "Until" | "For"
+ let until_date_state = ""; // set by ControlDate
+ let for_count_state = 5; // set by number input
+ // Keep a reference to the live ControlDate so we can read it in refresh_preview
+ let until_date_ctrl = null;
+
+ const recurrence_dialog = new frappe.ui.Dialog({
+ title: __("Choose Times"),
+ fields: [
+ {
+ label: __("Starting on"),
+ fieldname: "start_date",
+ fieldtype: "Date",
+ reqd: 1,
+ default: default_date,
+ },
+ { fieldtype: "Column Break" },
+ {
+ label: __("Time"),
+ fieldname: "start_time",
+ fieldtype: "Time",
+ default: default_time,
+ },
+ { fieldtype: "Section Break" },
+ {
+ label: __("Repeats"),
+ fieldname: "repeat_type",
+ fieldtype: "Select",
+ options: ["Daily", "Weekly", "Monthly"].join("\n"),
+ default: "Weekly",
+ reqd: 1,
+ },
+ {
+ fieldname: "recurrence_body",
+ fieldtype: "HTML",
+ options: body_html,
+ },
+ ],
+ primary_action_label: __("Add Times"),
+ primary_action(values) {
+ // Read until_date from our ctrl if it exists
+ if (current_end_type === "Until" && until_date_ctrl) {
+ until_date_state = until_date_ctrl.get_value() || until_date_state;
+ }
+ const occurrences = buzz_compute_occurrences(
+ values.start_date,
+ values.start_time || default_time,
+ values.repeat_type,
+ selected_weekdays,
+ current_end_type,
+ until_date_state,
+ for_count_state
+ );
+ if (!occurrences.length) {
+ frappe.msgprint({
+ message: __("No dates generated. Check your recurrence settings."),
+ indicator: "orange",
+ });
+ return;
+ }
+ on_add(occurrences);
+ recurrence_dialog.hide();
+ },
+ });
+
+ recurrence_dialog.show();
+
+ /* ── helpers ── */
+
+ function get_repeat_type() {
+ return recurrence_dialog.get_field("repeat_type").get_value() || "Weekly";
+ }
+
+ function get_start_date() {
+ return recurrence_dialog.get_field("start_date").get_value() || default_date;
+ }
+
+ function get_start_time() {
+ return recurrence_dialog.get_field("start_time").get_value() || default_time;
+ }
+
+ /* ── Weekday button styles ── */
+ function render_weekday_buttons() {
+ recurrence_dialog.$body.find(".recurrence-day-btn").each(function () {
+ const day = parseInt($(this).data("day"));
+ const active = selected_weekdays.includes(day);
+ $(this)
+ .toggleClass("btn-primary", active)
+ .toggleClass("btn-default", !active)
+ .css("border", "");
+ });
+ }
+
+ /* ── Show/hide weekday section ── */
+ function toggle_weekday_section() {
+ const $s = recurrence_dialog.$body.find("#recurrence-weekday-section");
+ get_repeat_type() === "Weekly" ? $s.show() : $s.hide();
+ }
+
+ /* ── End condition: "Until" date or "For N units" ── */
+ function render_end_condition() {
+ const $wrapper = recurrence_dialog.$body.find("#end-condition-wrapper");
+ $wrapper.empty();
+ until_date_ctrl = null;
+
+ // Update toggle button active state using Frappe classes
+ recurrence_dialog.$body.find(".end-type-btn").each(function () {
+ const active = $(this).data("type") === current_end_type;
+ $(this).toggleClass("btn-primary", active).toggleClass("btn-default", !active);
+ });
+
+ if (current_end_type === "Until") {
+ // Use Frappe's ControlDate rendered directly into the wrapper
+ until_date_ctrl = frappe.ui.form.make_control({
+ df: {
+ fieldtype: "Date",
+ fieldname: "recurrence_until_date",
+ label: "",
+ placeholder: __("End date"),
+ },
+ parent: $wrapper[0],
+ render_input: true,
+ });
+ until_date_ctrl.refresh();
+ // Strip extra spacing that make_control adds so it sits flush inline
+ $wrapper.find(".frappe-control").css({ margin: "0", padding: "0" });
+ $wrapper.find(".form-group").css({ margin: "0" });
+ $wrapper.find(".frappe-control label").hide();
+ // Restore previously chosen date
+ if (until_date_state) {
+ until_date_ctrl.set_value(until_date_state);
+ }
+ // Wire change
+ until_date_ctrl.$input.on("change blur", function () {
+ until_date_state = until_date_ctrl.get_value() || "";
+ refresh_preview();
+ });
+ } else {
+ // "For N weeks/days/months"
+ const unit_map = {
+ Daily: __("days"),
+ Weekly: __("weeks"),
+ Monthly: __("months"),
+ };
+ const unit = unit_map[get_repeat_type()] || __("occurrences");
+
+ $wrapper.html(`
+
+ ${unit}
+ `);
+
+ $wrapper.find("#end-for-count").on("input change", function () {
+ for_count_state = Math.max(1, parseInt($(this).val()) || 1);
+ // Keep the input aligned with state
+ $(this).val(for_count_state);
+ refresh_preview();
+ });
+ }
+ }
+
+ /* ── Occurrence preview chips ── */
+ function refresh_preview() {
+ const start_date = get_start_date();
+ const start_time = get_start_time();
+ const repeat_type = get_repeat_type();
+
+ if (!start_date) return;
+
+ // If Until mode, try to read the latest value from the live ctrl
+ let effective_until = until_date_state;
+ if (current_end_type === "Until" && until_date_ctrl) {
+ effective_until = until_date_ctrl.get_value() || until_date_state;
+ }
+
+ const occurrences = buzz_compute_occurrences(
+ start_date,
+ start_time,
+ repeat_type,
+ selected_weekdays,
+ current_end_type,
+ effective_until,
+ for_count_state
+ );
+
+ const $preview = recurrence_dialog.$body.find("#recurrence-preview");
+ $preview.empty();
+
+ if (!occurrences.length) {
+ $preview.html(
+ `
+ ${__("No dates match these settings.")}
+ `
+ );
+ recurrence_dialog.set_primary_action(__("Add Times"), null);
+ return;
+ }
+
+ const MAX_CHIPS = 4;
+ const visible = occurrences.slice(0, MAX_CHIPS);
+ const overflow = occurrences.length - MAX_CHIPS;
+
+ visible.forEach((entry) => {
+ const d_obj = frappe.datetime.str_to_obj(entry.start_date);
+ $preview.append(`
+
+
+ ${d_obj.toLocaleDateString("en-US", { month: "short" })}
+
+
+ ${d_obj.getDate()}
+
+
+ ${d_obj.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase()}
+
+
+ `);
+ });
+
+ if (overflow > 0) {
+ $preview.append(`
+
+ +${overflow}
+
+ `);
+ }
+
+ recurrence_dialog.set_primary_action(__("Add {0} Time(s)", [occurrences.length]), null);
+ }
+
+ /* ── Initial render ── */
+ render_weekday_buttons();
+ toggle_weekday_section();
+ render_end_condition();
+ refresh_preview();
+
+ /* ── Event bindings ── */
+
+ // Weekday toggle
+ recurrence_dialog.$body.on("click", ".recurrence-day-btn", function () {
+ const day = parseInt($(this).data("day"));
+ const idx = selected_weekdays.indexOf(day);
+ if (idx === -1) {
+ selected_weekdays.push(day);
+ } else if (selected_weekdays.length > 1) {
+ selected_weekdays.splice(idx, 1); // keep at least one day selected
+ }
+ render_weekday_buttons();
+ refresh_preview();
+ });
+
+ // Until / For toggle
+ recurrence_dialog.$body.on("click", ".end-type-btn", function () {
+ const type = $(this).data("type");
+ if (current_end_type === type) return;
+ current_end_type = type;
+ render_end_condition();
+ refresh_preview();
+ });
+
+ // Repeat type change
+ recurrence_dialog.get_field("repeat_type").$input.on("change", function () {
+ toggle_weekday_section();
+ render_end_condition(); // update "weeks/days/months" unit label in For mode
+ refresh_preview();
+ });
+
+ // Start date / time changes
+ recurrence_dialog.get_field("start_date").$input.on("change", () => refresh_preview());
+ recurrence_dialog.get_field("start_time").$input.on("change", () => refresh_preview());
+}
diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py
index d32586c5..e88c81ff 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.py
+++ b/buzz/events/doctype/buzz_event/buzz_event.py
@@ -117,3 +117,85 @@ def update_zoom_webinar(self):
}
)
webinar.save()
+
+
+@frappe.whitelist()
+def get_clone_dialog_html(template_name, context=None):
+ """Render a Jinja template from buzz/templates/ and return the HTML string."""
+ import json
+ import os
+
+ template_path = os.path.join(frappe.get_app_path("buzz"), "templates", f"{template_name}.html")
+ if not os.path.exists(template_path):
+ frappe.throw(frappe._("Template {0} not found").format(template_name))
+
+ with open(template_path) as f:
+ template_str = f.read()
+
+ ctx = {}
+ if context:
+ ctx = json.loads(context) if isinstance(context, str) else context
+
+ return frappe.render_template(template_str, ctx)
+
+
+@frappe.whitelist()
+def clone_buzz_event(name, dates, host=None):
+ """
+ Clone a Buzz Event for each entry in `dates`.
+
+ Args:
+ name : name of the source Buzz Event
+ dates : JSON list of {"start_date": "YYYY-MM-DD", "start_time": "HH:MM:SS"}
+ host : optional Event Host to override on clones
+
+ Returns:
+ list of newly created Buzz Event names
+ """
+ import json
+
+ from frappe.utils import add_days, date_diff
+
+ if isinstance(dates, str):
+ dates = json.loads(dates)
+
+ if not dates:
+ frappe.throw(frappe._("Please provide at least one date."))
+
+ source = frappe.get_doc("Buzz Event", name)
+
+ # Preserve the original duration (end_date - start_date offset in days)
+ duration_days = 0
+ if source.end_date and source.start_date:
+ duration_days = date_diff(source.end_date, source.start_date)
+
+ created = []
+
+ for entry in dates:
+ new_doc = frappe.copy_doc(source)
+ new_doc.start_date = entry.get("start_date")
+ new_doc.start_time = entry.get("start_time") or source.start_time
+
+ if duration_days:
+ new_doc.end_date = add_days(new_doc.start_date, duration_days)
+ else:
+ new_doc.end_date = new_doc.start_date
+
+ # New clone starts as a draft
+ new_doc.is_published = 0
+ new_doc.route = ""
+
+ if host:
+ new_doc.host = host
+
+ # Adjust schedule item dates by the same offset
+ if source.start_date and new_doc.schedule:
+ for row in new_doc.schedule:
+ if row.date:
+ row_offset = date_diff(row.date, source.start_date)
+ row.date = add_days(new_doc.start_date, row_offset)
+
+ new_doc.insert()
+ created.append(new_doc.name)
+
+ return created
diff --git a/buzz/templates/clone_event_dialog.html b/buzz/templates/clone_event_dialog.html
new file mode 100644
index 00000000..2b87d753
--- /dev/null
+++ b/buzz/templates/clone_event_dialog.html
@@ -0,0 +1,47 @@
+
+
+
+ {{ _("New Times") }}
+
+
+
+
+
+
+
+
diff --git a/buzz/templates/recurrence_dialog.html b/buzz/templates/recurrence_dialog.html
new file mode 100644
index 00000000..0d1a367d
--- /dev/null
+++ b/buzz/templates/recurrence_dialog.html
@@ -0,0 +1,47 @@
+
+
+
+ {{ _("Days of the week") }}
+
+
+ {% set day_labels = [("M","Monday"),("T","Tuesday"),("W","Wednesday"),("T","Thursday"),("F","Friday"),("S","Saturday"),("S","Sunday")] %}
+ {% for label, full in day_labels %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ _("Set options above to preview dates…") }}
+
+
+
From 8b4a16d625156a17a421e8f1e848ec464ad7126d Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Fri, 6 Mar 2026 02:53:50 +0530
Subject: [PATCH 3/7] feat: clone event and recurrence dialog
Closes #93
---
buzz/events/doctype/buzz_event/buzz_event.js | 107 +++++++++++-------
buzz/events/doctype/buzz_event/buzz_event.py | 4 +-
.../clone_event_dialog.html | 0
.../recurrence_dialog.html | 6 +-
4 files changed, 72 insertions(+), 45 deletions(-)
rename buzz/templates/{ => clone_event_dialog}/clone_event_dialog.html (100%)
rename buzz/templates/{ => clone_event_dialog}/recurrence_dialog.html (90%)
diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js
index ea5d1b77..d480d2d1 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.js
+++ b/buzz/events/doctype/buzz_event/buzz_event.js
@@ -116,9 +116,22 @@ function buzz_compute_occurrences(
const results = [];
const cur = frappe.datetime.str_to_obj(start_date);
- const limit_date =
- end_type === "Until" && until_date ? frappe.datetime.str_to_obj(until_date) : null;
- const count_limit = end_type === "For" ? Math.max(1, parseInt(for_count) || 1) : SAFETY;
+ // Compute the end fence date
+ let limit_date = null;
+ if (end_type === "Until" && until_date) {
+ limit_date = frappe.datetime.str_to_obj(until_date);
+ } else if (end_type === "For") {
+ const n = Math.max(1, parseInt(for_count) || 1);
+ limit_date = new Date(cur);
+ if (repeat_type === "Daily") {
+ limit_date.setDate(limit_date.getDate() + n - 1);
+ } else if (repeat_type === "Weekly") {
+ limit_date.setDate(limit_date.getDate() + n * 7 - 1);
+ } else if (repeat_type === "Monthly") {
+ limit_date.setMonth(limit_date.getMonth() + n);
+ limit_date.setDate(limit_date.getDate() - 1);
+ }
+ }
// Hard fence: two years out
const fence = new Date(cur);
@@ -130,7 +143,6 @@ function buzz_compute_occurrences(
iters++;
if (cur > fence) break;
if (limit_date && cur > limit_date) break;
- if (results.length >= count_limit) break;
const date_str = frappe.datetime.obj_to_str(cur);
@@ -185,50 +197,63 @@ function _build_clone_dialog(frm, body_html) {
${__("No times added yet.")}
`
);
- } else {
- selected_dates.forEach((entry, idx) => {
- $list.append(`
-
-
- ${frappe.utils.escape_html(buzz_fmt_date(entry.start_date))}
-
-
-
- ${frappe.utils.escape_html(buzz_fmt_time(entry.start_time))}
-
-
-
-
- `);
+ return;
+ }
+
+ selected_dates.forEach((entry, idx) => {
+ const $row = $(`
+
+ `);
+ $list.append($row);
+
+ // Date control
+ const date_ctrl = frappe.ui.form.make_control({
+ df: { fieldtype: "Date", fieldname: `row_date_${idx}`, label: "" },
+ parent: $row.find(".row-date-wrap")[0],
+ render_input: true,
+ });
+ date_ctrl.refresh();
+ $row.find(".row-date-wrap .frappe-control").css({ margin: 0, padding: 0 });
+ $row.find(".row-date-wrap .form-group").css({ margin: 0 });
+ $row.find(".row-date-wrap label").hide();
+ date_ctrl.set_value(entry.start_date || "");
+ date_ctrl.$input.on("change blur", () => {
+ const v = date_ctrl.get_value();
+ if (v) selected_dates[idx].start_date = v;
+ sync_primary_label();
});
- $list.find(".edit-clone-date").on("click", function () {
- const idx = parseInt($(this).data("idx"));
- const entry = selected_dates[idx];
- show_add_time_dialog(entry.start_date, entry.start_time, (updated) => {
- selected_dates[idx] = updated;
- render_dates();
- });
+ // Time control
+ const time_ctrl = frappe.ui.form.make_control({
+ df: { fieldtype: "Time", fieldname: `row_time_${idx}`, label: "" },
+ parent: $row.find(".row-time-wrap")[0],
+ render_input: true,
+ });
+ time_ctrl.refresh();
+ $row.find(".row-time-wrap .frappe-control").css({ margin: 0, padding: 0 });
+ $row.find(".row-time-wrap .form-group").css({ margin: 0 });
+ $row.find(".row-time-wrap label").hide();
+ time_ctrl.set_value(entry.start_time || "");
+ time_ctrl.$input.on("change blur", () => {
+ const v = time_ctrl.get_value();
+ if (v) selected_dates[idx].start_time = v;
});
- $list.find(".remove-clone-date").on("click", function () {
- selected_dates.splice(parseInt($(this).data("idx")), 1);
+ // Remove
+ $row.find(".row-remove-btn").on("click", () => {
+ selected_dates.splice(idx, 1);
render_dates();
sync_primary_label();
});
- }
+ });
}
function sync_primary_label() {
diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py
index e88c81ff..b76582f4 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.py
+++ b/buzz/events/doctype/buzz_event/buzz_event.py
@@ -125,7 +125,9 @@ def get_clone_dialog_html(template_name, context=None):
import json
import os
- template_path = os.path.join(frappe.get_app_path("buzz"), "templates", f"{template_name}.html")
+ template_path = os.path.join(
+ frappe.get_app_path("buzz"), "templates", "clone_event_dialog", f"{template_name}.html"
+ )
if not os.path.exists(template_path):
frappe.throw(frappe._("Template {0} not found").format(template_name))
diff --git a/buzz/templates/clone_event_dialog.html b/buzz/templates/clone_event_dialog/clone_event_dialog.html
similarity index 100%
rename from buzz/templates/clone_event_dialog.html
rename to buzz/templates/clone_event_dialog/clone_event_dialog.html
diff --git a/buzz/templates/recurrence_dialog.html b/buzz/templates/clone_event_dialog/recurrence_dialog.html
similarity index 90%
rename from buzz/templates/recurrence_dialog.html
rename to buzz/templates/clone_event_dialog/recurrence_dialog.html
index 0d1a367d..7e7f020c 100644
--- a/buzz/templates/recurrence_dialog.html
+++ b/buzz/templates/clone_event_dialog/recurrence_dialog.html
@@ -8,7 +8,7 @@
{% for label, full in day_labels %}
@@ -21,11 +21,11 @@
style="display:flex;align-items:center;gap:10px;margin-bottom:14px;width:100%;">
From 10961685232f7e6c83fdb6331cc986de00c3d703 Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Fri, 6 Mar 2026 14:48:23 +0530
Subject: [PATCH 4/7] feat: rename "Calendar" field to "Host" in clone event
dialog
---
buzz/events/doctype/buzz_event/buzz_event.js | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js
index d480d2d1..11172ca2 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.js
+++ b/buzz/events/doctype/buzz_event/buzz_event.js
@@ -273,7 +273,7 @@ function _build_clone_dialog(frm, body_html) {
options: body_html,
},
{
- label: __("Calendar"),
+ label: __("Host"),
fieldname: "host",
fieldtype: "Link",
options: "Event Host",
@@ -320,12 +320,8 @@ function _build_clone_dialog(frm, body_html) {
clone_dialog.show();
render_dates();
- // Move the Calendar field above the HTML body (aesthetic preference)
const $host_field = clone_dialog.fields_dict.host.$wrapper;
const $body_html = clone_dialog.$body.find('[data-fieldname="body_html"]');
- $body_html.before(
- $(`${__("Calendar")}
`)
- );
$body_html.before($host_field.detach());
clone_dialog.$body.on("click", "#clone-add-time-btn", () => {
From ad6af83b96fb85e755bde293e056e342a4a19066 Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Fri, 6 Mar 2026 16:05:08 +0530
Subject: [PATCH 5/7] feat: add type hints
---
buzz/events/doctype/buzz_event/buzz_event.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py
index 79d76579..599e0f45 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.py
+++ b/buzz/events/doctype/buzz_event/buzz_event.py
@@ -209,7 +209,7 @@ def update_zoom_webinar(self):
@frappe.whitelist()
-def get_clone_dialog_html(template_name, context=None):
+def get_clone_dialog_html(template_name: str, context: str | dict | None = None) -> str:
"""Render a Jinja template from buzz/templates/ and return the HTML string."""
import json
import os
@@ -231,7 +231,7 @@ def get_clone_dialog_html(template_name, context=None):
@frappe.whitelist()
-def clone_buzz_event(name, dates, host=None):
+def clone_buzz_event(name: str, dates: str | list, host: str | None = None) -> list[str]:
"""
Clone a Buzz Event for each entry in `dates`.
@@ -246,6 +246,7 @@ def clone_buzz_event(name, dates, host=None):
import json
from frappe.utils import add_days, date_diff
+ from frappe.utils.data import get_time_str
if isinstance(dates, str):
dates = json.loads(dates)
@@ -267,6 +268,9 @@ def clone_buzz_event(name, dates, host=None):
new_doc.start_date = entry.get("start_date")
new_doc.start_time = entry.get("start_time") or source.start_time
+ new_doc.start_time = get_time_str(new_doc.start_time)
+ new_doc.end_time = get_time_str(new_doc.end_time)
+
if duration_days:
new_doc.end_date = add_days(new_doc.start_date, duration_days)
else:
From 3fa1e939929e142900796484740c7d464833fd1e Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Fri, 6 Mar 2026 17:15:27 +0530
Subject: [PATCH 6/7] feat: update clone and recurrence dialog methods for Buzz
Events
---
buzz/events/doctype/buzz_event/buzz_event.js | 20 +++++++++---------
buzz/events/doctype/buzz_event/buzz_event.py | 22 ++++++++------------
2 files changed, 19 insertions(+), 23 deletions(-)
diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js
index 8f224f14..332bc2a7 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.js
+++ b/buzz/events/doctype/buzz_event/buzz_event.js
@@ -318,9 +318,13 @@ frappe.ui.form.on("Buzz Event", {
// Clone Event button – only shown on saved documents
if (!frm.is_new()) {
- frm.add_custom_button(__("Clone Event"), () => {
- show_clone_event_dialog(frm);
- });
+ frm.add_custom_button(
+ __("Clone Event"),
+ () => {
+ show_clone_event_dialog(frm);
+ },
+ __("Actions")
+ );
}
frm.set_query("track", "schedule", (doc, cdt, cdn) => {
@@ -484,11 +488,8 @@ function buzz_compute_occurrences(
function show_clone_event_dialog(frm) {
frappe.call({
- method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_dialog_html",
- args: {
- template_name: "clone_event_dialog",
- context: JSON.stringify({ title: frm.doc.title }),
- },
+ method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_event_dialog_html",
+ args: { context: JSON.stringify({ title: frm.doc.title }) },
callback(r) {
_build_clone_dialog(frm, r.message);
},
@@ -698,8 +699,7 @@ function show_add_time_dialog(default_date, default_time, on_add) {
function show_recurrence_dialog(default_date, default_time, on_add) {
frappe.call({
- method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_dialog_html",
- args: { template_name: "recurrence_dialog" },
+ method: "buzz.events.doctype.buzz_event.buzz_event.get_recurrence_dialog_html",
callback(r) {
_build_recurrence_dialog(default_date, default_time, on_add, r.message);
},
diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py
index 599e0f45..6c72c46d 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.py
+++ b/buzz/events/doctype/buzz_event/buzz_event.py
@@ -209,25 +209,21 @@ def update_zoom_webinar(self):
@frappe.whitelist()
-def get_clone_dialog_html(template_name: str, context: str | dict | None = None) -> str:
- """Render a Jinja template from buzz/templates/ and return the HTML string."""
+def get_clone_event_dialog_html(context: str | dict | None = None) -> str:
+ """Render the clone-event dialog template and return the HTML string."""
import json
- import os
-
- template_path = os.path.join(
- frappe.get_app_path("buzz"), "templates", "clone_event_dialog", f"{template_name}.html"
- )
- if not os.path.exists(template_path):
- frappe.throw(frappe._("Template {0} not found").format(template_name))
-
- with open(template_path) as f:
- template_str = f.read()
ctx = {}
if context:
ctx = json.loads(context) if isinstance(context, str) else context
- return frappe.render_template(template_str, ctx)
+ return frappe.render_template("buzz/templates/clone_event_dialog/clone_event_dialog.html", ctx)
+
+
+@frappe.whitelist()
+def get_recurrence_dialog_html() -> str:
+ """Render the recurrence dialog template and return the HTML string."""
+ return frappe.render_template("buzz/templates/clone_event_dialog/recurrence_dialog.html", {})
@frappe.whitelist()
From f6c36e7be24ba3aee40c8d33ee46ac31e12ebc9d Mon Sep 17 00:00:00 2001
From: Krishna Shirsath
Date: Mon, 9 Mar 2026 10:52:28 +0530
Subject: [PATCH 7/7] style: fixed date card styling issue
---
buzz/events/doctype/buzz_event/buzz_event.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js
index 332bc2a7..802c7991 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.js
+++ b/buzz/events/doctype/buzz_event/buzz_event.js
@@ -933,7 +933,7 @@ function _build_recurrence_dialog(default_date, default_time, on_add, body_html)
$preview.append(`
+ background:var(--card-bg);min-width:0;flex:0 0 calc(20% - 5px);text-align:center;">
${d_obj.toLocaleDateString("en-US", { month: "short" })}
@@ -952,7 +952,7 @@ function _build_recurrence_dialog(default_date, default_time, on_add, body_html)
$preview.append(`
+${overflow}