internal/gaby: action log page: controls

Set up a page to display the action log.
This CL contains the controls that select a time interval.

For golang/oscar#9.

Change-Id: I1bd38ac42ba42631604fb07e788861da21df2735
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/616056
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gaby/actionlog.go b/internal/gaby/actionlog.go
new file mode 100644
index 0000000..7e8b78a
--- /dev/null
+++ b/internal/gaby/actionlog.go
@@ -0,0 +1,183 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"embed"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/google/safehtml/template"
+	"golang.org/x/oscar/internal/actions"
+)
+
+// actionLogPage is the data for the action log HTML template.
+type actionLogPage struct {
+	Start, End         endpoint
+	StartTime, EndTime string // formatted times that the endpoints describe
+	Entries            []*actions.Entry
+}
+
+// An endpoint holds the values for a UI component for selecting a point in time.
+type endpoint struct {
+	Radio   string // "fixed", "dur" or "date"
+	Date    string // date and time, see below for format
+	DurNum  string // integer
+	DurUnit string // "hours", "minutes", etc.
+}
+
+func (g *Gaby) handleActionLog(w http.ResponseWriter, r *http.Request) {
+	data, status, err := g.doActionLog(r)
+	if err != nil {
+		http.Error(w, err.Error(), status)
+	} else {
+		_, _ = w.Write(data)
+	}
+}
+
+func (g *Gaby) doActionLog(r *http.Request) (content []byte, status int, err error) {
+	var page actionLogPage
+
+	// Fill in the endpoint values from the form on the page.
+	page.Start.formValues(r, "start")
+	page.End.formValues(r, "end")
+
+	startTime, endTime, err := times(page.Start, page.End, time.Now())
+	if err != nil {
+		return nil, http.StatusBadRequest, err
+	}
+
+	// Display a table heading, but only if something was set.
+	if r.FormValue("start") != "" {
+		if startTime.IsZero() {
+			page.StartTime = "the beginning of time"
+		} else {
+			page.StartTime = startTime.Format(time.DateTime)
+		}
+		page.EndTime = endTime.Format(time.DateTime)
+	}
+
+	// Set defaults: from 1 hour ago to now.
+	if page.Start.Radio == "" {
+		page.Start.Radio = "dur"
+		page.Start.DurNum = "1"
+		page.Start.DurUnit = "hours"
+	}
+	if page.End.Radio == "" {
+		page.End.Radio = "fixed"
+	}
+
+	var buf bytes.Buffer
+	if err := actionLogPageTmpl.Execute(&buf, page); err != nil {
+		return nil, http.StatusInternalServerError, err
+	}
+	return buf.Bytes(), http.StatusOK, nil
+}
+
+// formValues populates an endpoint from the values in the form.
+func (e *endpoint) formValues(r *http.Request, prefix string) {
+	e.Radio = r.FormValue(prefix)
+	e.Date = r.FormValue(prefix + "-date")
+	e.DurNum = r.FormValue(prefix + "-dur-num")
+	e.DurUnit = r.FormValue(prefix + "-dur-unit")
+}
+
+// times computes the start and end times from the endpoint controls.
+func times(start, end endpoint, now time.Time) (startTime, endTime time.Time, err error) {
+	var ztime time.Time
+	st, sd, err1 := start.timeOrDuration(now)
+	et, ed, err2 := end.timeOrDuration(now)
+	if err := errors.Join(err1, err2); err != nil {
+		return ztime, ztime, err
+	}
+	if sd != 0 && ed != 0 {
+		return ztime, ztime, errors.New("both endpoints can't be durations")
+	}
+	// TODO(jba): times should be local to the user, not the Gaby server.
+
+	// The "fixed" choice always returns a zero time, but for the end endpoint is should be now.
+	if et.IsZero() && ed == 0 {
+		et = now
+	}
+
+	// Add a duration to a time.
+	if sd != 0 {
+		st = et.Add(-sd)
+	}
+	if ed != 0 {
+		et = st.Add(ed)
+	}
+	if et.Before(st) {
+		return ztime, ztime, errors.New("end time before start time")
+	}
+	return st, et, nil
+}
+
+// timeOrDuration returns the time or duration described by the endpoint's controls.
+// If the controls aren't set, it returns the zero time.
+func (e *endpoint) timeOrDuration(now time.Time) (time.Time, time.Duration, error) {
+	var ztime time.Time
+	switch e.Radio {
+	case "", "fixed":
+		// A fixed time: start at the beginning of time, end at now.
+		// That choice is left to the caller.
+		return ztime, 0, nil
+	case "date":
+		// Format described in
+		// https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings
+		// TODO(jba): times should be local to the user, not the Gaby server.
+		t, err := time.ParseInLocation("2006-01-02T03:04", e.Date, time.Local)
+		if err != nil {
+			return ztime, 0, err
+		}
+		return t, 0, nil
+	case "dur":
+		// A duration after a start time, or before an end time.
+		if e.DurNum == "" {
+			return ztime, 0, errors.New("missing duration value")
+		}
+		num, err := strconv.Atoi(e.DurNum)
+		if err != nil {
+			return ztime, 0, err
+		}
+		if num <= 0 {
+			return ztime, 0, errors.New("non-positive duration")
+		}
+		var unit time.Duration
+		switch e.DurUnit {
+		case "minutes":
+			unit = time.Minute
+		case "hours":
+			unit = time.Hour
+		case "days":
+			unit = 24 * time.Hour
+		case "weeks":
+			unit = 7 * 24 * time.Hour
+		default:
+			return ztime, 0, fmt.Errorf("bad duration unit %q", e.DurUnit)
+
+		}
+		return ztime, time.Duration(num) * unit, nil
+	default:
+		return ztime, 0, fmt.Errorf("bad radio button value %q", e.Radio)
+	}
+}
+
+// Embed the action template into the binary.
+// We must use the FS form in order to make it trusted with the
+// github.com/google/safehtml/template API.
+
+//go:embed actionlog.tmpl
+var actionLogFS embed.FS
+
+const actionLogTmplFile = "actionlog.tmpl"
+
+// The template name must match the filename.
+var actionLogPageTmpl = template.Must(template.New(actionLogTmplFile).
+	ParseFS(template.TrustedFSFromEmbed(actionLogFS), actionLogTmplFile))
diff --git a/internal/gaby/actionlog.tmpl b/internal/gaby/actionlog.tmpl
new file mode 100644
index 0000000..3db3c81
--- /dev/null
+++ b/internal/gaby/actionlog.tmpl
@@ -0,0 +1,89 @@
+<!--
+Copyright 2024 The Go Authors. All rights reserved.
+Use of this source code is governed by a BSD-style
+license that can be found in the LICENSE file.
+-->
+<!doctype html>
+<html>
+  <head>
+    <title>Oscar Action Log</title>
+    <style>
+      body { font-family: sans-serif }
+    </style>
+  </head>
+  <body>
+    <h1>Action Log Viewer</h1>
+
+    <form id="form" action="/actionlog" method="GET">
+		<table>
+		  <tr>
+		    <td>
+              <fieldset>
+                <legend>Start</legend>
+                <div>
+                  <input type="radio" name="start" id="start-fixed" value="fixed"
+                    {{if eq .Start.Radio "fixed"}}checked{{end}}/>
+                  <label for="start-fixed">Beginning</label>
+                </div>
+                <div>
+                  <input type="radio" name="start" id="start-dur" value="dur"
+                    {{if eq .Start.Radio "dur"}}checked{{end}}/>
+                  <input type="text" id="start-dur" size="4" name="start-dur-num" value="{{.Start.DurNum}}" autofocus/>
+                    <select name="start-dur-unit">
+                      {{template "dur-unit" .Start.DurUnit}}
+                    </select>
+                  <label for="start-dur">before end</label>
+                </div>
+                <div>
+                  <input type="radio" name="start" id="start-date" value="date"
+                    {{if eq .Start.Radio "date"}}checked{{end}}/>
+                  <label for="start-date">From</label>
+                  <input type="datetime-local" name="start-date" value="{{.Start.Date}}"/>
+                </div>
+              </fieldset>
+            </td>
+
+		    <td>
+              <fieldset>
+                <legend>End</legend>
+                <div>
+                  <input type="radio" name="end" id="end-fixed" value="fixed"
+                    {{if eq .End.Radio "fixed"}}checked{{end}}/>
+                  <label for="end-fixed">End</label>
+                </div>
+                <div>
+                  <input type="radio" name="end" id="end-dur" value="dur"
+                    {{if eq .End.Radio "dur"}}checked{{end}}/>
+                    <input type="text" id="end-dur" size="4" name="end-dur-num" value="1" autofocus/>
+                    <select name="end-dur-unit">
+                      {{template "dur-unit" .End.DurUnit}}
+                    </select>
+                  <label for="end-dur">after start</label>
+                </div>
+                <div>
+                  <input type="radio" name="end" id="end-date" value="date"
+                    {{if eq .End.Radio "date"}}checked{{end}}/>
+                  <label for="end-date">To</label>
+                  <input type="datetime-local" name="end-date" value="{{.End.Date}}"ga/>
+                </div>
+              </fieldset>
+            </td>
+
+            <td> <input type="submit" value="Display"/> </td>
+		  </tr>
+        </table>
+    </form>
+
+    {{if .StartTime}}
+      <h2>Action Log from {{.StartTime}} to {{.EndTime}}</h2>
+    {{end}}
+
+   </body>
+</html>
+
+{{define "dur-unit"}}
+  <option value="minutes"{{if eq . "minutes"}} selected{{end}}>minutes</option>
+  <option value="hours"{{if eq . "hours"}} selected{{end}}>hours</option>
+  <option value="days"{{if eq . "days"}} selected{{end}}>days</option>
+  <option value="weeks"{{if eq . "weeks"}} selected{{end}}>weeks</option>
+{{end}}
diff --git a/internal/gaby/actionlog_test.go b/internal/gaby/actionlog_test.go
new file mode 100644
index 0000000..b5a70af
--- /dev/null
+++ b/internal/gaby/actionlog_test.go
@@ -0,0 +1,112 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestTimeOrDuration(t *testing.T) {
+	now := time.Now()
+	for _, test := range []struct {
+		endpoint endpoint
+		wantTime time.Time
+		wantDur  time.Duration
+	}{
+		{
+			endpoint: endpoint{Radio: "fixed"},
+			wantTime: time.Time{},
+		},
+		{
+			endpoint: endpoint{Radio: "date", Date: "2018-01-02T09:11"},
+			wantTime: time.Date(2018, 1, 2, 9, 11, 0, 0, time.Local),
+		},
+		{
+			endpoint: endpoint{Radio: "dur", DurNum: "3", DurUnit: "hours"},
+			wantDur:  3 * time.Hour,
+		},
+	} {
+		gotTime, gotDur, err := test.endpoint.timeOrDuration(now)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !gotTime.Equal(test.wantTime) || gotDur != test.wantDur {
+			t.Errorf("%+v: got (%s, %s), want (%s, %s)",
+				test.endpoint, gotTime, gotDur, test.wantTime, test.wantDur)
+		}
+	}
+}
+
+func TestTimes(t *testing.T) {
+	dt := func(year int, month time.Month, day, hour, minute int) time.Time {
+		return time.Date(year, month, day, hour, minute, 0, 0, time.Local)
+	}
+
+	now := dt(2024, 9, 10, 0, 0)
+
+	for _, test := range []struct {
+		start, end         endpoint
+		wantStart, wantEnd time.Time
+	}{
+		{
+			start:     endpoint{Radio: "fixed"},
+			end:       endpoint{Radio: "fixed"},
+			wantStart: time.Time{},
+			wantEnd:   now,
+		},
+		{
+			start:     endpoint{Radio: "dur", DurNum: "1", DurUnit: "hours"},
+			end:       endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+			wantStart: dt(2001, 11, 12, 3, 0),
+			wantEnd:   dt(2001, 11, 12, 4, 0),
+		},
+		{
+			start:     endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+			end:       endpoint{Radio: "dur", DurNum: "1", DurUnit: "hours"},
+			wantStart: dt(2001, 11, 12, 4, 0),
+			wantEnd:   dt(2001, 11, 12, 5, 0),
+		},
+		{
+			start:     endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+			end:       endpoint{Radio: "date", Date: "2002-01-02T11:21"},
+			wantStart: dt(2001, 11, 12, 4, 0),
+			wantEnd:   dt(2002, 1, 2, 11, 21),
+		},
+	} {
+		gotStart, gotEnd, err := times(test.start, test.end, now)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !gotStart.Equal(test.wantStart) || !gotEnd.Equal(test.wantEnd) {
+			t.Errorf("times(%+v, %+v):\ngot  (%s, %s)\nwant (%s, %s)",
+				test.start, test.end, gotStart, gotEnd, test.wantStart, test.wantEnd)
+		}
+	}
+}
+
+func TestActionTemplate(t *testing.T) {
+	var buf bytes.Buffer
+	page := actionLogPage{
+		Start: endpoint{DurNum: "3", DurUnit: "days"},
+	}
+	if err := actionLogPageTmpl.Execute(&buf, page); err != nil {
+		t.Fatal(err)
+	}
+	got := buf.String()
+	wants := []string{
+		`<option value="days" selected>days</option>`,
+	}
+	for _, w := range wants {
+		if !strings.Contains(got, w) {
+			t.Errorf("did not find %q", w)
+		}
+	}
+	if t.Failed() {
+		t.Log(got)
+	}
+}
diff --git a/internal/gaby/main.go b/internal/gaby/main.go
index 2bc0f03..06693ac 100644
--- a/internal/gaby/main.go
+++ b/internal/gaby/main.go
@@ -433,6 +433,9 @@
 	// POST because the arguments to the request are in the body.
 	mux.HandleFunc("POST /api/search", g.handleSearchAPI)
 
+	// /actionlog: display action log
+	mux.HandleFunc("GET /actionlog", g.handleActionLog)
+
 	return mux
 }
 
diff --git a/internal/gaby/search.go b/internal/gaby/search.go
index 398e697..68517b4 100644
--- a/internal/gaby/search.go
+++ b/internal/gaby/search.go
@@ -56,7 +56,7 @@
 
 // This template assumes that if a result's Kind is non-empty, it is a URL,
 // and vice versa.
-var searchPageTmpl = template.Must(template.New("").Parse(`
+var searchPageTmpl = template.Must(template.New("search").Parse(`
 <!doctype html>
 <html>
   <head>