Refine report filtering and date formatting
CI / test (push) Successful in 28s
CI / publish (push) Failing after 1m14s

This commit is contained in:
Joe Julian
2026-03-30 18:05:54 -07:00
parent 86856abda2
commit edb66e3a5a
2 changed files with 200 additions and 23 deletions
+106 -23
View File
@@ -56,6 +56,12 @@ type Assignment struct {
Category string Category string
} }
type ClassInfo struct {
URL string
Name string
Grade string
}
func main() { func main() {
if err := run(); err != nil { if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
@@ -88,16 +94,16 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
missing = filterMissingAssignments(missing, cfg.StartDate)
classLinks, err := fetchClassLinks(client, cfg.ScheduleURL) classes, err := fetchScheduleClasses(client, cfg.ScheduleURL)
if err != nil { if err != nil {
return err return err
} }
missing = filterMissingAssignments(missing, cfg.StartDate, classesWithCurrentA(classes))
var upcoming []Assignment var upcoming []Assignment
for _, classURL := range classLinks { for _, classInfo := range classes {
items, err := fetchUpcomingAssignmentsForClass(client, classURL, now) items, err := fetchUpcomingAssignmentsForClass(client, classInfo.URL, now)
if err != nil { if err != nil {
return err return err
} }
@@ -429,31 +435,57 @@ func fetchMissingAssignments(client *http.Client, assignmentsURL string, now tim
return assignments, nil return assignments, nil
} }
func fetchClassLinks(client *http.Client, scheduleURL string) ([]string, error) { func fetchScheduleClasses(client *http.Client, scheduleURL string) ([]ClassInfo, error) {
root, baseURL, err := fetchHTML(client, scheduleURL) root, baseURL, err := fetchHTML(client, scheduleURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return extractScheduleClasses(root, baseURL)
}
func extractScheduleClasses(root *html.Node, baseURL *url.URL) ([]ClassInfo, error) {
table := findNode(root, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "current-classes")
})
if table == nil {
return nil, errors.New("current classes table not found")
}
seen := map[string]struct{}{} seen := map[string]struct{}{}
var links []string var classes []ClassInfo
for _, link := range findNodes(root, func(n *html.Node) bool { for _, row := range findNodes(table, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" && return n.Type == html.ElementNode && n.Data == "tr"
strings.Contains(attr(n, "href"), "/class/") &&
attr(n, "data-track") == "Class Home Page"
}) { }) {
href := attr(link, "href") cells := directChildCells(row)
if href == "" { if len(cells) < 5 {
continue
}
link := findNode(cells[0], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" &&
strings.Contains(attr(n, "href"), "/class/") &&
attr(n, "data-track") == "Class Home Page"
})
if link == nil {
continue
}
classURL := resolveURL(baseURL, strings.SplitN(attr(link, "href"), "?", 2)[0])
if classURL == "" {
continue continue
} }
classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0])
if _, ok := seen[classURL]; ok { if _, ok := seen[classURL]; ok {
continue continue
} }
seen[classURL] = struct{}{} seen[classURL] = struct{}{}
links = append(links, classURL) classes = append(classes, ClassInfo{
URL: classURL,
Name: firstNonEmpty(ownText(link), nodeText(link)),
Grade: nodeText(cells[4]),
})
} }
slices.Sort(links) slices.SortFunc(classes, func(a, b ClassInfo) int {
return links, nil return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return classes, nil
} }
func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) { func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) {
@@ -519,12 +551,16 @@ func fetchHTML(client *http.Client, rawURL string) (*html.Node, *url.URL, error)
return doc, resp.Request.URL, nil return doc, resp.Request.URL, nil
} }
func filterMissingAssignments(assignments []Assignment, startDate time.Time) []Assignment { func filterMissingAssignments(assignments []Assignment, startDate time.Time, coursesWithCurrentA map[string]struct{}) []Assignment {
var filtered []Assignment var filtered []Assignment
for _, assignment := range assignments { for _, assignment := range assignments {
if !assignment.DueDate.Before(startOfDay(startDate)) { if assignment.DueDate.Before(startOfDay(startDate)) {
filtered = append(filtered, assignment) continue
} }
if hasCurrentA(coursesWithCurrentA, assignment.Course) && isRevisionStatus(assignment.Status) {
continue
}
filtered = append(filtered, assignment)
} }
return filtered return filtered
} }
@@ -568,15 +604,15 @@ func buildSubject(missing, upcoming []Assignment, startDate time.Time, upcomingD
func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string { func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", now.Format("2006-01-02")) fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", formatReportDate(now))
fmt.Fprintf(&b, "Assignments due on or after %s\n\n", startDate.Format("2006-01-02")) fmt.Fprintf(&b, "Assignments due on or after %s\n\n", formatReportDate(startDate))
b.WriteString("Missing / Revision Needed\n") b.WriteString("Missing / Revision Needed\n")
b.WriteString("-------------------------\n") b.WriteString("-------------------------\n")
if len(missing) == 0 { if len(missing) == 0 {
b.WriteString("No missing or revision-needed assignments matched the filter.\n") b.WriteString("No missing or revision-needed assignments matched the filter.\n")
} else { } else {
for _, assignment := range missing { for _, assignment := range missing {
fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) fmt.Fprintf(&b, "- %s | %s | %s\n", formatReportDate(assignment.DueDate), assignment.Course, assignment.Title)
fmt.Fprintf(&b, " Status: %s\n", assignment.Status) fmt.Fprintf(&b, " Status: %s\n", assignment.Status)
fmt.Fprintf(&b, " Link: %s\n", assignment.Link) fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
} }
@@ -587,7 +623,7 @@ func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsU
b.WriteString("No upcoming assignments were due in the configured window.\n") b.WriteString("No upcoming assignments were due in the configured window.\n")
} else { } else {
for _, assignment := range upcoming { for _, assignment := range upcoming {
fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) fmt.Fprintf(&b, "- %s | %s | %s\n", formatReportDate(assignment.DueDate), assignment.Course, assignment.Title)
fmt.Fprintf(&b, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming")) fmt.Fprintf(&b, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming"))
fmt.Fprintf(&b, " Link: %s\n", assignment.Link) fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
} }
@@ -677,6 +713,30 @@ func normalizeSMTPHeloName(value string) string {
return value + ".localdomain" return value + ".localdomain"
} }
func formatReportDate(t time.Time) string {
return t.Format("Monday 2006-01-02")
}
func classesWithCurrentA(classes []ClassInfo) map[string]struct{} {
courses := map[string]struct{}{}
for _, classInfo := range classes {
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(classInfo.Grade)), "A") {
courses[strings.ToLower(strings.TrimSpace(classInfo.Name))] = struct{}{}
}
}
return courses
}
func hasCurrentA(courses map[string]struct{}, course string) bool {
_, ok := courses[strings.ToLower(strings.TrimSpace(course))]
return ok
}
func isRevisionStatus(status string) bool {
status = strings.ToLower(strings.TrimSpace(status))
return strings.Contains(status, "revision") || strings.Contains(status, "revise")
}
func inferAssignmentDate(monthDay string, now time.Time) time.Time { func inferAssignmentDate(monthDay string, now time.Time) time.Time {
return inferDate(monthDay, now, false) return inferDate(monthDay, now, false)
} }
@@ -778,6 +838,29 @@ func directChildrenByTag(node *html.Node, tag string) []*html.Node {
return children return children
} }
func directChildCells(node *html.Node) []*html.Node {
var children []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && (child.Data == "th" || child.Data == "td") {
children = append(children, child)
}
}
return children
}
func ownText(node *html.Node) string {
var parts []string
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.TextNode {
text := strings.TrimSpace(child.Data)
if text != "" {
parts = append(parts, text)
}
}
}
return strings.Join(parts, " ")
}
func hasClass(node *html.Node, class string) bool { func hasClass(node *html.Node, class string) bool {
for _, item := range strings.Fields(attr(node, "class")) { for _, item := range strings.Fields(attr(node, "class")) {
if item == class { if item == class {
+94
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -48,6 +49,28 @@ func TestFilterUpcomingAssignments(t *testing.T) {
} }
} }
func TestFilterMissingAssignmentsHidesRevisionForCurrentA(t *testing.T) {
start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
assignments := []Assignment{
{
DueDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
Course: "Geometry Lab",
Title: "Revise me",
Status: "Revision Needed",
},
{
DueDate: time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
Course: "Geometry Lab",
Title: "Still missing",
Status: "Missing",
},
}
got := filterMissingAssignments(assignments, start, map[string]struct{}{"geometry lab": {}})
if len(got) != 1 || got[0].Title != "Still missing" {
t.Fatalf("unexpected filtered assignments: %#v", got)
}
}
func TestDedupeAssignments(t *testing.T) { func TestDedupeAssignments(t *testing.T) {
assignment := Assignment{ assignment := Assignment{
DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
@@ -141,6 +164,77 @@ func TestNormalizeSMTPHeloName(t *testing.T) {
} }
} }
func TestClassesWithCurrentA(t *testing.T) {
got := classesWithCurrentA([]ClassInfo{
{Name: "Geometry Lab", Grade: "A"},
{Name: "Civics", Grade: "B"},
{Name: "Intro. Spanish 1", Grade: "A-"},
})
if !hasCurrentA(got, "Geometry Lab") {
t.Fatal("expected Geometry Lab to have current A")
}
if !hasCurrentA(got, "Intro. Spanish 1") {
t.Fatal("expected Intro. Spanish 1 to have current A")
}
if hasCurrentA(got, "Civics") {
t.Fatal("did not expect Civics to have current A")
}
}
func TestFetchScheduleClasses(t *testing.T) {
doc := mustParseHTML(`
<table class="student-classes current-classes pure-table pure-table-horizontal">
<tbody>
<tr>
<td><a data-track="Class Home Page" href="/class/1?back=/schedule">Geometry Lab</a></td>
<td>S1, S2</td>
<td>-</td>
<td>Teacher</td>
<td>A</td>
<td>Aug 25, 2025</td>
<td>-</td>
</tr>
<tr>
<td><a data-track="Class Home Page" href="/class/2?back=/schedule">Civics</a></td>
<td>S2</td>
<td>-</td>
<td>Teacher</td>
<td>B</td>
<td>Jan 20, 2026</td>
<td>-</td>
</tr>
</tbody>
</table>`)
base, err := url.Parse("https://example.invalid/children/student/schedule")
if err != nil {
t.Fatalf("parse base URL: %v", err)
}
got, err := extractScheduleClasses(doc, base)
if err != nil {
t.Fatalf("extractScheduleClasses returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d classes", len(got))
}
if got[1].Grade != "A" || got[1].Name != "Geometry Lab" {
t.Fatalf("unexpected classes: %#v", got)
}
}
func TestBuildBodyIncludesWeekday(t *testing.T) {
now := time.Date(2026, 3, 30, 10, 0, 0, 0, time.UTC)
start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
body := buildBody([]Assignment{{
DueDate: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC),
Course: "Civics",
Title: "Essay",
Status: "Missing",
}}, nil, start, "https://example.invalid/assignments", "https://example.invalid/schedule", now, 14)
if !strings.Contains(body, "Monday 2026-03-30") {
t.Fatalf("body missing weekday date: %s", body)
}
}
func mustParseHTML(text string) *html.Node { func mustParseHTML(text string) *html.Node {
doc, err := html.Parse(strings.NewReader(text)) doc, err := html.Parse(strings.NewReader(text))
if err != nil { if err != nil {