Skip to content

Events Command — Design Spec

Date: 2026-04-18 Status: Approved Project: sap-devs-cli


Overview

Add a sap-devs events command to browse upcoming SAP community events from two data sources: RSS feeds (CodeJam, Devtoberfest) fetched live with cache fallback, and manually curated YAML instances (TechEd sessions). Event types are defined in pack-level event-types.yaml; manual instances in event-instances.yaml. Location-based filtering uses an embedded city coordinate database with Haversine distance and configurable radius thresholds. Includes iCal export, session-start hook reminders, and cross-platform OS notifications.


Data Model

event-types.yaml (per pack)

Defines event categories and their data sources:

yaml
- id: codejam
  name: SAP CodeJam
  description: Hands-on workshop series hosted by SAP developer advocates
  source: rss
  rss_url: https://community.sap.com/t5/sap-codejam/bg-p/code-jam/rss
  default_scope: local
  tags: [workshop, hands-on, in-person]

- id: teched
  name: SAP TechEd
  description: Annual SAP technology conference
  source: manual
  default_scope: regional
  tags: [conference, annual]

- id: devtoberfest
  name: Devtoberfest
  description: Annual month-long SAP developer learning event
  source: rss
  rss_url: https://community.sap.com/t5/devtoberfest/bg-p/devtoberfest/rss
  default_scope: global
  tags: [learning, virtual, october]

Required fields: id, name, source, default_scope.

  • source: "rss" | "manual"
  • rss_url: required when source is "rss"
  • default_scope: applied to RSS-parsed events when scope is not determinable

event-instances.yaml (per pack)

Manually curated event instances (for source: manual types):

yaml
- id: teched-2026-bangalore
  type: teched
  title: SAP TechEd 2026 Bangalore
  date: "2026-10-21"
  end_date: "2026-10-23"
  location: Bangalore, India
  scope: regional
  url: https://www.sap.com/events/teched.html
  room:
  speaker:
  tags: [in-person, asia]

- id: teched-2026-virtual
  type: teched
  title: SAP TechEd 2026 Virtual
  date: "2026-11-05"
  end_date: "2026-11-06"
  location: virtual
  scope: virtual
  url: https://www.sap.com/events/teched.html
  tags: [virtual, global]

Required fields: id, type, title, date, url, scope. Optional fields: end_date, location, room, speaker, tags.

Go Structs

Added to internal/content/pack.go:

go
type EventType struct {
    ID           string   `yaml:"id"`
    Name         string   `yaml:"name"`
    Description  string   `yaml:"description,omitempty"`
    Source       string   `yaml:"source"`
    RSSURL       string   `yaml:"rss_url,omitempty"`
    DefaultScope string   `yaml:"default_scope"`
    Tags         []string `yaml:"tags,omitempty"`
    PackID       string   // set at load time
}

type EventInstance struct {
    ID       string   `yaml:"id"`
    Type     string   `yaml:"type"`
    Title    string   `yaml:"title"`
    Date     time.Time `yaml:"-"`     // parsed from string; YAML uses custom unmarshal
    DateStr  string    `yaml:"date"` // ISO 8601 date: "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM:SSZ"
    EndDate  time.Time `yaml:"-"`
    EndDateStr string  `yaml:"end_date,omitempty"`
    Location string   `yaml:"location,omitempty"`
    Scope    string   `yaml:"scope"`
    URL      string   `yaml:"url"`
    Room     string   `yaml:"room,omitempty"`
    Speaker  string   `yaml:"speaker,omitempty"`
    Tags     []string `yaml:"tags,omitempty"`
    PackID   string   // set at load time
}

Pack gains two new fields: EventTypes []EventType and EventInstances []EventInstance.

Date parsing contract: All dates are normalised to time.Time at parse time. YAML manual instances use DateStr/EndDateStr (ISO 8601 YYYY-MM-DD format), unmarshaled into Date/EndDate via a custom UnmarshalYAML. RSS-sourced events parse <pubDate> (RFC 1123Z) into Date via time.Parse. The canonical in-memory representation is always time.Time, used for sorting and iCal export. JSON cache files store dates as RFC 3339 strings.


Events Package: internal/events/

rss.go — RSS Fetching

FetchRSS(rssURL string, typeID string, defaultScope string) ([]EventInstance, error)

Fetches SAP Community RSS, parses &lt;item&gt; elements into []EventInstance. Uses the same HTTP pattern as internal/community/ (custom user agent, 10s timeout, 1 MiB limit). Maps RSS fields: <title> → Title, <link> → URL, &lt;pubDate&gt; → Date. Generates deterministic IDs from type + URL hash. Sets Type to typeID and Scope to defaultScope.

cache.go — File-Based Cache

  • LoadCache(cacheDir string, typeID string) ([]EventInstance, error) — reads ~/.cache/sap-devs/events/&lt;typeID&gt;.json
  • SaveCache(cacheDir string, typeID string, events []EventInstance) error — writes cache file as JSON

events.go — Orchestration

Resolve(eventType EventType, cacheDir string) ([]EventInstance, error) — main entrypoint for RSS-sourced types:

  1. Check cache freshness: if cache exists and is less than 4 hours old, return cached events (no fetch)
  2. If cache is stale or missing: attempt live fetch with 3-second timeout
  3. On success: update cache, return fresh events
  4. On failure: load from stale cache if available, return those events
  5. If no cache either: return nil (not an error)

This makes Resolve() the single fetch path — the sync command calls Resolve() for each RSS type (which populates the cache), and the events command also calls Resolve() (which reads fresh cache or re-fetches if stale). There is no separate sync-only fetch path.

FilterByLocation(events []EventInstance, userLat, userLon float64, localRadius, regionalRadius int) []EventInstance:

  • virtual / global scope: always included
  • regional scope: included if event location is within regionalRadius km
  • local scope: included if event location is within localRadius km
  • Events with empty location or location: "virtual": always included
  • Geocoding via geo.Lookup() on each event's location string

MergeAndSort(rss []EventInstance, manual []EventInstance) []EventInstance:

  • Combines both slices, deduplicates by ID, sorts by date ascending (upcoming first)

ical.go — iCal Export

ExportICS(events []EventInstance, w io.Writer) error — writes standard iCal format:

  • BEGIN:VCALENDAR / END:VCALENDAR wrapper
  • Per event: VEVENT with DTSTART, DTEND (if end_date), SUMMARY, LOCATION, URL, DESCRIPTION
  • PRODID:-//sap-devs//events//EN
  • Date format: YYYYMMDD (all-day) or YYYYMMDDTHHMMSSZ if time is present

notify.go — Notification Logic

CheckUpcoming(events []EventInstance, withinDays int) []EventInstance — returns events whose date is within N days from now.

FormatHookMessage(upcoming []EventInstance) string — formats a terminal-friendly reminder:

📅 3 upcoming SAP events this week:
  • May 15 — CodeJam Hamburg (local)
  • May 18 — CodeJam Munich (local)
  • May 20 — Devtoberfest Webinar (virtual)
Run 'sap-devs events' for details or 'sap-devs events open <id>' to register.

Geo Package: internal/geo/

cities.go — Embedded City Database

go
//go:embed cities.json
var citiesJSON []byte

cities.json contains ~500 cities with coordinates:

json
[
  {"name": "Hamburg", "country": "Germany", "lat": 53.5511, "lon": 9.9937},
  {"name": "Bangalore", "country": "India", "lat": 12.9716, "lon": 77.5946},
  ...
]

Lookup(location string) (lat, lon float64, ok bool) — parses "City, Country" format. Tries exact city+country match first, then city-only match. Case-insensitive. Returns ok=false if no match found.

distance.go — Haversine Distance

go
func DistanceKm(lat1, lon1, lat2, lon2 float64) float64
func IsNearby(lat1, lon1, lat2, lon2 float64, radiusKm float64) bool

Standard Haversine formula. Earth radius: 6371 km.


Notification System: internal/notify/

notify.go — Cross-Platform OS Notifications

Send(title, body string) error — dispatches OS-level notification:

  • Windows: PowerShell [Windows.UI.Notifications.ToastNotificationManager] (built-in on Windows 10+, no third-party module needed). Falls back to msg.exe console message if toast API unavailable.
  • macOS: osascript -e 'display notification "body" with title "title"'
  • Linux: notify-send "title" "body"

Available() bool — returns true if the current platform has a working notification method. Used by events notify to skip gracefully on unsupported systems.

Returns an error if the notification method is unavailable. Callers handle gracefully — notification failure is never fatal. On unsupported platforms, events notify prints the reminder to stdout instead (same as events hook output).


Sync Integration

cmd/sync.go adds "events" to allCategories(). The runSync() function gains an events phase after the zip extraction:

  1. Load event types from the freshly-synced official content packs
  2. For each type with source: rss, call events.Resolve() (which fetches and caches)
  3. Track in sync-state.json under events key via existing TTL mechanism

internal/sync/engine.go is not modified — the events fetch is orchestrated in cmd/sync.go using events.Resolve() directly (same pattern as how sync currently calls expansion.Process() for dynamic content). SyncConfig in internal/config/config.go gains an Events time.Duration field for the TTL (default: 4 hours). --force bypasses as usual.


Config Integration

EventsConfig sub-struct

go
type EventsConfig struct {
    LocalRadius    int    `yaml:"local_radius,omitempty"`    // km, default 200
    RegionalRadius int    `yaml:"regional_radius,omitempty"` // km, default 800
    NotifyDays     int    `yaml:"notify_days,omitempty"`     // default 7
    NotifyMethod   string `yaml:"notify_method,omitempty"`   // "hook" | "os" | "both"
}

Added to Config struct: Events EventsConfig yaml:"events,omitempty"``

Runtime defaults: LocalRadius=200, RegionalRadius=800, NotifyDays=7, NotifyMethod="hook" when values are zero/empty.

Config subcommands

configEventsCmd is a parent command registered under configCmd (like configCmd.AddCommand(configEventsCmd)). Running sap-devs config events with no arguments displays all current events settings. Subcommands set individual values:

sap-devs config events                         # show current events config
sap-devs config events local-radius [km]       # get/set local radius
sap-devs config events regional-radius [km]    # get/set regional radius
sap-devs config events notify-days [days]      # get/set notification window
sap-devs config events notify-method [method]  # get/set: hook | os | both

Each subcommand: with an argument sets the value and prints confirmation; without an argument prints the current value. Follows the configTipRotationCmd pattern. configShowCmd is updated to print the events sub-struct values.


Command Structure

Parent: events (defaults to list)

sap-devs events                              # upcoming, location-filtered
sap-devs events --all                        # no location filter
sap-devs events --type codejam               # filter by event type
sap-devs events --count 20                   # limit results (default 10)

Flags:

FlagShortTypeDefaultDescription
--all-aboolfalseShow all events regardless of location
--type-tstring""Filter by event type ID
--count-nint10Max events to display

Subcommands

SubcommandDescription
open &lt;id&gt;Open event URL in browser
typesList available event types
export [--type X] [--output path]Export events to .ics file
hookPrint upcoming event reminders (session-start hook)
notifySend OS notification for upcoming events

List Output (tabwriter)

DATE         TYPE       SCOPE     LOCATION              TITLE
2026-05-15   codejam    local     Hamburg, Germany       CodeJam Hamburg — CAP Hands-on
2026-06-02   codejam    local     Munich, Germany        CodeJam Munich — ABAP Cloud
2026-10-01   devtober   global    virtual                Devtoberfest 2026
2026-10-21   teched     regional  Bangalore, India       SAP TechEd 2026 Bangalore

Types Output

ID             SOURCE   NAME
codejam        rss      SAP CodeJam
devtoberfest   rss      Devtoberfest
teched         manual   SAP TechEd

List Flow

  1. Load event types from all packs via FlattenEventTypes()
  2. For each RSS-sourced type: call events.Resolve() (live fetch → cache fallback)
  3. Load manual instances via FlattenEventInstances()
  4. MergeAndSort() RSS + manual
  5. Apply --type filter if set
  6. Apply location filter unless --all (geocode user location, apply radius thresholds)
  7. Limit to --count
  8. Display with tabwriter

Location Filtering

When user has location configured:

  1. geo.Lookup(cfg.Location) → user coordinates
  2. If lookup fails: show all events (can't filter without coordinates)
  3. For each event:
    • virtual / global scope: always shown
    • regional scope: shown if geo.Lookup(event.Location) is within cfg.Events.RegionalRadius km (default 800)
    • local scope: shown if within cfg.Events.LocalRadius km (default 200)
    • Events with location: "virtual" or empty location: always shown
  4. Events whose location can't be geocoded: shown (fail-open)

No location configured = show all (equivalent to --all).


i18n Keys

Add to en.json and de.json:

KeyEnglish
events.shortBrowse upcoming SAP community events
events.longBrowse upcoming SAP community events. Filters by your location when configured.
events.noneNo upcoming events found.
events.none_typeNo events found for type "&#123;&#123;.Type&#125;&#125;".
events.open.shortOpen an event URL in the browser
events.types.shortList available event types
events.export.shortExport events to an iCal (.ics) file
events.export.doneExported &#123;&#123;.Count&#125;&#125; events to &#123;&#123;.Path&#125;&#125;
events.hook.shortPrint upcoming event reminders (session-start hook)
events.notify.shortSend OS notification for upcoming events
events.notify.sentNotification sent: &#123;&#123;.Count&#125;&#125; upcoming events
events.notify.noneNo upcoming events within &#123;&#123;.Days&#125;&#125; days.
events.notify.unsupportedOS notifications not available on this system.
events.not_foundEvent "&#123;&#123;.ID&#125;&#125;" not found.
events.col_dateDATE
events.col_typeTYPE
events.col_scopeSCOPE
events.col_locationLOCATION
events.col_titleTITLE
events.types.col_idID
events.types.col_sourceSOURCE
events.types.col_nameNAME

Files Changed

FileChange
internal/content/pack.goAdd EventType and EventInstance structs; add fields to Pack; load both YAML files in LoadPack()
internal/content/merge.goAdd mergeEventTypes() and mergeEventInstances(); wire into MergeWith()
internal/content/events.goNew: FlattenEventTypes, FlattenEventInstances, FilterEventsByType, FindEvent
internal/geo/cities.goNew: embedded city database, Lookup()
internal/geo/cities.jsonNew: ~500 cities with lat/lon coordinates
internal/geo/distance.goNew: DistanceKm(), IsNearby()
internal/events/rss.goNew: FetchRSS() — RSS fetch and parse into []EventInstance
internal/events/cache.goNew: LoadCache(), SaveCache()
internal/events/events.goNew: Resolve(), FilterByLocation(), MergeAndSort()
internal/events/ical.goNew: ExportICS() — iCal/ICS generation
internal/events/notify.goNew: CheckUpcoming(), FormatHookMessage()
internal/notify/notify.goNew: cross-platform OS notification dispatch
cmd/events.goNew: eventsCmd, eventsOpenCmd, eventsTypesCmd, eventsExportCmd, eventsHookCmd, eventsNotifyCmd
cmd/config.goAdd configEventsCmd parent with show-all default; add subcommands for each setting; update configShowCmd to print events values
internal/config/config.goAdd EventsConfig sub-struct; add Events field to Config; add Events time.Duration to SyncConfig
cmd/sync.goAdd "events" to allCategories(); add events fetch phase calling events.Resolve() for each RSS-sourced type
content/packs/base/event-types.yamlSeed: CodeJam, TechEd, Devtoberfest
content/packs/base/event-instances.yamlSeed: TechEd 2026 instances
content/schemas/event-types.schema.jsonJSON Schema
content/schemas/event-instances.schema.jsonJSON Schema
.vscode/settings.jsonWire both schemas
internal/i18n/catalogs/en.jsonAdd events.* i18n keys
internal/i18n/catalogs/de.jsonAdd German events.* i18n keys
CLAUDE.mdAdd events to CLI commands table