diff --git a/projects/gnoland/gno.land/p/eve000/event/calendar.gno b/projects/gnoland/gno.land/p/eve000/event/calendar.gno
index c84055f3..cedd201e 100644
--- a/projects/gnoland/gno.land/p/eve000/event/calendar.gno
+++ b/projects/gnoland/gno.land/p/eve000/event/calendar.gno
@@ -489,6 +489,17 @@ func ParseTimeSafe(timeStr string) time.Time {
var _ eve.LogoGraph = (*Schedule)(nil)
var _ eve.Projectable = (*Schedule)(nil)
+func ScheduleFromEvent(evt *Event, cid string) Schedule {
+ return Schedule{
+ Status: string(evt.Status),
+ StartDate: evt.StartDate.Format(time.RFC3339),
+ EndDate: evt.EndDate.Format(time.RFC3339),
+ Title: evt.Name,
+ Description: evt.Description,
+ EventCalendarCid: cid,
+ }
+}
+
type Schedule struct {
Status string
StartDate string
@@ -503,7 +514,7 @@ func (o Schedule) Thumbnail() string { return " DataUrl() string {
return "data:image/svg+xml;charset=utf-8," + url.PathEscape(o.SVG())
}
-func (o Schedule) SVG() string { return scheduleSVG(o.Title) }
+func (o Schedule) SVG() string { return ScheduleSVG(o.Title) }
func (o Schedule) String() string {
return "Schedule{Status:" + o.Status + ", StartDate:" + o.StartDate + "}"
}
@@ -625,7 +636,7 @@ func parseOhrScheduleOpts(obj Schedule) (start, end time.Time, status, descr, ti
return
}
-func scheduleSVG(title string) string {
+func ScheduleSVG(title string) string {
if title == "" {
title = "Event EventCalendar"
}
@@ -675,5 +686,8 @@ func (c EventCalendar) JsonLD() eve.JsonLDMap { return c.Default.JsonLD() }
func (EventCalendar) Heading() string { return "| |\n| -------- |\n" }
func (EventCalendar) Row(obj EventCalendar) string {
- return "| [" + obj.Thumbnail() + obj.Title + "](./officehours?cal=" + obj.Cid() + ") | "
+ cidLink := "?cal=" + obj.Cid()
+ return "| [" + obj.Thumbnail() + "](" + cidLink + ") |\n" +
+ "| 📅 ["+ obj.Title + " Calendar](" + cidLink + ")" + " |\n" +
+ "| 📄 [View Event Flyer]("+ cidLink +"&render=flyer)" + " |\n"
}
diff --git a/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno b/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno
index 0a402cc8..e48359d9 100644
--- a/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno
+++ b/projects/gnoland/gno.land/p/eve000/event/component/flyer.gno
@@ -58,7 +58,7 @@ func (a *Flyer) ToMarkdown(body ...Content) string {
markdown += "[📥 Download Calendar File](" + calFile.(string) + "/" + fullURL + "?format=ics" + ")\n\n"
}
if _, ok := a.RenderOpts()["CalendarDataUrl"]; ok {
- markdown += "[📅 Add To Calendar](" + CalenderDataUrl("?format=ics", a) + ")\n\n"
+ markdown += "[📅 Add To Calendar](" + IcsCalenderDataUrl("?format=ics", a) + ")\n\n"
}
if len(body) > 0 {
diff --git a/projects/gnoland/gno.land/p/eve000/event/component/calendar.gno b/projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno
similarity index 97%
rename from projects/gnoland/gno.land/p/eve000/event/component/calendar.gno
rename to projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno
index df2a0a14..72c5aba7 100644
--- a/projects/gnoland/gno.land/p/eve000/event/component/calendar.gno
+++ b/projects/gnoland/gno.land/p/eve000/event/component/icsevent.gno
@@ -9,7 +9,7 @@ import (
"gno.land/p/demo/ufmt"
)
-func CalenderDataUrl(path string, a *Flyer) string {
+func IcsCalenderDataUrl(path string, a *Flyer) string {
if path == "" {
path = "?format=ics"
}
diff --git a/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno b/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno
new file mode 100644
index 00000000..2c8152d5
--- /dev/null
+++ b/projects/gnoland/gno.land/p/eve000/event/component/icsrecurring.gno
@@ -0,0 +1,110 @@
+package component
+
+import (
+ "net/url"
+ "std"
+ "strings"
+ "time"
+
+ "gno.land/p/demo/ufmt"
+)
+
+func IcsRecurringCalenderDataUrl(path string, opts map[string]string, a *Flyer) string {
+ if path == "" {
+ path = "?format=ics"
+ }
+ data := IcsRecurringCalendarFile(path, a, opts)
+ return "data:text/calendar;charset=utf-8," + url.QueryEscape(data)
+}
+
+func IcsRecurringCalendarFile(path string, a *Flyer, opts map[string]string) string {
+ var f = ufmt.Sprintf
+ var b strings.Builder
+ w := func(s string) { b.WriteString(s + "\n") }
+
+ q := ParseQuery(path)
+ sessionIDs := q["session"]
+ format := strings.ToLower(q.Get("format"))
+
+ useAll := len(sessionIDs) == 0
+ allowed := make(map[string]bool)
+ for _, id := range sessionIDs {
+ allowed[id] = true
+ }
+ include := func(id string) bool { return useAll || allowed[id] }
+
+ fullPath := std.CurrentRealm().PkgPath()
+ prodID := strings.ReplaceAll(fullPath, "/", "//") + "//EN"
+
+ switch format {
+ case "json":
+ b.WriteString(a.ToJson())
+ return b.String()
+ case "ics":
+ w("BEGIN:VCALENDAR")
+ w("VERSION:2.0")
+ w("CALSCALE:GREGORIAN")
+ w("PRODID:-" + prodID)
+ w("METHOD:PUBLISH\n")
+
+ w("BEGIN:VEVENT")
+ w(f("UID:event-%s@%s", slugify(a.Name), fullPath))
+ w("SEQUENCE:0")
+ w(f("DTSTAMP:%s", time.Now().UTC().Format("20060102T150405Z")))
+ w(f("DTSTART;VALUE=DATE:%s", a.StartDate.Format("20060102")))
+ w(f("DTEND;VALUE=DATE:%s", a.StartDate.AddDate(0, 0, 1).Format("20060102")))
+ w(f("SUMMARY:%s", a.Name))
+ w(f("DESCRIPTION:%s", a.Description))
+ if a.Location != nil && a.Location.Name != "" {
+ w(f("LOCATION:%s", a.Location.Name))
+ }
+ // Recurrence support
+ if rrule, ok := opts["RRULE"]; ok && rrule != "" {
+ w("RRULE:" + rrule)
+ }
+ if exdate, ok := opts["EXDATE"]; ok && exdate != "" {
+ w("EXDATE:" + exdate)
+ }
+ w("END:VEVENT\n")
+
+ for i, s := range a.Sessions {
+ id := Pad3(i)
+ if !include(id) {
+ continue
+ }
+ w("BEGIN:VEVENT")
+ w(f("UID:%s-%d@%s", slugify(s.Title)[:5], s.StartTime.Unix(), fullPath))
+ w(f("SEQUENCE:%d", s.Sequence))
+ w(f("DTSTAMP:%s", time.Now().UTC().Format("20060102T150405Z")))
+ w(f("DTSTART:%s", s.StartTime.UTC().Format("20060102T150000Z")))
+ w(f("DTEND:%s", s.EndTime.UTC().Format("20060102T150000Z")))
+ w(f("SUMMARY:%s", s.Title))
+ w(f("DESCRIPTION:%s", s.Description))
+ w(f("LOCATION:%s", s.Location.Name))
+ if s.Cancelled {
+ w("STATUS:CANCELLED")
+ }
+ // Recurrence for sessions
+ if rrule, ok := opts["RRULE"]; ok && rrule != "" {
+ w("RRULE:" + rrule)
+ }
+ if exdate, ok := opts["EXDATE"]; ok && exdate != "" {
+ w("EXDATE:" + exdate)
+ }
+ w("END:VEVENT\n")
+ }
+
+ w("END:VCALENDAR")
+ return b.String()
+ default:
+ w(f("# %s\n\n%s", a.Name, a.Description))
+ for i, s := range a.Sessions {
+ id := Pad3(i)
+ if !include(id) {
+ continue
+ }
+ w(s.ToMarkdown())
+ }
+ return b.String()
+ }
+}
\ No newline at end of file
diff --git a/projects/gnoland/gno.land/r/labs000/officehours/index.gno b/projects/gnoland/gno.land/r/labs000/officehours/index.gno
index 253bb764..c5284e98 100644
--- a/projects/gnoland/gno.land/r/labs000/officehours/index.gno
+++ b/projects/gnoland/gno.land/r/labs000/officehours/index.gno
@@ -9,83 +9,174 @@ import (
)
var (
+ // Configuration
eventTitle = "Office Hours"
eventDescription = "AibLabs Office Hours: Join us for open discussion."
- eventDayOfWeek = time.Wednesday // Configurable: time.Sunday, time.Monday, etc.
+ eventDayOfWeek = time.Wednesday // e.g. time.Sunday, time.Monday, ...
eventStartHour = 10
eventDuration = time.Hour
- cancelledDates = map[string]bool{} // Example: YYYY-MM-DD format
+ cancelledDates = map[string]bool{} // YYYY-MM-DD -> true
+
+ // Per-env render options
+ renderOpts = map[string]interface{}{
+ "dev": map[string]interface{}{
+ "CalendarFile": "http://127.0.0.1:8080",
+ "SessionsTitle": "Next Session",
+ },
+ "labsnet1": map[string]interface{}{
+ "SvgFooter": struct{}{},
+ "CalendarHost": "webcal://gnocal.aiblabs.net",
+ "SessionsTitle": "Next Session",
+ },
+ }
)
+// ---------- Time helpers ----------
+
func nextEventDay() time.Time {
now := time.Now().UTC()
- daysUntilEvent := (int(eventDayOfWeek) - int(now.Weekday()) + 7) % 7
- if daysUntilEvent == 0 {
- daysUntilEvent = 7
+ daysUntil := (int(eventDayOfWeek) - int(now.Weekday()) + 7) % 7
+ if daysUntil == 0 {
+ daysUntil = 7
}
- nextEvent := now.AddDate(0, 0, daysUntilEvent)
- return time.Date(nextEvent.Year(), nextEvent.Month(), nextEvent.Day(), eventStartHour, 0, 0, 0, time.UTC)
+ next := now.AddDate(0, 0, daysUntil)
+ return time.Date(next.Year(), next.Month(), next.Day(), eventStartHour, 0, 0, 0, time.UTC)
}
func isCancelled(date time.Time) bool {
- dateStr := date.Format("2006-01-02")
- if _, exists := cancelledDates[dateStr]; exists {
- return true
- }
- return false
+ return cancelledDates[date.Format("2006-01-02")]
}
-func proposalForm(cid string, evt event.Schedule) string {
- if time.Now().After(event.ParseTimeSafe(evt.EndDate).Add(48 * time.Hour)) {
+// ---------- UI helpers ----------
+
+func proposalForm(topicCID string, s event.Schedule) string {
+ // Hide after 48h past end
+ if time.Now().After(event.ParseTimeSafe(s.EndDate).Add(48 * time.Hour)) {
return ""
}
var sb strings.Builder
sb.WriteString(`### Propose Topic
-
-
-
-
-
+
+
+
+
|||
-
`)
return sb.String()
}
-func Render(path string) string {
+// ---------- Composition core ----------
+
+// EventFromPath composes the Event (model) from the route/query.
+func EventFromPath(path string) *event.Event {
q := eve.ParseQuery(path)
- var sb strings.Builder
- if topic, ok := q["topic"]; ok && len(topic) > 0 {
- sb.WriteString("## Commit \n Topic: " + topic[0] + "\n\n")
+ start := nextEventDay()
+ end := start.Add(eventDuration)
+ status := "EventPlanned"
+ if isCancelled(start) {
+ status = "EventCancelled"
+ }
- if date, ok := q["date"]; ok && len(date) > 0 {
- sb.WriteString("Date: " + date[0] + "\n\n")
+ // Optional overrides via query (non-breaking)
+ if ds := q.Get("start"); ds != "" {
+ if t := event.ParseTimeSafe(ds); !t.IsZero() {
+ start = t
+ end = start.Add(eventDuration)
}
-
- if description, ok := q["description"]; ok && len(description) > 0 {
- sb.WriteString("Description: " + description[0] + "\n\n")
- return sb.String()
+ }
+ if de := q.Get("end"); de != "" {
+ if t := event.ParseTimeSafe(de); !t.IsZero() {
+ end = t
}
}
+ if st := q.Get("status"); st != "" {
+ status = st
+ }
- startDate := nextEventDay()
- endDate := startDate.Add(eventDuration)
- status := "EventPlanned"
- if isCancelled(startDate) {
- status = "EventCancelled"
+ session := &eve.Session{
+ Title: eventTitle,
+ StartTime: start,
+ EndTime: end,
+ Description: eventDescription,
+ Sequence: 0,
}
- options := map[string]string{
- "Status": status,
- "StartDate": startDate.Format(time.RFC3339),
- "EndDate": endDate.Format(time.RFC3339),
- "Title": eventTitle,
- "Description": eventDescription,
+ evt := &event.Event{
+ Name: eventTitle,
+ Status: eve.Status(status),
+ StartDate: start,
+ EndDate: end,
+ Location: &eve.Location{Name: "Online"},
+ Sessions: []*eve.Session{session},
}
+ evt.SetRenderOpts(renderOpts)
+ return evt
+}
- return event.Calendar(options, event.WithCommentForm(proposalForm)).Render(path)
+// RenderFlyer composes the flyer page view.
+func RenderFlyer(path string) string {
+ q := eve.ParseQuery(path)
+ evt := EventFromPath(path)
+ var body strings.Builder
+
+ body.WriteString(event.Schedule{ Title: evt.Name }.Thumbnail())
+
+ // Optional topic proposal form: ?cal=
+ if topicCID := q.Get("cal"); topicCID != "" {
+ s := event.ScheduleFromEvent(evt, topicCID)
+ body.WriteString(proposalForm(topicCID, s))
+ }
+
+ return evt.RenderPage("", eve.Content{
+ Published: true,
+ Markdown: body.String(),
+ })
}
+
+// RenderCalendar composes an ICS calendar file (recurring) for the event.
+func RenderCalendar(path string) string {
+ evt := EventFromPath(path)
+ opts := map[string]string{
+ "ProdId": "-//aibLabs//Office Hours//EN",
+ "CalName": "aibLabs Office Hours",
+ "CalDesc": "Join us for open discussion.",
+ "TimeZone": "UTC",
+ "Method": "PUBLISH",
+ "XWR-CALNAME": "aibLabs Office Hours",
+ "XWR-CALDESC": "Join us for open discussion.",
+ "XWR-TIMEZONE": "UTC",
+ }
+ return eve.IcsRecurringCalendarFile(path, evt.Flyer(), opts)
+}
+
+// Render is the single entrypoint; it composes the correct view by route param.
+func Render(path string) string {
+ q := eve.ParseQuery(path)
+ var sb strings.Builder
+
+ if ics := q.Get("render"); ics == "calendar" {
+ return RenderCalendar(path)
+ }
+
+ if topic := q.Get("topic"); topic != "" {
+ sb.WriteString("## Commit\n")
+ sb.WriteString("Topic: " + topic + "\n\n")
+
+ if date := q.Get("date"); date != "" {
+ sb.WriteString("Date: " + date + "\n\n")
+ }
+ if desc := q.Get("description"); desc != "" {
+ sb.WriteString("Description: " + desc + "\n\n")
+ }
+ return sb.String()
+ } else {
+ evt := EventFromPath(path)
+ cid := evt.Cid()
+ sb.WriteString(proposalForm(cid, event.ScheduleFromEvent(evt, cid)))
+ }
+ return RenderFlyer(path) + sb.String()
+}
\ No newline at end of file