Merge changes I81b22398,I19a72da6

* changes:
  hswaw/site: add spaceapi
  hswaw/site: add checkinator integration
diff --git a/hswaw/site/BUILD.bazel b/hswaw/site/BUILD.bazel
index 21edded..5cb3b99 100644
--- a/hswaw/site/BUILD.bazel
+++ b/hswaw/site/BUILD.bazel
@@ -4,14 +4,17 @@
 go_library(
     name = "go_default_library",
     srcs = [
+        "at.go",
         "feeds.go",
         "main.go",
+        "spaceapi.go",
         "views.go",
     ],
     importpath = "code.hackerspace.pl/hscloud/hswaw/site",
     visibility = ["//visibility:private"],
     deps = [
         "//go/mirko:go_default_library",
+        "//hswaw/site/calendar:go_default_library",
         "//hswaw/site/static:static_go",
         "//hswaw/site/templates:templates_go",
         "@com_github_golang_glog//:go_default_library",
@@ -25,8 +28,8 @@
 )
 
 container_image(
-    name="latest",
-    base="@prodimage-bionic//image",
+    name = "latest",
+    base = "@prodimage-bionic//image",
     files = [":site"],
     directory = "/hswaw/site/",
     entrypoint = ["/hswaw/site/site"],
diff --git a/hswaw/site/at.go b/hswaw/site/at.go
new file mode 100644
index 0000000..70df8c7
--- /dev/null
+++ b/hswaw/site/at.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+)
+
+const (
+	atURL = "https://at.hackerspace.pl/api"
+)
+
+// atStatus is the result of queruing checkinator/at (Hackerspace presence
+// service).
+type atStatus struct {
+	// Users is the list of present and publicly visible users.
+	Users []atUser `json:"users"`
+	// ESPs is the number of ESP{8266,32} devices.
+	ESPs int `json:"esps"`
+	// Kektops is the number of nettop “Kektop” devices.
+	Kektops int `json:"kektops"`
+	// Unknown is the number of unknown devices in the network.
+	Unknown int `json:"unknown"`
+}
+
+type atUser struct {
+	Login string `json:"login"`
+}
+
+func getAt(ctx context.Context) (*atStatus, error) {
+	r, err := http.NewRequestWithContext(ctx, "GET", atURL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("NewRequest(%q): %w", atURL, err)
+	}
+	res, err := http.DefaultClient.Do(r)
+	if err != nil {
+		return nil, fmt.Errorf("GET: %w", err)
+	}
+	defer res.Body.Close()
+
+	var status atStatus
+	if err := json.NewDecoder(res.Body).Decode(&status); err != nil {
+		return nil, fmt.Errorf("when decoding JSON: %w", err)
+	}
+
+	return &status, nil
+}
diff --git a/hswaw/site/calendar/BUILD.bazel b/hswaw/site/calendar/BUILD.bazel
index 297fde3..fcdac53 100644
--- a/hswaw/site/calendar/BUILD.bazel
+++ b/hswaw/site/calendar/BUILD.bazel
@@ -3,12 +3,12 @@
 go_library(
     name = "go_default_library",
     srcs = [
-        "load.go",
         "event.go",
+        "load.go",
         "time.go",
     ],
     importpath = "code.hackerspace.pl/hscloud/hswaw/site/calendar",
-    visibility = ["//visibility:private"],
+    visibility = ["//hswaw/site:__subpackages__"],
     deps = [
         "@com_github_arran4_golang_ical//:go_default_library",
         "@com_github_golang_glog//:go_default_library",
diff --git a/hswaw/site/calendar/load.go b/hswaw/site/calendar/load.go
index 5ea9198..5b36b9c 100644
--- a/hswaw/site/calendar/load.go
+++ b/hswaw/site/calendar/load.go
@@ -14,8 +14,8 @@
 )
 
 const (
-	// eventsURL is the calendar from which we load public Hackerspace events.
-	eventsURL = "https://owncloud.hackerspace.pl/remote.php/dav/public-calendars/g8toktZrA9fyAHNi/?export"
+	// EventsURL is the calendar from which we load public Hackerspace events.
+	EventsURL = "https://owncloud.hackerspace.pl/remote.php/dav/public-calendars/g8toktZrA9fyAHNi/?export"
 )
 
 // eventsBySooner sorts upcoming events so the one that happens the soonest
@@ -107,13 +107,13 @@
 // relative to the given time 'now' as per the Warsaw Hackerspace public
 // calender (from owncloud.hackerspace.pl).
 func GetUpcomingEvents(ctx context.Context, now time.Time) ([]*UpcomingEvent, error) {
-	r, err := http.NewRequestWithContext(ctx, "GET", eventsURL, nil)
+	r, err := http.NewRequestWithContext(ctx, "GET", EventsURL, nil)
 	if err != nil {
-		return nil, fmt.Errorf("NewRequest(%q): %w", eventsURL, err)
+		return nil, fmt.Errorf("NewRequest(%q): %w", EventsURL, err)
 	}
 	res, err := http.DefaultClient.Do(r)
 	if err != nil {
-		return nil, fmt.Errorf("Do(%q): %w", eventsURL, err)
+		return nil, fmt.Errorf("Do(%q): %w", EventsURL, err)
 	}
 	defer res.Body.Close()
 	return parseUpcomingEvents(now, res.Body)
diff --git a/hswaw/site/main.go b/hswaw/site/main.go
index a7f3e54..c4437bc 100644
--- a/hswaw/site/main.go
+++ b/hswaw/site/main.go
@@ -102,5 +102,6 @@
 
 func (s *service) registerHTTP(mux *http.ServeMux) {
 	mux.HandleFunc("/static/", s.handleHTTPStatic)
+	mux.HandleFunc("/spaceapi", s.handleSpaceAPI)
 	mux.HandleFunc("/", s.handleIndex)
 }
diff --git a/hswaw/site/spaceapi.go b/hswaw/site/spaceapi.go
new file mode 100644
index 0000000..13c5aad
--- /dev/null
+++ b/hswaw/site/spaceapi.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+	"context"
+
+	"code.hackerspace.pl/hscloud/hswaw/site/calendar"
+	"github.com/golang/glog"
+)
+
+// SpaceAPIResponse, per https://spaceapi.io/ - kinda. Mostly rewritten from
+// old implementation, someone should update this to use the official schema.
+type SpaceAPIResponse struct {
+	API                 string                      `json:"api"`
+	Space               string                      `json:"space"`
+	Logo                string                      `json:"logo"`
+	URL                 string                      `json:"url"`
+	Location            SpaceAPILocation            `json:"location"`
+	State               SpaceAPIState               `json:"state"`
+	Contact             map[string]string           `json:"contact"`
+	IssueReportChannels []string                    `json:"issue_report_channels"`
+	Projects            []string                    `json:"projects"`
+	Feeds               map[string]SpaceAPIFeed     `json:"feeds"`
+	Sensors             map[string][]SpaceAPISensor `json:"sensors"`
+}
+
+type SpaceAPILocation struct {
+	Latitude  float64 `json:"lat"`
+	Longitude float64 `json:"lon"`
+	Address   string  `json:"address"`
+}
+
+type SpaceAPIState struct {
+	Open    bool   `json:"open"`
+	Message string `json:"message"`
+	Icon    struct {
+		Open   string `json:"open"`
+		Closed string `json:"closed"`
+	} `json:"icon"`
+}
+
+type SpaceAPIFeed struct {
+	Type string `json:"type"`
+	URL  string `json:"url"`
+}
+
+type SpaceAPISensor struct {
+	Value int      `json:"value"`
+	Names []string `json:"names"`
+}
+
+func generateSpaceAPIResponse(ctx context.Context) SpaceAPIResponse {
+	state := SpaceAPIState{}
+	state.Icon.Open = "https://static.hackerspace.pl/img/status-open-small.png"
+	state.Icon.Closed = "https://static.hackerspace.pl/img/status-closed-small.png"
+	// TODO(q3k): post-coronavirus, make automatically open based on calendar
+	// events and Open Thursdays.
+	open := false
+	if open {
+		state.Open = true
+		state.Message = "open for public"
+	} else {
+		state.Open = false
+		state.Message = "members only"
+	}
+
+	peopleNowPresent := SpaceAPISensor{}
+	atState, err := getAt(ctx)
+	if err != nil {
+		glog.Errorf("Failed to get checkinator status: %v", err)
+	} else {
+		peopleNowPresent.Names = make([]string, len(atState.Users))
+		for i, u := range atState.Users {
+			peopleNowPresent.Names[i] = u.Login
+		}
+		peopleNowPresent.Value = len(peopleNowPresent.Names)
+	}
+
+	res := SpaceAPIResponse{
+		API:   "0.13",
+		Space: "Warsaw Hackerspace",
+		Logo:  "https://static.hackerspace.pl/img/syrenka-black.png",
+		URL:   "https://hackerspace.pl",
+		Location: SpaceAPILocation{
+			Latitude:  52.24160,
+			Longitude: 20.98485,
+			Address:   "ul. Wolność 2A, 01-018 Warszawa, Poland",
+		},
+		State: state,
+		Contact: map[string]string{
+			"irc":      "irc://irc.libera.chat/#hswaw",
+			"twitter":  "@hackerspacepl",
+			"facebook": "hackerspacepl",
+			"ml":       "waw@lists.hackerspace.pl",
+		},
+		IssueReportChannels: []string{"irc"},
+		Projects: []string{
+			"https://wiki.hackerspace.pl/projects",
+		},
+		Feeds: map[string]SpaceAPIFeed{
+			"blog": SpaceAPIFeed{
+				Type: "atom",
+				URL:  feedsURLs["blog"],
+			},
+			"calendar": SpaceAPIFeed{
+				Type: "ical",
+				URL:  calendar.EventsURL,
+			},
+			"wiki": SpaceAPIFeed{
+				Type: "rss",
+				URL:  "https://wiki.hackerspace.pl/feed.php",
+			},
+		},
+		Sensors: map[string][]SpaceAPISensor{
+			"people_now_present": []SpaceAPISensor{peopleNowPresent},
+		},
+	}
+
+	return res
+}
diff --git a/hswaw/site/static/landing.css b/hswaw/site/static/landing.css
index 431d8ea..3ddae1a 100644
--- a/hswaw/site/static/landing.css
+++ b/hswaw/site/static/landing.css
@@ -141,9 +141,19 @@
     opacity: 60%;
 }
 
-#quicklinks {
-    float: right;
-    font-family: monospace;
-    font-size: 14px;
-    margin: 2rem;
+.atlist {
+    display: inline;
+    list-style: none;
+}
+
+.atlist li {
+    display: inline;
+}
+
+.atlist li:after {
+    content: ", ";
+}
+
+.atlist li:last-child:after {
+    content: ".";
 }
diff --git a/hswaw/site/templates/index.html b/hswaw/site/templates/index.html
index 48f0acd..058c162 100644
--- a/hswaw/site/templates/index.html
+++ b/hswaw/site/templates/index.html
@@ -36,6 +36,28 @@
             <p>
               <b>Hackerspace nie zna barier.</b> Jeśli masz ciekawy pomysł i szukasz ludzi chętnych do współpracy lub po prostu potrzebujesz miejsca i sprzętu - <a href="">zapraszamy</a>!
             </p>
+            <h3>Kto jest teraz w spejsie?</h3>
+            <p>
+                {{ if ne .AtError nil }}
+                <i>Ups, nie udało się załadować stanu checkinatora.</i>
+                {{ else }}
+                  {{ $count := len .AtStatus.Users }}
+                  {{ if gt $count 4 }}
+                Według <a href="https://at.hackerspace.pl">naszych instrumentów</a> w spejsie obecnie znajduje się {{ $count }} osób:
+                  {{ else if gt $count 1 }}
+                Według <a href="https://at.hackerspace.pl">naszych instrumentów</a> w spejsie obecnie znajdują się {{ $count }} osoby:
+                  {{ else if gt $count 0 }}
+                Według <a href="https://at.hackerspace.pl">naszych instrumentów</a> w spejsie obecnie znajduje się jedna osoba:
+                  {{ else }}
+                Według <a href="https://at.hackerspace.pl">naszych instrumentów</a> w spejsie obecnie nie ma nikogo.
+                  {{ end }}
+                <ul class="atlist">
+                    {{ range .AtStatus.Users }}
+                    <li>{{ .Login }}</li>
+                    {{ end }}
+                </ul>
+                {{ end }}
+            </p>
         </div>
         <div class="blog">
             <h2>Blog</h2>
diff --git a/hswaw/site/views.go b/hswaw/site/views.go
index 59f948e..09755b2 100644
--- a/hswaw/site/views.go
+++ b/hswaw/site/views.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"encoding/json"
 	"fmt"
 	"html/template"
 	"net/http"
@@ -53,7 +54,19 @@
 
 // handleIndex handles rendering the main page at /.
 func (s *service) handleIndex(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	atStatus, atError := getAt(ctx)
+
 	render(w, tmplIndex, map[string]interface{}{
-		"Entries": s.getFeeds(),
+		"Entries":  s.getFeeds(),
+		"AtStatus": atStatus,
+		"AtError":  atError,
 	})
 }
+
+func (s *service) handleSpaceAPI(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(generateSpaceAPIResponse(ctx))
+}