Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the school-wide week calendar's lesson grid and clean the leaked routine rows. - Add the editable codes map (FR-17): subjects, rooms, teachers and period labels, seeded, an unknown code falls back to the raw code; `school codes` CLI. - Brief: Finn's block first, then Harry's, four sections always present (FR-9); periods decoded into plain English; the undated tail is one counted line (FR-18). - Calendar: write periods + work due + work overdue + major events, per child, on every pass. Identity is the stable per-item key, so a re-run never re-inserts (FR-8). morning-brief.sh now runs scan -> calendar push -> notify. - Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods. Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -47,6 +47,52 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
|
||||
AND i.due_at IS NOT NULL
|
||||
AND i.due_at >= ?`
|
||||
args := []any{from.UTC().Format(time.RFC3339)}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// CalendarPeriodCandidates returns the per-child periods for one day that are
|
||||
// not in the calendar yet. They come from the child's own day widget
|
||||
// (source_page child-profile / learning-tasks), never from the school-wide week
|
||||
// calendar (FR-16). A period is a routine lesson, so it carries importance
|
||||
// "normal"; school events are written by CalendarCandidates.
|
||||
func (s *Store) CalendarPeriodCandidates(ctx context.Context, childID, itemID *int64, dayStart, dayEnd time.Time, limit int) ([]CalendarItem, error) {
|
||||
q := calendarSelect + `
|
||||
WHERE i.type = 'event'
|
||||
AND i.source = 'compass'
|
||||
AND i.importance = 'normal'
|
||||
AND COALESCE(i.ha_event_uid, '') = ''
|
||||
AND i.source_page IN ('child-profile', 'learning-tasks')
|
||||
AND i.due_at IS NOT NULL
|
||||
AND i.due_at >= ? AND i.due_at < ?`
|
||||
args := []any{dayStart.UTC().Format(time.RFC3339), dayEnd.UTC().Format(time.RFC3339)}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// CalendarTaskCandidates returns the outstanding work that carries a due date
|
||||
// and is not in the calendar yet: the work due in the future and the work
|
||||
// overdue (FR-8). Submitted work is filtered by the caller, which holds the
|
||||
// body-status rule.
|
||||
func (s *Store) CalendarTaskCandidates(ctx context.Context, childID, itemID *int64, limit int) ([]CalendarItem, error) {
|
||||
q := calendarSelect + `
|
||||
WHERE i.type = 'task'
|
||||
AND i.source = 'compass'
|
||||
AND COALESCE(i.ha_event_uid, '') = ''
|
||||
AND i.due_at IS NOT NULL`
|
||||
args := []any{}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// narrowCalendarQuery appends the optional child and item filters.
|
||||
func narrowCalendarQuery(q string, args []any, childID, itemID *int64) (string, []any) {
|
||||
if childID != nil {
|
||||
q += ` AND i.child_id = ?`
|
||||
args = append(args, *childID)
|
||||
@@ -55,12 +101,19 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
|
||||
q += ` AND i.id = ?`
|
||||
args = append(args, *itemID)
|
||||
}
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
return q, args
|
||||
}
|
||||
|
||||
func capCalendarQuery(q string, args []any, limit int) (string, []any) {
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
// queryCalendarItems runs a candidate query and reads every row.
|
||||
func (s *Store) queryCalendarItems(ctx context.Context, q string, args []any) ([]CalendarItem, error) {
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calendar candidates: %w", err)
|
||||
@@ -118,6 +171,45 @@ func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem,
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// DeleteChildRoutineCalendarEvents removes a child's routine class periods that
|
||||
// came from the school-wide week calendar (source_page "calendar", importance
|
||||
// "normal"). The week calendar's lesson grid belongs to the eldest child, so
|
||||
// storing it against a younger child is a leak (FR-16); the child's own day
|
||||
// widget is the per-child source. Real school events (importance "high") were
|
||||
// written with the same page and are kept.
|
||||
func (s *Store) DeleteChildRoutineCalendarEvents(ctx context.Context, childID int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM items
|
||||
WHERE child_id = ? AND type = 'event' AND source_page = 'calendar' AND importance = 'normal'`,
|
||||
childID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete routine calendar events: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteChildDayWidgetEvents removes a child's previously parsed day-widget
|
||||
// periods so a re-parse replaces the snapshot instead of accumulating stale
|
||||
// days or wrong times. It only touches events from the child's own pages.
|
||||
func (s *Store) DeleteChildDayWidgetEvents(ctx context.Context, childID int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM items
|
||||
WHERE child_id = ? AND type = 'event' AND source_page IN ('child-profile', 'learning-tasks')`,
|
||||
childID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete day-widget events: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkItemCalendarSynced records the Home Assistant event id and the push time
|
||||
// on one item. After this the item is no longer a calendar candidate.
|
||||
func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error {
|
||||
|
||||
Reference in New Issue
Block a user