Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .kiro/specs/meeting-alerter/.config.kiro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "5a39fe9f-f879-423b-9f50-30c643b19d5d", "workflowType": "requirements-first", "specType": "feature"}
260 changes: 260 additions & 0 deletions .kiro/specs/meeting-alerter/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
# Design: meeting-alerter and natural-language time windows

## Overview

This document is the implementation-level companion to `requirements.md`. It covers the package layout, the natural-language parser's grammar and structure, and the alerter pieces that aren't yet built (REQ-4, REQ-6).

## Package layout (after this spec lands)

```
work-cal-sync/
├── cmd/
│ ├── work-cal-sync/ # existing, unchanged
│ ├── meeting-alerter/ # existing, gets install/uninstall + persisted state
│ └── ekprobe/ # existing, unchanged
├── pkg/
│ └── eventkit/ # existing, unchanged
├── internal/
│ ├── sync/ # existing, unchanged
│ └── whenparse/ # NEW: ParseInstant / ParseRange
└── docs/
└── ... # already updated for current state
```

`internal/whenparse` is `internal/` rather than `pkg/` because the grammar is project-specific (phrases tuned to "I want to see calendar events" rather than general date-time parsing). If a third-party tool wants the same parser, we can lift it to `pkg/` later — same trick we did with `eventkit`.

## `internal/whenparse`

### Public API

```go
package whenparse

// ParseInstant turns an English phrase into a single point in time,
// resolved against `now`. Day-precision phrases (today, tomorrow,
// 2026-05-25, monday) snap to local midnight on that day.
func ParseInstant(phrase string, now time.Time) (time.Time, error)

// ParseRange turns an English phrase into a [start, end) window.
// Single-instant phrases that look like a day (today, tomorrow, monday,
// 2026-05-25) become full local days. Phrases that name a duration
// (next 3 days, this week) become spans of that duration.
//
// Errors include the input string for ergonomics.
func ParseRange(phrase string, now time.Time) (start, end time.Time, err error)
```

`now` is injected so tests can pin time deterministically.

### Internal pipeline

```
phrase ──► normalize ──► tokenize ──► classify ──► parse ──► (instant | range)
```

1. **normalize**: lower-case, collapse whitespace, replace common synonyms (`thru` → `through`, `&` → `and`).
2. **tokenize**: split on whitespace, recognise compound tokens like `2026-05-25` and `13:00` as single tokens.
3. **classify**: route the token sequence to one of these handlers based on a leading keyword, falling back to a literal-date check at the end:

| Leading token | Handler |
|---|---|
| `now`, `today`, `tomorrow`, `yesterday` | `parseNamedDay` |
| `in`, `next`, `last` | `parseRelativeDuration` |
| a digit | `parseNumberLeading` (handles `3 days from now`, `3 days ago`) |
| a weekday name | `parseWeekday` |
| `this`, `next`, `last` followed by `week` | `parseWeekRange` |
| `rest` followed by `of` | `parseRestOfWeek` |
| `until` | `parseUntil` |
| otherwise | `parseAbsolute` (RFC3339 + the existing local-time formats) |

4. **parse**: each handler returns either a `time.Time` (instant) or a `(start, end)` pair (range). Range-only callers from instants get a 24-hour window automatically.

A small `composite()` step runs before classify when the input contains the literal word `through` (or `to` between two times when neither is `from`/`through` already): split on it, recursively parse each side as `ParseInstant`, return `[left, right)` or fail if `right` isn't after `left`.

### Grammar reference (illustrative, not exhaustive)

The acceptance criteria in REQ-5 are the contract. This subsection gives concrete examples for each handler so the implementation has clear targets.

```
parseNamedDay
"now" → instant: now
"today" → instant: midnight of today (local)
→ range: [midnight today, midnight tomorrow)
"tomorrow" → range: [midnight tomorrow, midnight day after)

parseRelativeDuration
"in 3 days" → instant: midnight on (today + 3)
"next 3 days" → range: [now, now + 72h)
"next 4 hours" → range: [now, now + 4h)
"next week" → range: [next Sunday 00:00, the Sunday after 00:00)
"last 2 weeks" → range: [now - 14d, now)

parseNumberLeading
"3 days from now" → instant: midnight on (today + 3)
"3 days ago" → instant: midnight on (today - 3)
"2 hours from now" → instant: now + 2h
"30 minutes from now" → instant: now + 30m

parseWeekday
"monday" → range: [Monday 00:00, Tuesday 00:00) of the next occurrence
"monday at 14:00" → instant: that Monday at 14:00 local
"monday through fri" → range: [Monday 00:00, Saturday 00:00) of the same week

parseWeekRange
"this week" → range: [most recent Sunday 00:00, next Sunday 00:00)
"next week" → range: [next Sunday 00:00, the Sunday after 00:00)
"rest of the week" → range: [now, next Sunday 00:00)
"rest of this week" → range: [now, next Sunday 00:00)

parseUntil
"until friday" → range: [now, end of next Friday)
"until 2026-05-25" → range: [now, end of 2026-05-25)
"until tomorrow" → range: [now, end of tomorrow)

parseAbsolute
"2026-05-25" → instant: 2026-05-25 00:00 local
→ range: [00:00, 24:00) of that day
"2026-05-25T14:00" → instant: that local time
"2026-05-25 14:00" → instant: that local time
RFC3339 → instant: as parsed
```

### Edge cases

- **End of day**: "rest of the week" must include the last day fully, so `end` is the start of the **following** Sunday (Sun-Sun convention; the half-open interval keeps everything clean).
- **Weekday already passed today**: `monday` when today is Wednesday means *next* Monday, not yesterday's Monday. Anchor to "the next occurrence at or after today midnight".
- **`today` mid-day**: `today` as an instant resolves to *midnight*, not now. `now` is the only phrase that produces a non-midnight instant. This matters when chaining — `--from today --to friday` includes the morning, which is what the user means.
- **Time-of-day clamps**: `monday at 14:00` produces 14:00 local on the next Monday; if today is Monday and it's already 15:00, that means *next* Monday (a week from now).
- **Composite range with overlap**: `--from "tomorrow" --to "yesterday"` errors; the composite parser checks `end.After(start)`.
- **Relative durations carry seconds, named days don't**: `in 90 seconds` produces an exact instant; `today` produces midnight. Mixing them in a composite is fine: `--from "in 30 minutes" --to "tomorrow"` is `[now+30min, midnight+24h)`.

## Wiring `whenparse` into the existing flags

`cmd/meeting-alerter/main.go` currently has `parseListWindow` returning `(start, end, err)`. The contract changes from "decode this specific flag set" to "feed each flag's value through `whenparse` and assemble a window":

```go
// Pseudo-code for the updated parser
func parseListWindow(args []string, cfg *Config, now time.Time) (time.Time, time.Time, error) {
flags := extractListFlags(args) // unchanged

// --when wins; it specifies a complete range.
if flags.when != "" {
return whenparse.ParseRange(flags.when, now)
}

// --minutes is an integer-only shortcut for "next N minutes".
if flags.hasMinutes {
return now, now.Add(time.Duration(flags.minutes) * time.Minute), nil
}

// --day passes through ParseRange so it accepts everything --when does.
if flags.day != "" {
return whenparse.ParseRange(flags.day, now)
}

// --from / --to each pass through ParseInstant.
if flags.from != "" || flags.to != "" {
if flags.from == "" || flags.to == "" {
return zero, zero, errors.New("--from and --to must be supplied together")
}
start, err := whenparse.ParseInstant(flags.from, now)
if err != nil {
return zero, zero, fmt.Errorf("--from: %w", err)
}
end, err := whenparse.ParseInstant(flags.to, now)
if err != nil {
return zero, zero, fmt.Errorf("--to: %w", err)
}
if !end.After(start) {
return zero, zero, errors.New("--to must be after --from")
}
return start, end, nil
}

return now, now.Add(time.Duration(cfg.LookaheadMinutes) * time.Minute), nil
}
```

The existing flag-combination rules ("`--minutes` cannot be combined with `--from/--to/--day`") stay the same.

The existing `meeting-alerter list` tests stay green because `--day "today"` is a recognised `whenparse` phrase. We add new tests for the natural-language phrases and remove the now-redundant `parseDayWindow` / `parseFlexibleTime` helpers.

## `meeting-alerter install` / `uninstall`

The launchd plist is generated from a Go template, same approach as `internal/sync/plist.go`. The bundle path is what goes in `ProgramArguments`, not the bare CLI:

```go
// Sketch
const plistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>tel.dead.meeting-alerter</string>
<key>ProgramArguments</key>
<array>
<string>{{.BundleBinary}}</string>
<string>watch</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{{.LogPath}}</string>
<key>StandardErrorPath</key>
<string>{{.LogPath}}</string>
</dict>
</plist>`
```

`{{.BundleBinary}}` resolves to `~/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter` so launchd-spawned processes get the bundle's TCC identity. `{{.LogPath}}` is `~/Library/Logs/meeting-alerter.log`.

`install` is idempotent: it unloads any existing job before loading the new one. `uninstall` confirms with the user, unloads, removes the plist + config dir.

## Persisted notification state (REQ-6)

`~/.config/meeting-alerter/notified.json`:

```json
{
"version": 1,
"entries": [
{"external_id": "987...", "meeting_start_unix": 1779555600},
{"external_id": "ABC...", "meeting_start_unix": 1779559200}
]
}
```

Schema-versioned so we can extend it (e.g., snooze state) without breaking loaders. Read on `watch` startup, written atomically (`os.Rename` from a sibling tmp file) after each notification. Pruned on every sweep: drop entries whose `meeting_start_unix < (now - 1h)`.

## Logging conventions

Existing slog conventions hold. Each subcommand begins with an `INFO` line that names the run:

```
meeting-alerter watch INFO "watch: started" calendar="Calendar" lookahead_minutes=60 notify_before_minutes=5
meeting-alerter watch INFO "watch: notified" work_id=987... title="Standup" start=2026-05-20T09:30 in_minutes=5
meeting-alerter watch DEBUG "watch: scanned" events=4 now=...
```

`meeting-alerter list` is silent on success except the table; failures go to stderr at ERROR.

## Testing strategy

- `internal/whenparse`: a single table-driven test covering every example in the grammar reference plus edge cases. Pin `now` to a fixed Wednesday so weekday-relative phrases have predictable outputs.
- `cmd/meeting-alerter`: extend the existing `parseListWindow` test to include NL phrases. The bundle-spawning code path stays unit-untestable; integration is by hand against a live calendar.

## Risks and mitigations

| Risk | Mitigation |
|---|---|
| Hand-rolled grammar grows long-tail of phrases users want | Future TODO-2 (LLM fallback) covers the long tail. Until then, return a clear error with the input phrase so users know it wasn't recognised. |
| Weekday boundary ambiguity (Sun-start vs Mon-start) | Pin to Sun-start (US default). Document explicitly in the user guide; revisit when we add localisation. |
| Persisted-state file grows or corrupts | Schema-versioned + atomic writes + 1-hour-past prune sweep. Worst case on corruption: ignore the file and start fresh; the user's only loss is "every still-upcoming meeting alerts again on the next start". |

## Out of scope (this spec)

- Localisation of the parser to non-English phrases.
- An LLM time-conversation mode (TODO-2 in requirements).
- A Kiro-skill wrapper (TODO-1 in requirements).
Loading