feat(schedule): workspace agenda endpoint (TASK-4104 D1) (#2357)

* feat(store): date-window item filter and one shared visibility clause (TASK-4104 D1)

ItemListParams.FieldDateWindow keeps items where any named date field falls
on a day in a window, on both stored date shapes and both dialects. The
pending-reminder visibility switch becomes scopeClause so the agenda reads
the same predicate.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* feat(schedule): workspace agenda endpoint (TASK-4104 D1)

GET /workspaces/{ws}/agenda?from&to&tz lists the schedules' occurrences with
their state and the items dated in the window, in the caller's zone, under
the dashboard's visibility applied in SQL. A per-schedule cap degrades that
schedule to truncated or unavailable instead of failing the read. History
rows merge by wall for floating and all-day schedules, by instant for pinned
timed ones.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* test(schedule): classify the agenda in the compress census and the lifecycle guard (TASK-4104 D1)

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* fix(schedule): agenda scan skips ended schedules, one bad schedule is unavailable, empty tz is refused (TASK-4104 D1 r1-1, r1-2, r1-5)

- r1-1: the 1000-schedule scan bound no longer counts schedules that ended
  before from (ended_at IS NULL OR ended_at >= from).
- r1-2: a non-cap error from one schedule's expansion is logged by schedule
  id and error class only and listed under unavailable with code internal;
  the agenda stays 200.
- r1-5: an explicit empty or blank tz is 400 invalid_tz.
- Tests: scan starvation, corrupt schedule, empty tz, the 100 and 500 cuts,
  an app-token ceiling vs the item list, a pinned HOURLY and a floating
  missed count across New York's 2026-11-01 fall-back, a stronger runaway row.
- Docs: date-entry selection and at, the cut before date ordering, accepted
  deviations.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* fix(schedule): agenda lists an early-resolved schedule, store classifies expansion errors (TASK-4104 D1 r2-1, r2-2)

r2-1: an ended schedule stays in the agenda scan while a history row can
reach [from-36h, to+36h), so a deadline marked done early still lists as
done; one whose history is wholly outside the window still spends no bound
slot. ORDER BY is qualified: the unnamed reminder subselects also output an
"id" column, which Postgres refuses as ambiguous.

r2-2: ExpandAgenda reports unparseable stored state as unavailable
"internal" with a nil error and returns an error only for a database
failure; the handler answers that with a 500 again. The 1.5 s budget is
checked between schedules and never reaches that branch.

r2-3: the API docs state the three behaviours under a plain label.
r2-4: the runaway test proves the runaway's own ref and the healthy refs.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* test(schedule): the agenda's DB-failure row breaks only the history read; the runaway row pins its one outcome (TASK-4104 D1 r4-2, r4-4)

TestAgendaDatabaseFailureDuringExpansionIs500 dropped schedule_occurrences,
which since r2-1 also fails the list query's EXISTS, so it 500ed before
ExpandAgenda and could not catch a handler that swallowed the error. It now
renames a column only agendaOccurrences selects. A mutant that swallows the
error as unavailable=internal turns it red.

TestAgendaOneRunawayScheduleDoesNotTakeItDown accepted either outcome. A
MINUTELY rule over the window hits the instance cap with partial
occurrences, cut to the per-schedule cap: exactly 100 entries, truncated.
It pins that; AgendaMaxPerSchedule 100 -> 99 turns it red.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
This commit is contained in:
xarmian
2026-10-31 09:32:23 -04:00
committed by GitHub
parent 705c6e9862
commit b721dfb74d
12 changed files with 1533 additions and 35 deletions
+16 -1
View File
@@ -315,7 +315,7 @@ HTTP only for now: there is no CLI command or MCP action, and the web has no cli
- **Bodies are strict.** A body that is not a JSON object (`null` included), an unknown or repeated member, a NUL, or a wrong JSON type is `400 invalid_body`. POST: `{kind, dtstart, dtend?, tz?, floating?, rrule?, rdates?, exdates?, anchor?, audience?, lead_minutes?}`; `kind` is `deadline_hard | deadline_soft | check | window`, `anchor` `calendar | completion`, `audience` `human | agent | both`, `lead_minutes` 0 to 44640. Walls (`dtstart`, `dtend`, `rdates`, `exdates`) are zone-less text, `2026-11-02` (all-day) or `2026-11-02T09:00[:00]`; a zone or offset is refused as `wall`. `rrule` is RRULE text or a shorthand and is stored in its canonical form. On POST, `null` on `kind` or `dtstart` is `400 validation_error` with `details: {field, code: "required"}`, and `null` on any other member is the member left out. PATCH takes any subset: `null` clears `dtend`, `rrule`, `rdates` and `exdates` (an empty `rrule: ""` clears it too); `null` on any other member (and `tz: null`; send `floating: true` to unpin) is `400 validation_error` with `details: {field, code: "required"}`; `{}` is a 200 no-op.
- **Zone default.** A POST naming neither `tz` nor `floating: true` is pinned to the caller's account zone (UTC when none is set, and then the response carries `timezone_note`). A PATCH of `floating: false` on a floating schedule with no `tz` does the same.
- **The view** is `{id, item_ref, kind, dtstart, dtend?, tz?, floating, rrule?, rdates, exdates, anchor, audience, lead_minutes, next_occurrence?: {at, wall}, next_override_at?, missed_count?, missed_truncated?, stale?, ended_at?, error_code?, reminder_id?, created_at, updated_at, timezone_note?}`. A pinned schedule's `next_occurrence.at` is the instant it fires for. An armed FLOATING schedule's is the `wall` read in the CALLER's zone (the tick uses the recipient's zone, so two callers may see different `at` for one `wall`); once a floating occurrence has fired, `next_occurrence.at` and a managed reminder's `occurrence_at` report the instant it fired for, whatever zone the reader or recipient is in now. `missed_count` is the occurrences that passed unresolved AFTER the outstanding one (which is not counted), computed on every read and never stored, at most 500 and looking at most 92 days past the outstanding occurrence; `missed_truncated` says the count is a floor; `stale` is `missed_count >= 2`. All three are omitted when there is nothing to report: an absent `missed_count` means 0, and an absent `missed_truncated` or `stale` means false. `next_occurrence` and `reminder_id` are absent once `ended_at` is set (a rule with nothing left, after an edit). `error_code` is null while healthy; it is set when the fire path could not advance a stored rule and cleared by a timing edit that compiles.
- **Errors.** `400 validation_error` with `details.code`: the ten schedule codes `syntax`, `unknown_part`, `duplicate_part`, `missing_part`, `range`, `combination`, `unsupported`, `unsatisfiable`, `zone`, `wall` (plus `part` and `value`, at most 64 bytes of the offending text); `cap_scan` and `cap_stall` (input-caused; separate codes); the store's `details: {field, code}` with `code` `enum`, `range`, `combination` or `required`, and on a resolve `format` (an instant member that does not parse); and `exhausted` (a POST whose rule has no occurrence at or after now; the same edit through PATCH ends the schedule instead, 200 with `ended_at`). A rule whose BYxxx lists multiply past the budget is `range` with `part: FREQ`. `cap_time` is `503 schedule_busy` with `Retry-After: 1` (load-dependent, retry). `cap_window` and `cap_instances` are never returned by a write; they can appear only as a stored `error_code`. These names are API vocabulary and do not change spelling. Nothing is persisted on any refusal.
- **Errors.** `400 validation_error` with `details.code`: the ten schedule codes `syntax`, `unknown_part`, `duplicate_part`, `missing_part`, `range`, `combination`, `unsupported`, `unsatisfiable`, `zone`, `wall` (plus `part` and `value`, at most 64 bytes of the offending text); `cap_scan` and `cap_stall` (input-caused; separate codes); the store's `details: {field, code}` with `code` `enum`, `range`, `combination` or `required`, and on a resolve `format` (an instant member that does not parse); and `exhausted` (a POST whose rule has no occurrence at or after now; the same edit through PATCH ends the schedule instead, 200 with `ended_at`). A rule whose BYxxx lists multiply past the budget is `range` with `part: FREQ`. `cap_time` is `503 schedule_busy` with `Retry-After: 1` (load-dependent, retry). `cap_window` and `cap_instances` are never returned by a schedule write; `cap_instances` can appear only as a stored `error_code`, and `cap_window` is the agenda read's answer to a window over 92 days (see Workspace agenda). These names are API vocabulary and do not change spelling. Nothing is persisted on any refusal.
- **Resolving: `POST /workspaces/{ws}/items/{ref}/schedule/resolve`** (TASK-4103). Body `{occurrence_at, state, comment?, until?}`, strict like the others. `occurrence_at` is the RFC3339 instant of the outstanding occurrence as the caller last read it (the view's `next_occurrence.at`, or a reminder's `occurrence_at`), `state` is `done | skip | snooze | move`, `comment` is optional text written as an ordinary item comment in the same transaction, and `until` (RFC3339, after now and at most 366 days ahead) is required for `snooze` and `move` and refused for `done` and `skip`. A missing, mistyped or unparseable member is `400 validation_error` with `details: {field, code}`: `required`, `enum` (`state`), `format` (`occurrence_at` or `until` that is not an RFC3339 instant) or `range` (an `until` out of range) or `combination` (`until` on a done or skip). Permission is the PATCH rule (edit on the item; archived 409; unknown item 404; no schedule 404). Success is `200 {schedule: <the view>, resolution: {occurrence_at, state, comment_id?, missed_recorded}}`.
- `done` and `skip` close the occurrence and arm the next one on the SAME managed reminder row (a fired row is re-armed; nothing is emitted by the resolve itself, so the next event is the next fire). A calendar schedule advances to the first instance after both now and the resolved occurrence, so resolving early does not return the occurrence just closed. Instances that passed unresolved after it become `missed` occurrence records (the same walk `missed_count` shows, so the count and the records agree; at most 500 records and 92 days past the occurrence, best-effort history). A schedule with nothing left (a one-shot, a rule past its UNTIL) is ended: `ended_at` is set and the managed reminder is removed.
- `snooze` keeps the occurrence outstanding and re-fires it once at `until`; `move` keeps it outstanding and makes `until` its new due instant (the lead is subtracted, so it fires `lead_minutes` earlier). Both re-arm the same managed row, leave `next_occurrence.wall` alone, report `next_override_at`, and fire an event whose `occurrence_at` is the ORIGINAL occurrence, and a second notification. A floating occurrence is frozen at the instant the caller named, so a later change of the recipient's zone does not move it. `next_override_at` for a snooze is `until` plus the lead (the stored value the fire instant is derived from). `move` also writes a `moved` occurrence record that the eventual done or skip replaces. A hard deadline (`deadline_hard`) cannot be moved (`400 combination` on `state`; snooze it, or change its kind); moving a soft deadline needs a non-blank `comment` (`400 required` on `comment`), checked before anything is written.
@@ -324,3 +324,18 @@ HTTP only for now: there is no CLI command or MCP action, and the web has no cli
- **Stale and broken.** `409 stale` when `occurrence_at` is not the outstanding occurrence (already resolved or moved on, the schedule ended, or a floating one read in a zone that gives another instant): `details: {ended, next_occurrence?: {at, wall}}` carries the current one (a floating `at` in the caller's zone), so the caller can retry. An armed floating occurrence is accepted at the instant read in the recipient's zone or in the caller's own. Nothing is written. There is no idempotency key: a retried resolve that already took effect answers `409 stale`. `409 schedule_error` with `details.error_code` when the stored rule no longer compiles (nothing is advanced or ended; a timing PATCH repairs it). `cap_time` is the same `503 schedule_busy`.
- **Activity and events.** Each successful write logs `schedule_created`, `schedule_updated`, `schedule_deleted` or `schedule_resolved` (metadata holds the kind and, for an update, the member NAMES sent, and for a resolve the `state`; never rule text, walls, instants or comment text) and publishes `item_updated`. A resolve that sent a comment also publishes the ordinary comment event and watch notification.
- **Limitations.** (a) A FLOATING schedule with a bare HOURLY or MINUTELY rule cannot address the repeated hour of a fall-back; pinned schedules are exact. (b) Schedules are not carried by workspace export/import, cross-workspace copy or `pad db migrate-to-pg` (export skips the managed reminders); a schedule is LOST on those paths, with no `dropped_fields` entry yet. (c) A fired schedule fires once and then waits for a resolve: the managed reminder cannot be acked, so its reminder leaves the pending list when the schedule is resolved (a one-shot ends) or deleted. (d) `audience` is stored and returned but does not route push or events yet.
### Workspace agenda (TASK-4104)
`GET /workspaces/{ws}/agenda?from=<RFC3339>&to=<RFC3339>&tz=<IANA>`: the schedules' occurrences and the items dated inside the window, in one list. HTTP only; the CLI and MCP render this shape in a later unit. Open to every principal `RequireWorkspaceAccess` admits.
- **Window.** `from` and `to` are required RFC 3339 instants with `to` after `from` and at most 92 days apart (92 exactly is allowed). `400 validation_error` with `details`: `{field: from|to, code: required|format}`, `{field: to, code: range}` for `to <= from`, `{code: cap_window, limit_days: 92}` for a longer window. `tz` is optional: an IANA name that decides how a FLOATING schedule is read and how `at` and `day` are rendered (`400 invalid_tz`, `Local` included). Absent, the caller's account zone is used, else UTC with a `timezone_note`. `tz` never moves a pinned instant.
- **Visibility** is the dashboard's: the caller's visible collections plus their item grants, applied in SQL before any bound, so items they cannot see never push a visible one past a limit and never appear in `entries`, `truncated` or `unavailable`. Done items (a terminal status) are hidden, schedules and dated entries alike.
- **Response.** `{from, to, tz, timezone_note?, entries, truncated?, unavailable?: [{item_ref, code}]}`. Entries are ordered by `at`, then schedule before date, then `item_ref`, then `schedule_id`/`field`. `at` is RFC 3339 in `tz`'s offset and `day` its local date (an all-day entry's `day` is its date, not shifted by `tz`).
- **Schedule entry:** `{type: "schedule", at, day, wall, all_day?, floating, state, item_ref, item_title, collection, kind, audience, schedule_id, fired?, overdue?, effective_at?, resolved_at?, actor?, comment_id?, missed_count?, stale?}`. `at` is the GRID instant (an all-day occurrence: local midnight, not the 09:00 fire hour). `state` is `outstanding` (the one live occurrence; carries `fired`, `overdue` once due, `missed_count`, `stale`), `done | skipped | moved | missed` from the history row, `missed` also derived for a passed instance with no row (the rule `missed_count` uses), `scheduled` (future), or `unrecorded` (before the outstanding occurrence with no row; a state is never invented). A MOVED outstanding occurrence stays at its grid `at` and reports `effective_at`; it is listed when either instant is in the window. A snooze does not move the entry. Nothing is pulled in from before `from`.
- **Matching history to occurrences** is by the WALL for floating and all-day schedules (a resolve stores the recipient's reading, which is another instant in the caller's zone) and by the INSTANT for pinned timed ones.
- **Date entry:** `{type: "date", at, day, field, date, overdue?, item_ref, item_title, collection}` for `due_date` and `end_date` (the same keys `overdue` uses), one per hit. `date` echoes the stored value (`YYYY-MM-DD` or RFC 3339); `day` is its own first ten characters. An entry is SELECTED by its calendar day in `tz` (the window's first and last local days), not by instant. `at` is local midnight of that day for a date-only value and the stored instant for a datetime value, so `at` may fall outside `[from, to)` (a date-only item on `from`'s day, or a datetime on the window's last day after `to`). `overdue` is the stored value before the caller's today. An item dated before `from`'s day is not listed.
- **Bounds, never a 400.** A schedule that cannot be expanded (a stored `error_code`, a rule that no longer compiles, `cap_scan`, `cap_stall`, `cap_time`) is listed under `unavailable` with its code and the response is still 200. One schedule shows at most 100 entries (`truncated`), the response at most 500, at most 1000 schedules are read and expansion stops after 1.5 s (`truncated`).
- **Incomplete lists.** `truncated` means the list is incomplete: narrow the window. The dated half is cut at 501 items BEFORE date ordering, so past 500 dated items a later date can be shown while an earlier one is missing. More than 1000 live, visible schedules still truncate the schedule scan by id; schedules that ended before `from`, with no history near the window, do not count toward the 1000. A schedule whose stored state cannot be read is listed under `unavailable` with code `internal` (logged by id and error class only) and the response is still 200.
- **History before the anchor; dense rules.** Rows before a schedule's `anchor_wall` are listed from history only (the rule is not walked there). `unrecorded` is derived only from `anchor_wall` on; earlier instances without a row are not invented. The 100-entry cap per schedule applies to the window's first 100, so a dense rule shows its earliest occurrences.
- Never in the response: item content, comments, rule text, `next_at`.
+10
View File
@@ -1937,6 +1937,12 @@ func flexJSONToString(raw json.RawMessage, expectedStart byte, errInvalid error)
}
}
// FieldDateWindow is ItemListParams.FieldDateWindow.
type FieldDateWindow struct {
Keys []string
From, To string
}
type ItemListParams struct {
CollectionSlug string
// ScopeCollectionID restricts to exactly one collection by ID, ANDed
@@ -1967,6 +1973,10 @@ type ItemListParams struct {
// filter key (BUG-3480), through ListItems so it shares the
// scope/permission clauses.
FieldKeyPresent string
// FieldDateWindow keeps only items where ANY of Keys holds a date whose first
// ten characters (the day, in the value's own offset) are within [From, To],
// both `YYYY-MM-DD`. The agenda's dated half; the P2 calendar's shape.
FieldDateWindow *FieldDateWindow
Sort string // e.g. "priority:desc,created_at:asc"
GroupBy string
Search string // FTS query
@@ -39,15 +39,16 @@ var lifecycleHelpers = map[string]bool{
var lifecycleSites = map[string]string{
// The helpers themselves, composed of each other.
"internal/models/close_state.go::ItemCloseState::IsTerminalItem": "keep: an item's own close state (open/done/abandoned), not a count",
"internal/models/close_state.go::ItemCloseState::IsAbandonedItem": "keep: an item's own close state, not a count",
"internal/server/handlers_dashboard.go::isItemDone::IsTerminalItem": "keep: the done predicate itself; each caller below is classified",
"internal/server/handlers_dashboard.go::childProgressState::IsAbandonedItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/server/handlers_dashboard.go::childProgressState::IsTerminalItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/store/items.go::nonTerminalFilter::doneFiltersForWorkspace": "keep: the list filter for an item list's own open items (a doc's status filters its own list)",
"internal/store/items.go::nonTerminalFilter::buildChildrenDoneExpr": "keep: as above",
"internal/store/items.go::listItemsQ::nonTerminalFilter": "keep: `pad item list` / MCP list's default open filter, a list's own items (ListItems delegates here; ListItemsQ refuses NonTerminal, BUG-3659)",
"internal/store/items.go::listItemsFTS::nonTerminalFilter": "keep: the same filter on the search-backed list",
"internal/models/close_state.go::ItemCloseState::IsTerminalItem": "keep: an item's own close state (open/done/abandoned), not a count",
"internal/models/close_state.go::ItemCloseState::IsAbandonedItem": "keep: an item's own close state, not a count",
"internal/server/handlers_dashboard.go::isItemDone::IsTerminalItem": "keep: the done predicate itself; each caller below is classified",
"internal/server/handlers_dashboard.go::childProgressState::IsAbandonedItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/server/handlers_dashboard.go::childProgressState::IsTerminalItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/store/items.go::nonTerminalFilter::doneFiltersForWorkspace": "keep: the list filter for an item list's own open items (a doc's status filters its own list)",
"internal/store/items.go::nonTerminalFilter::buildChildrenDoneExpr": "keep: as above",
"internal/store/items.go::listItemsQ::nonTerminalFilter": "keep: `pad item list` / MCP list's default open filter, a list's own items (ListItems delegates here; ListItemsQ refuses NonTerminal, BUG-3659)",
"internal/store/items.go::listItemsFTS::nonTerminalFilter": "keep: the same filter on the search-backed list",
"internal/store/schedule_agenda.go::ListSchedulesForAgenda::nonTerminalFilter": "keep: lists the time triggers someone attached to an item, not open work; a schedule on a reference item is deliberate (TASK-4104)",
// Progress.
"internal/store/items.go::GetItemProgress::buildChildrenDoneExpr": "exclude: counted only where buildChildrenUncountedExpr is false",
+236
View File
@@ -0,0 +1,236 @@
package server
import (
"net/http"
"sort"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/usertz"
)
// The workspace agenda (TASK-4104 D1, DOC-4098 Views): the schedules'
// occurrences and the items dated inside a window, in the caller's zone.
// HTTP only; the CLI and MCP render this shape in a later unit.
const (
agendaMaxWindow = 92 * 24 * time.Hour // schedule.CapWindow's own bound
agendaMaxEntries = 500
agendaMaxScan = 1000 // schedules read per request
// agendaBudget bounds the expansion of a guest-callable read: a thousand
// schedules at the engine's own per-call time cap are otherwise unbounded.
agendaBudget = 1500 * time.Millisecond
)
type agendaEntry struct {
Type string `json:"type"` // schedule | date
At string `json:"at"`
Day string `json:"day"`
Wall string `json:"wall,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Floating *bool `json:"floating,omitempty"`
State string `json:"state,omitempty"`
ItemRef string `json:"item_ref"`
ItemTitle string `json:"item_title"`
Collection string `json:"collection"`
Kind string `json:"kind,omitempty"`
Audience string `json:"audience,omitempty"`
ScheduleID string `json:"schedule_id,omitempty"`
Field string `json:"field,omitempty"`
Date string `json:"date,omitempty"`
Fired bool `json:"fired,omitempty"`
Overdue bool `json:"overdue,omitempty"`
EffectiveAt string `json:"effective_at,omitempty"`
ResolvedAt string `json:"resolved_at,omitempty"`
Actor string `json:"actor,omitempty"`
CommentID string `json:"comment_id,omitempty"`
MissedCount int `json:"missed_count,omitempty"`
Stale bool `json:"stale,omitempty"`
instant time.Time // sort key, with Type and ItemRef
subKey string
}
type agendaUnavailable struct {
ItemRef string `json:"item_ref"`
Code string `json:"code"`
}
type agendaResponse struct {
From string `json:"from"`
To string `json:"to"`
Tz string `json:"tz"`
TimezoneNote string `json:"timezone_note,omitempty"`
Entries []agendaEntry `json:"entries"`
Truncated bool `json:"truncated,omitempty"`
Unavailable []agendaUnavailable `json:"unavailable,omitempty"`
}
func writeAgendaParam(w http.ResponseWriter, field, code, msg string) {
writeError2(w, http.StatusBadRequest, "validation_error", msg, map[string]interface{}{"field": field, "code": code})
}
// parseAgendaWindow reads from and to, writing the 400 itself on a refusal.
func parseAgendaWindow(w http.ResponseWriter, r *http.Request) (from, to time.Time, ok bool) {
var t [2]time.Time
for i, name := range []string{"from", "to"} {
raw := strings.TrimSpace(r.URL.Query().Get(name))
if raw == "" {
writeAgendaParam(w, name, "required", "`"+name+"` is required: an RFC 3339 instant")
return
}
var err error
if t[i], err = time.Parse(time.RFC3339, raw); err != nil {
writeAgendaParam(w, name, "format", "`"+name+"` must be an RFC 3339 instant, such as 2026-11-02T00:00:00Z")
return
}
}
from, to = t[0], t[1]
switch {
case !to.After(from):
writeAgendaParam(w, "to", "range", "`to` must be after `from`")
case to.Sub(from) > agendaMaxWindow:
writeError2(w, http.StatusBadRequest, "validation_error", "The window is longer than 92 days",
map[string]interface{}{"code": "cap_window", "limit_days": 92})
default:
return from, to, true
}
return
}
// handleGetAgenda serves GET /api/v1/workspaces/{ws}/agenda?from&to&tz.
func (s *Server) handleGetAgenda(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
from, to, ok := parseAgendaWindow(w, r)
if !ok {
return
}
zone, note := viewerZone(r), ""
if tz := strings.TrimSpace(r.URL.Query().Get("tz")); tz != "" || r.URL.Query().Has("tz") {
loc, err := usertz.Validate(tz)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_tz", "`tz` must be an IANA time zone name, such as America/Los_Angeles")
return
}
zone = usertz.Zone{Name: tz, Loc: loc, Set: true}
} else {
note = usertz.Note(zone)
}
loc := zone.Loc
// The dashboard's visibility, applied in SQL before any bound.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
fullColl, granted, err := s.guestResourceFilter(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
scope := store.PendingReminderScope{CollectionIDs: visibleIDs}
if len(granted) > 0 {
scope = store.PendingReminderScope{CollectionIDs: fullColl, ItemIDs: granted}
}
now := time.Now()
resp := agendaResponse{From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Tz: zone.Name, TimezoneNote: note, Entries: []agendaEntry{}}
schedules, more, err := s.store.ListSchedulesForAgenda(workspaceID, scope, from, to, agendaMaxScan)
if err != nil {
writeInternalError(w, err)
return
}
resp.Truncated = more
deadline := now.Add(agendaBudget)
for _, as := range schedules {
if time.Now().After(deadline) {
resp.Truncated = true
break
}
sc := as.Schedule
es, cut, unavailable, err := s.store.ExpandAgenda(sc, loc, from, to, now)
if err != nil {
writeInternalError(w, err)
return
}
if unavailable != "" {
resp.Unavailable = append(resp.Unavailable, agendaUnavailable{ItemRef: as.ItemRef, Code: unavailable})
}
resp.Truncated = resp.Truncated || cut
for _, e := range es {
out := agendaEntry{
Type: "schedule", At: e.At.In(loc).Format(time.RFC3339), Day: e.At.In(loc).Format("2006-01-02"),
Wall: e.Wall.String(), AllDay: e.Wall.AllDay, Floating: &sc.Floating, State: e.State,
ItemRef: as.ItemRef, ItemTitle: as.ItemTitle, Collection: as.Collection, Kind: sc.Kind, Audience: sc.Audience,
ScheduleID: sc.ID, Fired: e.Fired, Overdue: e.Overdue, ResolvedAt: e.ResolvedAt, Actor: e.Actor,
CommentID: e.CommentID, MissedCount: e.MissedCount, Stale: e.Stale, instant: e.At, subKey: sc.ID,
}
if e.Wall.AllDay {
out.Day = e.Wall.String() // a date, not shifted by the zone it is read in
}
if e.EffectiveAt != nil {
out.EffectiveAt = e.EffectiveAt.In(loc).Format(time.RFC3339)
}
resp.Entries = append(resp.Entries, out)
}
}
// The dated half: ListItems carries the scope, archived and terminal clauses.
fromDay, toDay := from.In(loc).Format("2006-01-02"), to.Add(-time.Nanosecond).In(loc).Format("2006-01-02")
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
CollectionIDs: scope.CollectionIDs, ItemIDs: scope.ItemIDs, NonTerminal: true, NoContent: true,
FieldDateWindow: &models.FieldDateWindow{Keys: overdueDateFields, From: fromDay, To: toDay},
Limit: agendaMaxEntries + 1,
})
if err != nil {
writeInternalError(w, err)
return
}
resp.Truncated = resp.Truncated || len(items) > agendaMaxEntries
today := overdueToday(now.In(loc))
for _, it := range items {
ref := it.Ref
if ref == "" {
ref = it.Slug
}
for _, key := range overdueDateFields {
v := extractFieldValue(it.Fields, key)
if len(v) < 10 || v[:10] < fromDay || v[:10] > toDay {
continue
}
at, err := time.Parse(time.RFC3339, v)
if err != nil {
if at, err = time.ParseInLocation("2006-01-02", v[:10], loc); err != nil {
continue
}
}
resp.Entries = append(resp.Entries, agendaEntry{
Type: "date", At: at.In(loc).Format(time.RFC3339), Day: v[:10], Field: key, Date: v, Overdue: v < today,
ItemRef: ref, ItemTitle: it.Title, Collection: it.CollectionSlug, instant: at, subKey: key,
})
}
}
sort.SliceStable(resp.Entries, func(i, j int) bool {
a, b := resp.Entries[i], resp.Entries[j]
switch {
case !a.instant.Equal(b.instant):
return a.instant.Before(b.instant)
case a.Type != b.Type:
return a.Type == "schedule"
case a.ItemRef != b.ItemRef:
return a.ItemRef < b.ItemRef
}
return a.subKey < b.subKey
})
if len(resp.Entries) > agendaMaxEntries {
resp.Entries, resp.Truncated = resp.Entries[:agendaMaxEntries], true
}
writeJSON(w, http.StatusOK, resp)
}
+565
View File
@@ -0,0 +1,565 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TASK-4104 D1: GET /workspaces/{ws}/agenda.
const agendaSchema = `{"fields":[` +
`{"key":"status","type":"select","options":["open","done"],"default":"open","terminal_options":["done"]},` +
`{"key":"due_date","type":"date"},{"key":"end_date","type":"date"}]}`
// agendaWindow is a one-day window in 2031, well after any clock a test runs at,
// so a schedule created with a 2031 start is always armed.
const agendaWindow = "from=2031-03-10T00:00:00Z&to=2031-03-11T00:00:00Z"
type agendaWorld struct {
srv *Server
ws *models.Workspace
owner *models.User
colls map[string]*models.Collection
items map[string]*models.Item
}
func newAgendaWorld(t *testing.T, slugs ...string) *agendaWorld {
t.Helper()
srv := testServer(t)
owner := mustUser(t, srv, "agenda-owner@example.com", "agendaowner", "")
w := &agendaWorld{srv: srv, ws: mustWorkspace(t, srv, "Agenda", owner.ID), owner: owner,
colls: map[string]*models.Collection{}, items: map[string]*models.Item{}}
for i, slug := range slugs {
c, err := srv.store.CreateCollection(w.ws.ID, models.CollectionCreate{Name: strings.ToUpper(slug[:1]) + slug[1:], Slug: slug, Prefix: fmt.Sprintf("P%d", i), Schema: agendaSchema})
if err != nil {
t.Fatalf("CreateCollection(%s): %v", slug, err)
}
w.colls[slug] = c
}
return w
}
// item creates an item and, when sc is non-nil, its schedule.
func (w *agendaWorld) item(t *testing.T, coll, title, fields string, sc *models.ScheduleCreate) *models.Item {
t.Helper()
it, err := w.srv.store.CreateItem(w.ws.ID, w.colls[coll].ID, models.ItemCreate{Title: title, Fields: fields})
if err != nil {
t.Fatalf("CreateItem(%s): %v", title, err)
}
w.items[title] = it
if sc != nil {
if _, err := w.srv.store.CreateSchedule(w.ws.ID, it.ID, *sc); err != nil {
t.Fatalf("CreateSchedule(%s): %v", title, err)
}
}
return it
}
// oneShot is a schedule at 09:00 UTC on the agendaWindow's day.
func oneShot() *models.ScheduleCreate {
return &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-10T09:00", Tz: "UTC"}
}
func (w *agendaWorld) get(t *testing.T, u *models.User, query string) (int, map[string]any) {
t.Helper()
tok, err := w.srv.store.CreateSession(u.ID, "web-test", "192.0.2.1", "", webSessionTTL)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
rr := doRequestWithCookie(w.srv, "GET", "/api/v1/workspaces/"+w.ws.Slug+"/agenda?"+query, nil, tok)
m := map[string]any{}
_ = json.Unmarshal(rr.Body.Bytes(), &m)
return rr.Code, m
}
func (w *agendaWorld) ok(t *testing.T, u *models.User, query string) map[string]any {
t.Helper()
code, m := w.get(t, u, query)
if code != http.StatusOK {
t.Fatalf("agenda %s: %d %v", query, code, m)
}
return m
}
func entriesOf(m map[string]any) []map[string]any {
var out []map[string]any
es, _ := m["entries"].([]any)
for _, e := range es {
out = append(out, e.(map[string]any))
}
return out
}
// seen is the sorted "type:title" set of a response.
func seen(m map[string]any) string {
var out []string
for _, e := range entriesOf(m) {
out = append(out, fmt.Sprintf("%v:%v", e["type"], e["item_title"]))
}
sort.Strings(out)
return strings.Join(out, ",")
}
func TestAgendaGuestVisibility(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks", "secret", "ideas")
for _, c := range []string{"tasks", "secret", "ideas"} {
w.item(t, c, "AG "+c+" sched", `{"status":"open"}`, oneShot())
w.item(t, c, "AG "+c+" dated", `{"status":"open","due_date":"2031-03-10"}`, nil)
}
all := "date:AG ideas dated,date:AG secret dated,date:AG tasks dated,schedule:AG ideas sched,schedule:AG secret sched,schedule:AG tasks sched"
if got := seen(w.ok(t, w.owner, agendaWindow)); got != all {
t.Fatalf("owner sees %s, want %s", got, all)
}
member := mustUser(t, w.srv, "agenda-member@example.com", "agendamember", "")
if err := w.srv.store.AddWorkspaceMember(w.ws.ID, member.ID, "editor"); err != nil {
t.Fatal(err)
}
if err := w.srv.store.SetMemberCollectionAccess(w.ws.ID, member.ID, "specific", []string{w.colls["tasks"].ID}); err != nil {
t.Fatal(err)
}
if got, want := seen(w.ok(t, member, agendaWindow)), "date:AG tasks dated,schedule:AG tasks sched"; got != want {
t.Errorf("restricted member sees %s, want %s", got, want)
}
guest := mustUser(t, w.srv, "agenda-guest@example.com", "agendaguest", "")
for _, title := range []string{"AG ideas sched", "AG secret dated"} {
if _, err := w.srv.store.CreateItemGrant(w.ws.ID, w.items[title].ID, guest.ID, "view", w.owner.ID); err != nil {
t.Fatal(err)
}
}
m := w.ok(t, guest, agendaWindow)
if got, want := seen(m), "date:AG secret dated,schedule:AG ideas sched"; got != want {
t.Errorf("item-grant guest sees %s, want %s", got, want)
}
if _, has := m["unavailable"]; has {
t.Errorf("unavailable must not name anything for a guest here: %v", m["unavailable"])
}
cguest := mustUser(t, w.srv, "agenda-cguest@example.com", "agendacguest", "")
if _, err := w.srv.store.CreateCollectionGrant(w.ws.ID, w.colls["ideas"].ID, cguest.ID, "view", w.owner.ID); err != nil {
t.Fatal(err)
}
if got, want := seen(w.ok(t, cguest, agendaWindow)), "date:AG ideas dated,schedule:AG ideas sched"; got != want {
t.Errorf("collection-grant guest sees %s, want %s", got, want)
}
// A guest with no grant at all sees an empty agenda, not an error.
none := mustUser(t, w.srv, "agenda-none@example.com", "agendanone", "")
if err := w.srv.store.AddWorkspaceMember(w.ws.ID, none.ID, "viewer"); err != nil {
t.Fatal(err)
}
if err := w.srv.store.SetMemberCollectionAccess(w.ws.ID, none.ID, "specific", []string{}); err != nil {
t.Fatal(err)
}
if got := seen(w.ok(t, none, agendaWindow)); got != "" {
t.Errorf("a member with no collections sees %q, want nothing", got)
}
}
func TestAgendaWindowAndParameterRefusals(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.ok(t, w.owner, "from=2031-03-01T00:00:00Z&to=2031-06-01T00:00:00Z") // exactly 92 days
for name, c := range map[string]struct{ q, code, detail string }{
"92d + 1s": {"from=2031-03-01T00:00:00Z&to=2031-06-01T00:00:01Z", "validation_error", "cap_window"},
"missing from": {"to=2031-03-11T00:00:00Z", "validation_error", "required"},
"missing to": {"from=2031-03-10T00:00:00Z", "validation_error", "required"},
"bad from": {"from=yesterday&to=2031-03-11T00:00:00Z", "validation_error", "format"},
"bad to": {"from=2031-03-10T00:00:00Z&to=2031-03-11", "validation_error", "format"},
"to == from": {"from=2031-03-10T00:00:00Z&to=2031-03-10T00:00:00Z", "validation_error", "range"},
"to < from": {"from=2031-03-11T00:00:00Z&to=2031-03-10T00:00:00Z", "validation_error", "range"},
"tz Local": {agendaWindow + "&tz=Local", "invalid_tz", ""},
"tz unknown": {agendaWindow + "&tz=Nope/Zone", "invalid_tz", ""},
"tz empty": {agendaWindow + "&tz=", "invalid_tz", ""},
"tz blank": {agendaWindow + "&tz=%20", "invalid_tz", ""},
} {
code, m := w.get(t, w.owner, c.q)
e, _ := m["error"].(map[string]any)
if code != http.StatusBadRequest || e == nil || e["code"] != c.code {
t.Errorf("%s: %d %v, want 400 %s", name, code, m, c.code)
continue
}
if d, _ := e["details"].(map[string]any); c.detail != "" && (d == nil || d["code"] != c.detail) {
t.Errorf("%s: details = %v, want code %s", name, e["details"], c.detail)
}
}
}
func TestAgendaTimezoneDefaultsToTheCaller(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG tz", `{"status":"open"}`, oneShot())
m := w.ok(t, w.owner, agendaWindow)
if m["tz"] != "UTC" || m["timezone_note"] == nil || m["timezone_note"] == "" {
t.Errorf("no account zone: tz %v note %v, want UTC with a note", m["tz"], m["timezone_note"])
}
la := "America/Los_Angeles"
if _, err := w.srv.store.UpdateUser(w.owner.ID, models.UserUpdate{Timezone: &la}); err != nil {
t.Fatal(err)
}
m = w.ok(t, w.owner, agendaWindow)
if m["tz"] != la || m["timezone_note"] != nil {
t.Errorf("account zone set: tz %v note %v, want %s and no note", m["tz"], m["timezone_note"], la)
}
if m = w.ok(t, w.owner, agendaWindow+"&tz=Asia/Tokyo"); m["tz"] != "Asia/Tokyo" || m["timezone_note"] != nil {
t.Errorf("explicit tz: %v %v", m["tz"], m["timezone_note"])
}
}
func TestAgendaRendersPinnedAndFloatingInTheCallersZone(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG pinned", `{"status":"open"}`, &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-10T09:00", Tz: "Asia/Tokyo"})
w.item(t, "tasks", "AG floating", `{"status":"open"}`, &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-10T09:00", Floating: true})
la, _ := time.LoadLocation("America/Los_Angeles")
q := "from=2031-03-09T00:00:00Z&to=2031-03-12T00:00:00Z&tz=America/Los_Angeles"
byTitle := map[string]map[string]any{}
for _, e := range entriesOf(w.ok(t, w.owner, q)) {
byTitle[e["item_title"].(string)] = e
}
pinnedInstant := time.Date(2031, 3, 10, 0, 0, 0, 0, time.UTC).In(la) // 09:00 Tokyo
if e := byTitle["AG pinned"]; e == nil || e["at"] != pinnedInstant.Format(time.RFC3339) || e["day"] != pinnedInstant.Format("2006-01-02") || e["floating"] != false {
t.Errorf("pinned entry = %v, want the Tokyo instant rendered in LA (%s)", e, pinnedInstant.Format(time.RFC3339))
}
wall := time.Date(2031, 3, 10, 9, 0, 0, 0, la)
if e := byTitle["AG floating"]; e == nil || e["at"] != wall.Format(time.RFC3339) || e["day"] != "2031-03-10" || e["floating"] != true {
t.Errorf("floating entry = %v, want 09:00 LA (%s)", e, wall.Format(time.RFC3339))
}
}
func TestAgendaAllDay(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG allday", `{"status":"open"}`, &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-10", Tz: "Asia/Tokyo"})
e := entriesOf(w.ok(t, w.owner, "from=2031-03-09T00:00:00Z&to=2031-03-12T00:00:00Z&tz=Asia/Tokyo"))
if len(e) != 1 || e[0]["at"] != "2031-03-10T00:00:00+09:00" || e[0]["all_day"] != true || e[0]["day"] != "2031-03-10" || e[0]["wall"] != "2031-03-10" {
t.Fatalf("all-day in Tokyo = %v, want local midnight, all_day, day 2031-03-10", e)
}
la, _ := time.LoadLocation("America/Los_Angeles")
e = entriesOf(w.ok(t, w.owner, "from=2031-03-08T00:00:00Z&to=2031-03-12T00:00:00Z&tz=America/Los_Angeles"))
want := time.Date(2031, 3, 9, 15, 0, 0, 0, time.UTC).In(la).Format(time.RFC3339) // Tokyo midnight, same instant
if len(e) != 1 || e[0]["at"] != want || e[0]["day"] != "2031-03-10" {
t.Fatalf("all-day viewed in LA = %v, want the same instant (%s) on the wall day 2031-03-10", e, want)
}
}
func TestAgendaOneRunawayScheduleDoesNotTakeItDown(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG normal", `{"status":"open"}`, oneShot())
w.item(t, "tasks", "AG runaway", `{"status":"open"}`, &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-01T00:00", Tz: "UTC", Rrule: "FREQ=MINUTELY"})
broken := w.item(t, "tasks", "AG broken", `{"status":"open"}`, oneShot())
if _, err := w.srv.store.DB().Exec(w.srv.store.D().Rebind(`UPDATE item_schedules SET error_code = 'cap_scan' WHERE item_id = ?`), broken.ID); err != nil {
t.Fatal(err)
}
code, m := w.get(t, w.owner, "from=2031-03-01T00:00:00Z&to=2031-05-30T00:00:00Z")
if code != http.StatusOK {
t.Fatalf("one runaway schedule must not fail the agenda: %d %v", code, m)
}
runawayRef := fmt.Sprintf("P0-%d", *w.items["AG runaway"].ItemNumber)
var normal, runaway int
for _, e := range entriesOf(m) {
switch e["item_title"] {
case "AG normal":
normal++
case "AG runaway":
runaway++
case "AG broken":
t.Errorf("a broken schedule has no entries: %v", e)
}
}
if normal != 1 {
t.Errorf("the healthy schedule is still listed once, got %d", normal)
}
un, _ := m["unavailable"].([]any)
var names []string
for _, u := range un {
names = append(names, fmt.Sprint(u))
if um, ok := u.(map[string]any); ok && um["item_ref"] == runawayRef {
t.Errorf("the runaway is listed under unavailable (%v); the code gives it a capped listing", um)
}
}
if len(un) != 1 || !strings.Contains(strings.Join(names, ";"), "cap_scan") {
t.Errorf("unavailable = %v, want only the broken schedule with cap_scan", un)
}
// The one outcome for a MINUTELY rule over the window: Between hits the
// instance cap with partial occurrences, and the entries are cut to the
// per-schedule cap, flagged truncated.
if runaway != 100 || m["truncated"] != true {
t.Errorf("the runaway (%s) lists %d entries, truncated=%v; want exactly 100 and truncated", runawayRef, runaway, m["truncated"])
}
}
func TestAgendaHidesDoneItems(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG open sched", `{"status":"open"}`, oneShot())
w.item(t, "tasks", "AG done sched", `{"status":"done"}`, oneShot())
w.item(t, "tasks", "AG open dated", `{"status":"open","due_date":"2031-03-10"}`, nil)
w.item(t, "tasks", "AG done dated", `{"status":"done","due_date":"2031-03-10"}`, nil)
if got, want := seen(w.ok(t, w.owner, agendaWindow)), "date:AG open dated,schedule:AG open sched"; got != want {
t.Errorf("agenda = %s, want %s", got, want)
}
}
func TestAgendaDatedItems(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG day", `{"status":"open","due_date":"2031-03-10"}`, nil)
w.item(t, "tasks", "AG stamp", `{"status":"open","end_date":"2031-03-10T23:30:00-08:00"}`, nil)
w.item(t, "tasks", "AG outside", `{"status":"open","due_date":"2031-03-12"}`, nil)
w.item(t, "tasks", "AG old", `{"status":"open","due_date":"2020-01-01"}`, nil)
e := entriesOf(w.ok(t, w.owner, agendaWindow))
if len(e) != 2 {
t.Fatalf("entries = %v, want the two dated items inside the window", e)
}
for _, x := range e {
if x["type"] != "date" || x["day"] != "2031-03-10" || x["overdue"] != nil {
t.Errorf("dated entry = %v", x)
}
switch x["item_title"] {
case "AG day":
if x["field"] != "due_date" || x["date"] != "2031-03-10" {
t.Errorf("day entry = %v", x)
}
case "AG stamp":
if x["field"] != "end_date" || x["date"] != "2031-03-10T23:30:00-08:00" {
t.Errorf("stamp entry = %v", x)
}
default:
t.Errorf("unexpected %v", x)
}
}
// Past dates are overdue; an item before `from` is never pulled in.
e = entriesOf(w.ok(t, w.owner, "from=2020-01-01T00:00:00Z&to=2020-01-02T00:00:00Z"))
if len(e) != 1 || e[0]["item_title"] != "AG old" || e[0]["overdue"] != true {
t.Errorf("past window = %v, want AG old flagged overdue", e)
}
if got := seen(w.ok(t, w.owner, "from=2031-03-11T00:00:00Z&to=2031-03-12T00:00:00Z")); got != "" {
t.Errorf("nothing is pulled in from before from: %q", got)
}
}
func TestAgendaOrderIsDeterministic(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG b", `{"status":"open","due_date":"2031-03-10"}`, oneShot())
w.item(t, "tasks", "AG a", `{"status":"open","due_date":"2031-03-10"}`, oneShot())
var order []string
for _, e := range entriesOf(w.ok(t, w.owner, agendaWindow)) {
order = append(order, fmt.Sprintf("%v:%v", e["type"], e["item_ref"]))
}
if len(order) != 4 {
t.Fatalf("entries = %v", order)
}
// By time, then schedule before date, then ref.
if !(strings.HasPrefix(order[0], "date:") && strings.HasPrefix(order[1], "date:") && strings.HasPrefix(order[2], "schedule:") && strings.HasPrefix(order[3], "schedule:")) ||
order[0] > order[1] || order[2] > order[3] {
t.Errorf("order = %v, want the 00:00 dates first (by ref), then the 09:00 schedules (by ref)", order)
}
}
// hourlyWindow holds 120 occurrences of an HOURLY schedule.
const hourlyWindow = "from=2031-03-10T00:00:00Z&to=2031-03-15T00:00:00Z" // 120 hours
func hourly() *models.ScheduleCreate {
return &models.ScheduleCreate{Kind: "check", Dtstart: "2031-03-01T00:00", Tz: "UTC", Rrule: "FREQ=HOURLY"}
}
func TestAgendaPerScheduleCutIs100(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG hourly", `{"status":"open"}`, hourly())
m := w.ok(t, w.owner, hourlyWindow)
if n := len(entriesOf(m)); n != 100 || m["truncated"] != true {
t.Fatalf("120 occurrences: %d entries, truncated=%v, want 100 and truncated", n, m["truncated"])
}
}
func TestAgendaResponseCutIs500(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
for i := 0; i < 6; i++ {
w.item(t, "tasks", fmt.Sprintf("AG hourly %d", i), `{"status":"open"}`, hourly())
}
m := w.ok(t, w.owner, hourlyWindow)
if n := len(entriesOf(m)); n != 500 || m["truncated"] != true {
t.Fatalf("600 entries: %d returned, truncated=%v, want 500 and truncated", n, m["truncated"])
}
}
// r1-1: the scan bound must not be spent on schedules that cannot appear. 1001
// ended one-shots precede (by id) one live schedule; it is still listed.
func TestAgendaScanBoundIsNotSpentOnEndedSchedules(t *testing.T) {
t.Parallel()
scanBoundHolds(t, false)
}
// r2-1 (a pin): the same when the ended schedules' only history lies AFTER the
// window, which the history lookup must not count as reaching it.
func TestAgendaScanBoundIsNotSpentOnEndedSchedulesWithLaterHistory(t *testing.T) {
t.Parallel()
scanBoundHolds(t, true)
}
func scanBoundHolds(t *testing.T, laterHistory bool) {
t.Helper()
w := newAgendaWorld(t, "tasks")
for i := 0; i < 1002; i++ {
w.item(t, "tasks", fmt.Sprintf("AG s%04d", i), `{"status":"open"}`, oneShot())
}
db, d := w.srv.store.DB(), w.srv.store.D()
var liveItem, liveID string
if err := db.QueryRow(`SELECT item_id, id FROM item_schedules WHERE workspace_id = '`+w.ws.ID+`' ORDER BY id DESC LIMIT 1`).Scan(&liveItem, &liveID); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(d.Rebind(`UPDATE item_schedules SET ended_at = '2031-01-01T00:00:00Z', next_at = NULL WHERE workspace_id = ? AND id <> ?`), w.ws.ID, liveID); err != nil {
t.Fatal(err)
}
if laterHistory {
if _, err := db.Exec(d.Rebind(`INSERT INTO schedule_occurrences (id, schedule_id, occurrence_at, occurrence_wall, state, actor, resolved_at)
SELECT 'o' || id, id, '2031-06-01T09:00:00Z', '2031-06-01T09:00', 'done', 'user', '2031-01-01T00:00:00Z'
FROM item_schedules WHERE workspace_id = ? AND id <> ?`), w.ws.ID, liveID); err != nil {
t.Fatal(err)
}
}
m := w.ok(t, w.owner, agendaWindow)
var found bool
for _, e := range entriesOf(m) {
found = found || e["schedule_id"] == liveID
}
if !found || m["truncated"] == true {
t.Fatalf("the live schedule with the highest id is listed=%v, truncated=%v; want listed and not truncated (%d entries)", found, m["truncated"], len(entriesOf(m)))
}
}
// r2-1: marking a deadline done EARLY ends the schedule at the resolve time,
// while its history row keeps the due instant. The window around the due
// instant still lists it, as done.
func TestAgendaListsAnEarlyResolvedScheduleAsDone(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
it := w.item(t, "tasks", "AG early", `{"status":"open"}`, oneShot())
if _, err := w.srv.store.ResolveOccurrence(w.ws.ID, it.ID, store.ResolveInput{OccurrenceAt: time.Date(2031, 3, 10, 9, 0, 0, 0, time.UTC), State: "done", Actor: "user"}); err != nil {
t.Fatalf("resolve early: %v", err)
}
m := w.ok(t, w.owner, agendaWindow)
es := entriesOf(m)
if len(es) != 1 || es[0]["item_title"] != "AG early" || es[0]["state"] != "done" {
t.Fatalf("entries = %v, want the early-resolved schedule listed as done", es)
}
}
// r2-2: a database failure while expanding is a 500, not an incomplete 200.
func TestAgendaDatabaseFailureDuringExpansionIs500(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG db", `{"status":"open"}`, oneShot())
// Rename a column only agendaOccurrences selects: the list query (its EXISTS
// reads schedule_id and occurrence_at) still succeeds, so the 500 can only
// come from ExpandAgenda's history read. Valid on SQLite and Postgres.
if _, err := w.srv.store.DB().Exec(`ALTER TABLE schedule_occurrences RENAME COLUMN actor TO actor_renamed`); err != nil {
t.Fatal(err)
}
if code, m := w.get(t, w.owner, agendaWindow); code != http.StatusInternalServerError {
t.Fatalf("a failing history read gave %d %v, want 500", code, m)
}
}
// r1-2: one schedule whose stored state cannot be read is that schedule's
// problem, not a 500 for the agenda.
func TestAgendaCorruptScheduleIsUnavailableNotA500(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks")
w.item(t, "tasks", "AG good one", `{"status":"open"}`, oneShot())
bad := w.item(t, "tasks", "AG corrupt", `{"status":"open"}`, oneShot())
w.item(t, "tasks", "AG good two", `{"status":"open"}`, oneShot())
if _, err := w.srv.store.DB().Exec(w.srv.store.D().Rebind(`UPDATE item_schedules SET next_at = 'not-an-instant' WHERE item_id = ?`), bad.ID); err != nil {
t.Fatal(err)
}
code, m := w.get(t, w.owner, agendaWindow)
if code != http.StatusOK {
t.Fatalf("a corrupt schedule gave %d %v, want 200", code, m)
}
if got, want := seen(m), "schedule:AG good one,schedule:AG good two"; got != want {
t.Errorf("entries = %s, want %s", got, want)
}
un, _ := m["unavailable"].([]any)
wantRef := fmt.Sprintf("P0-%d", *bad.ItemNumber)
if len(un) != 1 || fmt.Sprint(un[0]) != fmt.Sprintf("map[code:internal item_ref:%s]", wantRef) {
t.Errorf("unavailable = %v, want the corrupt schedule (%s) with code internal", un, wantRef)
}
}
// A read-scoped app token sees what its item list shows, no more.
func TestAgendaAppTokenCeilingMatchesItemList(t *testing.T) {
t.Parallel()
w := newAgendaWorld(t, "tasks", "secret")
for _, c := range []string{"tasks", "secret"} {
w.item(t, c, "AG "+c+" sched", `{"status":"open"}`, oneShot())
w.item(t, c, "AG "+c+" dated", `{"status":"open","due_date":"2031-03-10"}`, nil)
}
member := mustUser(t, w.srv, "agenda-app@example.com", "agendaapp", "")
if err := w.srv.store.AddWorkspaceMember(w.ws.ID, member.ID, "editor"); err != nil {
t.Fatal(err)
}
call := func(ceiling []string, h http.HandlerFunc, target string) []byte {
t.Helper()
req := httptest.NewRequest("GET", target, nil)
ctx := WithCurrentUser(req.Context(), member)
ctx = WithAPITokenAuth(ctx)
ctx = context.WithValue(ctx, ctxResolvedWorkspaceID, w.ws.ID)
ctx = context.WithValue(ctx, ctxWorkspaceRole, "editor")
if ceiling != nil {
ctx = context.WithValue(ctx, appCtxKey{}, &appContext{WorkspaceID: w.ws.ID, ReadCeiling: ceiling})
}
rr := httptest.NewRecorder()
h(rr, req.WithContext(ctx))
if rr.Code != http.StatusOK {
t.Fatalf("%s: %d %s", target, rr.Code, rr.Body.String())
}
return rr.Body.Bytes()
}
titles := func(ceiling []string) (agenda, list string) {
var a map[string]any
_ = json.Unmarshal(call(ceiling, w.srv.handleGetAgenda, "/x?"+agendaWindow), &a)
seenTitles := map[string]bool{}
for _, e := range entriesOf(a) {
seenTitles[fmt.Sprint(e["item_title"])] = true
}
var items []models.Item
_ = json.Unmarshal(call(ceiling, w.srv.handleListItems, "/x"), &items)
listTitles := map[string]bool{}
for _, it := range items {
listTitles[it.Title] = true
}
join := func(m map[string]bool) string {
var out []string
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, ",")
}
return join(seenTitles), join(listTitles)
}
if a, l := titles(nil); a != l || !strings.Contains(a, "AG secret sched") {
t.Fatalf("control, no ceiling: agenda %q, list %q; want both to show everything", a, l)
}
a, l := titles([]string{w.colls["tasks"].ID})
if a != l || a != "AG tasks dated,AG tasks sched" {
t.Errorf("ceiling on tasks: agenda %q, list %q, want both AG tasks dated,AG tasks sched", a, l)
}
}
+1
View File
@@ -2555,6 +2555,7 @@ func (s *Server) setupRouter() {
r.Post("/reminders/{reminderID}/ack", s.handleAckReminder)
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
r.Get("/dashboard", s.handleGetDashboard)
r.Get("/agenda", s.handleGetAgenda) // TASK-4104: schedules + dated items in a window
// Workspace graph — {nodes, edges} for the 3D
// graph view (PLAN-1730 / TASK-1731). Active
+1
View File
@@ -82,6 +82,7 @@ compress /api/v1/workspaces/deleted
compress /api/v1/workspaces/import-status
compress /api/v1/workspaces/{slug}/
compress /api/v1/workspaces/{slug}/activity
compress /api/v1/workspaces/{slug}/agenda
compress /api/v1/workspaces/{slug}/agent-roles/
compress /api/v1/workspaces/{slug}/agent-roles/{roleID}/
compress /api/v1/workspaces/{slug}/agent/bootstrap
+15
View File
@@ -2455,6 +2455,21 @@ func (s *Store) appendFieldFilters(workspaceID string, params models.ItemListPar
if key := params.FieldKeyPresent; key != "" && isValidFieldKey(key) {
query += " AND " + s.dialect.JSONExtractText("i.fields", key) + " IS NOT NULL"
}
if win := params.FieldDateWindow; win != nil {
// A date field is `YYYY-MM-DD` or RFC3339; the first ten characters are
// the day in the value's own offset on both shapes and both dialects.
var conds []string
for _, key := range win.Keys {
if isValidFieldKey(key) {
conds = append(conds, "substr("+s.dialect.JSONExtractText("i.fields", key)+", 1, 10) BETWEEN ? AND ?")
args = append(args, win.From, win.To)
}
}
if len(conds) == 0 {
conds = []string{"1 = 0"} // no key to match is no match, not no filter
}
query += " AND (" + strings.Join(conds, " OR ") + ")"
}
return query, args
}
+62
View File
@@ -0,0 +1,62 @@
package store
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-4104 D1: the date-window clause of ListItems. SQLite in make test,
// Postgres in make test-pg (the one dialect-sensitive piece of the agenda).
func TestListItemsFieldDateWindow(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "Dated")
col, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks", Schema: `{"fields":[` +
`{"key":"status","type":"select","options":["open","done"],"default":"open"},` +
`{"key":"due_date","type":"date"},{"key":"end_date","type":"date"}]}`})
if err != nil {
t.Fatal(err)
}
mk := func(title, fields string) {
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: title, Fields: fields}); err != nil {
t.Fatalf("CreateItem(%s): %v", title, err)
}
}
mk("day first", `{"status":"open","due_date":"2026-11-05"}`)
mk("offset shape", `{"status":"open","due_date":"2026-11-06T23:30:00-08:00"}`)
mk("end key z", `{"status":"open","end_date":"2026-11-06T01:00:00Z"}`)
mk("before", `{"status":"open","due_date":"2026-11-04"}`)
mk("after", `{"status":"open","due_date":"2026-11-07"}`)
mk("done", `{"status":"done","due_date":"2026-11-05"}`)
mk("no date", `{"status":"open"}`)
list := func(scope []string) []string {
items, err := s.ListItems(ws.ID, models.ItemListParams{
FieldDateWindow: &models.FieldDateWindow{Keys: []string{"due_date", "end_date"}, From: "2026-11-05", To: "2026-11-06"},
NonTerminal: true, NoContent: true, CollectionIDs: scope,
})
if err != nil {
t.Fatal(err)
}
var out []string
for _, it := range items {
out = append(out, it.Title)
}
return out
}
got := list(nil)
want := map[string]bool{"day first": true, "offset shape": true, "end key z": true}
if len(got) != len(want) {
t.Fatalf("window 2026-11-05..2026-11-06 = %v, want exactly %v (inclusive bounds, both shapes, both keys, done and undated out)", got, want)
}
for _, g := range got {
if !want[g] {
t.Errorf("unexpected %q in %v", g, got)
}
}
if got := list([]string{}); len(got) != 0 {
t.Errorf("an empty scope sees nothing, got %v", got)
}
}
+29 -25
View File
@@ -364,6 +364,32 @@ type PendingReminderScope struct {
ItemIDs []string
}
// scopeClause is the visibility predicate on the item alias, as " AND ..." (empty
// when scope is unrestricted). The same three-way shape as models.ItemListParams:
// a guest may hold collection-level grants, item-level grants, or both, and
// "both" is an OR rather than an AND, so an item in a fully granted collection
// qualifies even when it is not individually granted. One predicate for every
// reader that must agree on who sees an item (pending reminders, the agenda).
func scopeClause(alias string, scope PendingReminderScope) (string, []any) {
var args []any
for _, ids := range [][]string{scope.CollectionIDs, scope.ItemIDs} {
for _, id := range ids {
args = append(args, id)
}
}
coll := alias + ".collection_id IN (" + placeholders(len(scope.CollectionIDs)) + ")"
item := alias + ".id IN (" + placeholders(len(scope.ItemIDs)) + ")"
switch {
case len(scope.CollectionIDs) > 0 && len(scope.ItemIDs) > 0:
return " AND (" + coll + " OR " + item + ")", args
case len(scope.CollectionIDs) > 0:
return " AND " + coll, args
case len(scope.ItemIDs) > 0:
return " AND " + item, args
}
return "", nil
}
// The window is BOUNDED because this list feeds a payload that is otherwise
// capped: every pending reminder became a suggestion prepended to a
// three-entry list, so a workspace with five hundred unacknowledged reminders
@@ -415,31 +441,9 @@ func (s *Store) ListPendingReminders(workspaceID string, scope PendingReminderSc
AND w.deleted_at IS NULL`
args := []any{workspaceID}
// Same three-way shape as models.ItemListParams: a guest may hold
// collection-level grants, item-level grants, or both, and "both" is an OR
// rather than an AND — an item in a fully granted collection qualifies
// even when it is not individually granted.
switch {
case len(scope.CollectionIDs) > 0 && len(scope.ItemIDs) > 0:
query += " AND (i.collection_id IN (" + placeholders(len(scope.CollectionIDs)) +
") OR i.id IN (" + placeholders(len(scope.ItemIDs)) + "))"
for _, id := range scope.CollectionIDs {
args = append(args, id)
}
for _, id := range scope.ItemIDs {
args = append(args, id)
}
case len(scope.CollectionIDs) > 0:
query += " AND i.collection_id IN (" + placeholders(len(scope.CollectionIDs)) + ")"
for _, id := range scope.CollectionIDs {
args = append(args, id)
}
case len(scope.ItemIDs) > 0:
query += " AND i.id IN (" + placeholders(len(scope.ItemIDs)) + ")"
for _, id := range scope.ItemIDs {
args = append(args, id)
}
}
clause, scopeArgs := scopeClause("i", scope)
query += clause
args = append(args, scopeArgs...)
query += " ORDER BY r.fired_at, r.id LIMIT ? OFFSET ?"
args = append(args, limit+1, offset)
+308
View File
@@ -0,0 +1,308 @@
package store
import (
"errors"
"fmt"
"log/slog"
"sort"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/schedule"
)
// The workspace agenda's store half (TASK-4104 D1): which schedules a caller
// may see, and one schedule's occurrences in a window with their state.
// AgendaMaxPerSchedule bounds one schedule's entries, so a runaway rule (a
// stored MINUTELY) cannot crowd every other schedule out of the response.
const AgendaMaxPerSchedule = 100
// AgendaSchedule is a schedule with the facts of its item the agenda shows.
type AgendaSchedule struct {
Schedule *models.ItemSchedule
ItemRef string
ItemTitle string
Collection string // slug
}
// ListSchedulesForAgenda returns the schedules of the open, live items scope
// allows, by id, and whether there were more than limit. The scope and the
// terminal filter are applied in SQL, BEFORE the bound, so rows the caller
// cannot see, that are done, or that ended before from with no history row near
// [from, to) (an early resolve ends a schedule before its due instant) never
// push a visible schedule past it.
func (s *Store) ListSchedulesForAgenda(workspaceID string, scope PendingReminderScope, from, to time.Time, limit int) ([]AgendaSchedule, bool, error) {
if scope.CollectionIDs != nil && len(scope.CollectionIDs) == 0 && len(scope.ItemIDs) == 0 {
return nil, false, nil
}
scopeSQL, scopeArgs := scopeClause("i", scope)
open, openArgs := s.nonTerminalFilter(workspaceID, "i")
f := time.RFC3339
args := append(append([]any{workspaceID, from.UTC().Format(f), from.UTC().Add(-36 * time.Hour).Format(f), to.UTC().Add(36 * time.Hour).Format(f), workspaceID}, scopeArgs...), openArgs...)
// item_id IN (...), not a join: scheduleColumns names item_schedules.id.
rows, err := s.db.Query(s.q(`SELECT `+scheduleColumns+` FROM item_schedules
WHERE workspace_id = ? AND (ended_at IS NULL OR ended_at >= ? OR EXISTS (SELECT 1 FROM schedule_occurrences o
WHERE o.schedule_id = item_schedules.id AND o.occurrence_at >= ? AND o.occurrence_at < ?)) AND item_id IN (
SELECT i.id FROM items i JOIN collections c ON c.id = i.collection_id
WHERE i.workspace_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL`+scopeSQL+` AND `+open+`)
ORDER BY item_schedules.id LIMIT ?`), append(args, limit+1)...)
if err != nil {
return nil, false, fmt.Errorf("list schedules for agenda: %w", err)
}
defer rows.Close()
var out []AgendaSchedule
for rows.Next() {
sc, err := scanSchedule(rows)
if err != nil {
return nil, false, fmt.Errorf("scan agenda schedule: %w", err)
}
out = append(out, AgendaSchedule{Schedule: sc})
}
if err := rows.Err(); err != nil {
return nil, false, err
}
truncated := len(out) > limit
if truncated {
out = out[:limit]
}
if len(out) == 0 {
return nil, false, nil
}
ids, byID := make([]any, len(out)), map[string]*AgendaSchedule{}
for i := range out {
ids[i], byID[out[i].Schedule.ItemID] = out[i].Schedule.ItemID, &out[i]
}
irows, err := s.db.Query(s.q(`SELECT i.id, i.title, i.slug, c.slug, c.prefix, i.item_number
FROM items i JOIN collections c ON c.id = i.collection_id WHERE i.id IN (`+placeholders(len(ids))+`)`), ids...)
if err != nil {
return nil, false, fmt.Errorf("read agenda items: %w", err)
}
defer irows.Close()
for irows.Next() {
var id, title, slug, coll, prefix string
var number *int64
if err := irows.Scan(&id, &title, &slug, &coll, &prefix, &number); err != nil {
return nil, false, fmt.Errorf("scan agenda item: %w", err)
}
a := byID[id]
a.ItemTitle, a.Collection, a.ItemRef = title, coll, slug // a legacy item has no ref
if number != nil {
a.ItemRef = fmt.Sprintf("%s-%d", prefix, *number)
}
}
return out, truncated, irows.Err()
}
// AgendaEntry is one occurrence on the agenda. At is the grid instant, in the
// view's zone for a floating schedule; EffectiveAt is where a MOVE put it.
type AgendaEntry struct {
At time.Time
Wall schedule.Wall
State string // outstanding | scheduled | done | skipped | moved | missed | unrecorded
Fired bool
Overdue bool
EffectiveAt *time.Time
ResolvedAt string
Actor string
CommentID string
MissedCount int // the outstanding entry only
Stale bool
}
type agendaRow struct{ at, wall, state, commentID, actor, resolvedAt string }
// agendaOccurrences reads a schedule's history rows around [from, to), padded a
// day and a half each way because a floating row is the recipient's reading, plus
// its moved rows, which can sit anywhere.
func (s *Store) agendaOccurrences(scheduleID string, from, to time.Time) ([]agendaRow, error) {
const f = "2006-01-02T15:04:05Z"
rows, err := s.db.Query(s.q(`SELECT occurrence_at, occurrence_wall, state, COALESCE(comment_id, ''), actor, resolved_at
FROM schedule_occurrences WHERE schedule_id = ? AND ((occurrence_at >= ? AND occurrence_at < ?) OR state = 'moved')`),
scheduleID, from.UTC().Add(-36*time.Hour).Format(f), to.UTC().Add(36*time.Hour).Format(f))
if err != nil {
return nil, fmt.Errorf("list agenda occurrences: %w", err)
}
defer rows.Close()
var out []agendaRow
for rows.Next() {
var r agendaRow
if err := rows.Scan(&r.at, &r.wall, &r.state, &r.commentID, &r.actor, &r.resolvedAt); err != nil {
return nil, fmt.Errorf("scan agenda occurrence: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// ExpandAgenda lists sc's occurrences with from <= At < to, plus its outstanding
// occurrence when a move placed it in the window. view is the zone a floating
// schedule is read in; a pinned one ignores it.
//
// unavailable is a stable code when the schedule cannot be expanded (a stored
// error_code, a rule that no longer compiles, a cap, `internal` for stored state
// that will not parse): one such schedule is a per-schedule outcome, never an
// error for the whole agenda. err is a database failure only. truncated says
// more existed than the entries show.
//
// A history row is matched by the WALL for a floating or all-day schedule (a
// resolve stores the recipient's reading, and the same wall is another instant
// in the caller's zone) and by the INSTANT for a pinned timed one, whose elapsed
// rules can repeat a wall.
func (s *Store) ExpandAgenda(sc *models.ItemSchedule, view *time.Location, from, to, now time.Time) (entries []AgendaEntry, truncated bool, unavailable string, err error) {
if sc.ErrorCode != nil {
return nil, false, *sc.ErrorCode, nil
}
start := sc.Dtstart
if sc.AnchorWall != nil {
start = *sc.AnchorWall // a re-phased rule never walks from dtstart
}
rows, err := s.agendaOccurrences(sc.ID, from, to)
if err != nil {
return nil, false, "", err
}
c, err := compileScheduleSpec(storedSpecInput(sc, start))
if err == nil {
entries, truncated, err = s.expandCompiled(c, sc, rows, view, from, to, now)
}
if code := ScheduleErrorCode(err); code != "" {
return nil, false, code, nil
}
if err != nil {
slog.Warn("agenda: schedule unreadable", "schedule_id", sc.ID, "error_class", fmt.Sprintf("%T", err))
return nil, false, "internal", nil
}
if len(entries) > AgendaMaxPerSchedule {
entries, truncated = entries[:AgendaMaxPerSchedule], true
}
return entries, truncated, "", nil
}
func (s *Store) expandCompiled(c *schedule.Compiled, sc *models.ItemSchedule, rows []agendaRow, view *time.Location, from, to, now time.Time) ([]AgendaEntry, bool, error) {
loc := view
if !sc.Floating {
var err error
if loc, err = time.LoadLocation(c.Spec().Zone); err != nil {
return nil, false, fmt.Errorf("load schedule zone: %w", err)
}
}
lo := from
if floor := schedule.Resolve(loc, c.Spec().Start); floor.After(lo) {
lo = floor // nothing before the walk's own start
}
truncated := false
occs, err := c.Between(lo, to, view, schedule.DefaultLimits)
var capErr *schedule.CapError
if errors.As(err, &capErr) && capErr.Kind == schedule.CapInstances {
truncated, err = true, nil
}
if err != nil {
return nil, false, err
}
entries, err := s.agendaEntries(sc, occs, rows, view, loc, lo, from, to, now)
return entries, truncated, err
}
// agendaEntries states each occurrence. loc is the zone the schedule's walls
// resolve in and lo where the walk began (the later of from and the rule's start,
// which a re-anchor moves up): history rows between from and lo are listed from
// the rows themselves, since the walk does not reach them.
func (s *Store) agendaEntries(sc *models.ItemSchedule, occs []schedule.Occurrence, rows []agendaRow, view, loc *time.Location, lo, from, to, now time.Time) ([]AgendaEntry, error) {
byKey := map[string]agendaRow{}
key := func(at time.Time, w schedule.Wall) string {
if sc.Floating || w.AllDay {
return w.String()
}
return at.UTC().Format(time.RFC3339)
}
for _, r := range rows {
if sc.Floating || len(r.wall) == 10 {
byKey[r.wall] = r
} else {
byKey[r.at] = r
}
}
var o time.Time
var hasO bool
var err error
if sc.EndedAt == nil {
if o, hasO, err = walkOrigin(sc, view); err != nil {
return nil, err
}
}
isO := func(at time.Time, w schedule.Wall) bool {
if sc.NextWall == nil || !hasO {
return false
}
if sc.Floating || w.AllDay {
return w.String() == *sc.NextWall
}
return at.Equal(o)
}
var effective *time.Time
outstanding := func(e *AgendaEntry, r agendaRow, have bool) {
e.State, e.Fired = "outstanding", sc.ReminderFired
due := e.At
if have && r.state == "moved" && sc.NextOverrideAt != nil {
if t, err := time.Parse(time.RFC3339, *sc.NextOverrideAt); err == nil {
e.EffectiveAt, due = &t, t
effective = &t
}
}
e.Overdue = !due.After(now)
if n, _, err := ScheduleMissed(sc, view, now); err == nil {
e.MissedCount, e.Stale = n, n >= 2
}
}
var out []AgendaEntry
seenO := false
for _, occ := range occs {
e := AgendaEntry{At: occ.At, Wall: occ.Wall}
r, have := byKey[key(occ.At, occ.Wall)]
if have {
e.State, e.ResolvedAt, e.Actor, e.CommentID = r.state, r.resolvedAt, r.actor, r.commentID
}
switch {
case isO(occ.At, occ.Wall):
seenO = true
outstanding(&e, r, have)
case have:
case hasO && occ.At.After(o) && !occ.At.After(now.Add(-time.Second)):
e.State = "missed" // derived, as missed_count counts it
case hasO && occ.At.After(o):
e.State = "scheduled"
default:
e.State = "unrecorded" // never invent a state
}
out = append(out, e)
}
if !seenO && hasO && sc.NextWall != nil {
w, err := schedule.ParseWall(*sc.NextWall)
if err != nil {
return nil, fmt.Errorf("parse next_wall: %w", err)
}
e := AgendaEntry{At: o, Wall: w}
r, have := byKey[key(o, w)]
if have {
e.ResolvedAt, e.Actor, e.CommentID = r.resolvedAt, r.actor, r.commentID
}
if outstanding(&e, r, have); effective != nil && !effective.Before(from) && effective.Before(to) {
out = append(out, e)
}
}
for _, r := range rows {
w, err := schedule.ParseWall(r.wall)
if err != nil || !lo.After(from) || (sc.NextWall != nil && r.wall == *sc.NextWall) {
continue
}
at, err := time.Parse(time.RFC3339, r.at)
if sc.Floating || w.AllDay {
at, err = schedule.Resolve(loc, w), nil
}
if err == nil && !at.Before(from) && at.Before(lo) && at.Before(to) {
out = append(out, AgendaEntry{At: at, Wall: w, State: r.state, ResolvedAt: r.resolvedAt, Actor: r.actor, CommentID: r.commentID})
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out, nil
}
+280
View File
@@ -0,0 +1,280 @@
package store
import (
"fmt"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-4104 D1: the agenda's expansion and its date-window clause. SQLite in
// make test, Postgres in make test-pg.
// agendaStates maps an entry's wall to its state, for a compact assertion.
func agendaStates(es []AgendaEntry) map[string]string {
out := map[string]string{}
for _, e := range es {
out[e.Wall.String()] = e.State
}
return out
}
func (f *schedFixture) expand(t *testing.T, item *models.Item, view *time.Location, from, to, now string) []AgendaEntry {
t.Helper()
sc, err := f.s.GetSchedule(f.ws.ID, item.ID)
if err != nil || sc == nil {
t.Fatalf("GetSchedule: %v %v", sc, err)
}
es, _, unavailable, err := f.s.ExpandAgenda(sc, view, rt(from), rt(to), rt(now))
if err != nil || unavailable != "" {
t.Fatalf("ExpandAgenda: unavailable=%q err=%v", unavailable, err)
}
return es
}
func TestAgendaExpandMergesOccurrenceState(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Tz: "UTC", Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
f.mustResolve(t, item, ResolveInput{OccurrenceAt: rt("2026-10-01T09:00:00Z"), State: "done", Actor: "user"}, "2026-10-01T10:00:00Z")
f.mustResolve(t, item, ResolveInput{OccurrenceAt: rt("2026-10-02T09:00:00Z"), State: "skip", Actor: "user"}, "2026-10-02T10:00:00Z")
es := f.expand(t, item, time.UTC, "2026-10-01T00:00:00Z", "2026-10-08T00:00:00Z", "2026-10-05T12:00:00Z")
want := map[string]string{
"2026-10-01T09:00:00": "done", "2026-10-02T09:00:00": "skipped", "2026-10-03T09:00:00": "outstanding",
"2026-10-04T09:00:00": "missed", "2026-10-05T09:00:00": "missed",
"2026-10-06T09:00:00": "scheduled", "2026-10-07T09:00:00": "scheduled",
}
if got := agendaStates(es); fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("states = %v, want %v", got, want)
}
for _, e := range es {
if e.State == "outstanding" && (!e.Overdue || e.MissedCount != 2 || !e.Stale) {
t.Errorf("outstanding entry = %+v, want overdue, missed_count 2, stale", e)
}
if e.State != "outstanding" && (e.Overdue || e.MissedCount != 0) {
t.Errorf("only the outstanding entry carries overdue/missed_count: %+v", e)
}
}
// A row that is not there is never invented: the occurrence reads unrecorded.
if _, err := f.s.db.Exec(f.s.q(`DELETE FROM schedule_occurrences WHERE state = 'skipped'`)); err != nil {
t.Fatal(err)
}
es = f.expand(t, item, time.UTC, "2026-10-02T00:00:00Z", "2026-10-03T00:00:00Z", "2026-10-05T12:00:00Z")
if got := agendaStates(es)["2026-10-02T09:00:00"]; got != "unrecorded" {
t.Errorf("before the outstanding occurrence with no row = %q, want unrecorded", got)
}
}
// A resolve writes the history row in the RECIPIENT's reading. The agenda reads a
// floating schedule in the caller's zone, where the same wall is another instant,
// so the row must be found by its wall (F3).
func TestAgendaFloatingMergesByWall(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
la := "America/Los_Angeles"
u, err := f.s.CreateUser(models.UserCreate{Email: "la@example.com", Name: "LA", Password: "pw-123456789"})
if err != nil {
t.Fatal(err)
}
if _, err := f.s.UpdateUser(u.ID, models.UserUpdate{Timezone: &la}); err != nil {
t.Fatal(err)
}
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Floating: true, Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
if _, err := f.s.db.Exec(f.s.q(`UPDATE items SET assigned_user_id = ? WHERE id = ?`), u.ID, item.ID); err != nil {
t.Fatal(err)
}
laLoc, _ := time.LoadLocation(la)
// 09:00 in Los Angeles on 2026-10-01 (PDT) is 16:00Z.
f.mustResolve(t, item, ResolveInput{OccurrenceAt: rt("2026-10-01T16:00:00Z"), State: "done", View: laLoc, Actor: "user"}, "2026-10-01T17:00:00Z")
tokyo, _ := time.LoadLocation("Asia/Tokyo")
es := f.expand(t, item, tokyo, "2026-09-30T00:00:00Z", "2026-10-03T00:00:00Z", "2026-10-01T17:00:00Z")
var found bool
for _, e := range es {
if e.Wall.String() == "2026-10-01T09:00:00" {
found = true
if e.State != "done" {
t.Errorf("floating row resolved in LA, read in Tokyo: state = %q, want done", e.State)
}
if !e.At.Equal(rt("2026-10-01T00:00:00Z")) {
t.Errorf("at = %s, want 09:00 Tokyo = 2026-10-01T00:00:00Z", e.At)
}
}
}
if !found {
t.Fatalf("the 2026-10-01T09:00:00 wall is not on the agenda: %+v", es)
}
}
func TestAgendaPinnedIgnoresViewerFloatingFollowsIt(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
pinned, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-11-02T09:00", Tz: "Asia/Tokyo"}, "2026-10-01T00:00:00Z")
floating, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-11-02T09:00", Floating: true}, "2026-10-01T00:00:00Z")
la, _ := time.LoadLocation("America/Los_Angeles")
tokyo, _ := time.LoadLocation("Asia/Tokyo")
at := func(item *models.Item, view *time.Location) string {
es := f.expand(t, item, view, "2026-11-01T00:00:00Z", "2026-11-04T00:00:00Z", "2026-10-01T00:00:00Z")
if len(es) != 1 {
t.Fatalf("entries = %+v", es)
}
return es[0].At.UTC().Format(time.RFC3339)
}
if a, b := at(pinned, la), at(pinned, tokyo); a != "2026-11-02T00:00:00Z" || b != a {
t.Errorf("pinned Tokyo 09:00: la %s tokyo %s, want 2026-11-02T00:00:00Z in both", a, b)
}
if a, b := at(floating, la), at(floating, tokyo); a != "2026-11-02T17:00:00Z" || b != "2026-11-02T00:00:00Z" {
t.Errorf("floating 09:00: la %s tokyo %s, want 17:00Z and 00:00Z", a, b)
}
}
func TestAgendaMovedOccurrenceAtGridWithEffectiveAt(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Tz: "UTC", Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
until := rt("2026-10-04T15:00:00Z")
f.mustResolve(t, item, ResolveInput{OccurrenceAt: rt("2026-10-01T09:00:00Z"), State: "move", Until: &until, Actor: "user"}, "2026-10-01T08:00:00Z")
for name, w := range map[string][2]string{
"grid window": {"2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"},
"effective window": {"2026-10-04T00:00:00Z", "2026-10-05T00:00:00Z"},
} {
es := f.expand(t, item, time.UTC, w[0], w[1], "2026-10-01T08:00:00Z")
var o *AgendaEntry
for i := range es {
if es[i].State == "outstanding" {
o = &es[i]
}
}
if o == nil {
t.Errorf("%s: the moved occurrence is not listed: %+v", name, es)
continue
}
if !o.At.Equal(rt("2026-10-01T09:00:00Z")) || o.EffectiveAt == nil || !o.EffectiveAt.Equal(until) {
t.Errorf("%s: at %s effective %v, want grid 2026-10-01T09:00Z and effective %s", name, o.At, o.EffectiveAt, until)
}
}
if es := f.expand(t, item, time.UTC, "2026-10-02T00:00:00Z", "2026-10-03T00:00:00Z", "2026-10-01T08:00:00Z"); len(es) != 1 || es[0].State != "scheduled" {
t.Errorf("a window holding neither instant lists only the grid's own days: %+v", es)
}
}
func TestAgendaSnoozeDoesNotMoveTheEntry(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Tz: "UTC", Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
until := rt("2026-10-04T15:00:00Z")
f.mustResolve(t, item, ResolveInput{OccurrenceAt: rt("2026-10-01T09:00:00Z"), State: "snooze", Until: &until, Actor: "user"}, "2026-10-01T10:00:00Z")
es := f.expand(t, item, time.UTC, "2026-10-04T00:00:00Z", "2026-10-05T00:00:00Z", "2026-10-01T10:00:00Z")
if len(es) != 1 || es[0].State != "scheduled" || es[0].EffectiveAt != nil {
t.Errorf("a snooze must not place the outstanding entry in the snooze day, only the grid's own 10-04 occurrence: %+v", es)
}
}
func TestAgendaAllDayAtIsLocalMidnight(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-11-05", Tz: "Asia/Tokyo"}, "2026-10-01T00:00:00Z")
es := f.expand(t, item, time.UTC, "2026-11-01T00:00:00Z", "2026-11-10T00:00:00Z", "2026-10-01T00:00:00Z")
if len(es) != 1 || !es[0].Wall.AllDay || !es[0].At.Equal(rt("2026-11-04T15:00:00Z")) {
t.Fatalf("all-day Tokyo 2026-11-05 = %+v, want At local midnight 2026-11-04T15:00:00Z (not the 09:00 fire hour)", es)
}
}
func TestAgendaDegradesPerSchedule(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
runaway, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T00:00", Tz: "UTC", Rrule: "FREQ=MINUTELY"}, "2026-10-01T00:00:00Z")
sc, _ := f.s.GetSchedule(f.ws.ID, runaway.ID)
es, truncated, unavailable, err := f.s.ExpandAgenda(sc, time.UTC, rt("2026-10-02T00:00:00Z"), rt("2026-12-30T00:00:00Z"), rt("2026-10-01T00:00:00Z"))
if err != nil {
t.Fatalf("a runaway rule is a per-schedule outcome, not an error: %v", err)
}
if unavailable == "" && !(truncated && len(es) > 0 && len(es) <= 500) {
t.Errorf("runaway: truncated=%v entries=%d unavailable=%q, want truncated at <=500 or unavailable", truncated, len(es), unavailable)
}
broken, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Tz: "UTC", Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
if _, err := f.s.db.Exec(f.s.q(`UPDATE item_schedules SET error_code = 'cap_scan' WHERE item_id = ?`), broken.ID); err != nil {
t.Fatal(err)
}
sc, _ = f.s.GetSchedule(f.ws.ID, broken.ID)
if es, _, unavailable, err = f.s.ExpandAgenda(sc, time.UTC, rt("2026-10-01T00:00:00Z"), rt("2026-10-05T00:00:00Z"), rt("2026-10-01T00:00:00Z")); err != nil || unavailable != "cap_scan" || len(es) != 0 {
t.Errorf("stored error_code: entries=%d unavailable=%q err=%v, want none and cap_scan", len(es), unavailable, err)
}
}
// A pinned HOURLY rule across New York's 2026-11-01 fall-back (06:00Z is 01:00
// EST, the second 01:xx hour), with a window edge inside the repeated hour: every
// instant is listed once, the edge selects by instant, none is doubled or lost.
func TestAgendaPinnedHourlyAcrossFallBack(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-11-01T00:00", Tz: "America/New_York", Rrule: "FREQ=HOURLY"}, "2026-10-31T00:00:00Z")
ny, _ := time.LoadLocation("America/New_York")
got := func(from, to string) []string {
var out []string
for _, e := range f.expand(t, item, ny, from, to, "2026-10-31T00:00:00Z") {
out = append(out, e.At.UTC().Format("15:04")+" "+e.At.In(ny).Format("15:04 MST"))
}
return out
}
// 05:30Z is inside the first 01:xx hour (EDT); 07:30Z inside the second (EST).
want := []string{"06:00 01:00 EST", "07:00 02:00 EST"}
if g := got("2026-11-01T05:30:00Z", "2026-11-01T07:30:00Z"); strings.Join(g, ",") != strings.Join(want, ",") {
t.Errorf("window edges in the repeated hour: %v, want %v", g, want)
}
want = []string{"05:00 01:00 EDT", "06:00 01:00 EST", "07:00 02:00 EST"}
if g := got("2026-11-01T04:30:00Z", "2026-11-01T07:30:00Z"); strings.Join(g, ",") != strings.Join(want, ",") {
t.Errorf("both 01:00 instants: %v, want %v", g, want)
}
}
// Hunter Q (TASK-4104 D1 r1): a FLOATING daily 09:00 viewed in New York across
// the 2026-11-01 fall-back. now is 08:30 EST on 11-02 (13:30Z), which a stuck
// EDT offset would read as already past 09:00 (13:00Z). The outstanding wall is
// 10-29; 10-30, 10-31 and 11-01 passed, 11-02 has not.
func TestAgendaFloatingMissedAcrossFallBack(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-29T09:00", Floating: true, Rrule: "FREQ=DAILY"}, "2026-10-29T00:00:00Z")
ny, _ := time.LoadLocation("America/New_York")
es := f.expand(t, item, ny, "2026-10-29T00:00:00Z", "2026-11-04T00:00:00Z", "2026-11-02T13:30:00Z")
var found bool
for _, e := range es {
if e.State != "outstanding" {
continue
}
found = true
if e.Wall.String() != "2026-10-29T09:00:00" || e.MissedCount != 3 || !e.Stale {
t.Errorf("outstanding = wall %s missed %d stale %v, want 2026-10-29T09:00:00, 3, true", e.Wall, e.MissedCount, e.Stale)
}
}
if !found {
t.Fatalf("no outstanding entry: %+v", es)
}
}
// r2-2: ExpandAgenda classifies. A stored-state parse failure is that schedule's
// `internal` outcome with a nil error; only a database failure is an error.
func TestAgendaExpandClassifiesStoredStateVersusDatabase(t *testing.T) {
t.Parallel()
f := newSchedFixture(t)
item, _ := f.mk(t, models.ScheduleCreate{Kind: "check", Dtstart: "2026-10-01T09:00", Tz: "UTC", Rrule: "FREQ=DAILY"}, "2026-10-01T00:00:00Z")
f.exec(t, `UPDATE item_schedules SET next_at = 'not-an-instant' WHERE item_id = ?`, item.ID)
sc, _ := f.s.GetSchedule(f.ws.ID, item.ID)
es, _, unavailable, err := f.s.ExpandAgenda(sc, time.UTC, rt("2026-10-01T00:00:00Z"), rt("2026-10-05T00:00:00Z"), rt("2026-10-01T00:00:00Z"))
if err != nil || unavailable != "internal" || len(es) != 0 {
t.Errorf("corrupt next_at: entries=%d unavailable=%q err=%v, want none, internal, nil", len(es), unavailable, err)
}
f.exec(t, `UPDATE item_schedules SET next_at = ? WHERE item_id = ?`, "2026-10-01T09:00:00Z", item.ID)
sc, _ = f.s.GetSchedule(f.ws.ID, item.ID)
f.exec(t, `DROP TABLE schedule_occurrences`)
es, _, unavailable, err = f.s.ExpandAgenda(sc, time.UTC, rt("2026-10-01T00:00:00Z"), rt("2026-10-05T00:00:00Z"), rt("2026-10-01T00:00:00Z"))
if err == nil || unavailable != "" || len(es) != 0 {
t.Errorf("failing history read: entries=%d unavailable=%q err=%v, want an error and no code", len(es), unavailable, err)
}
}