package web import ( "fmt" "log/slog" "net/http" "time" "pete/internal/storage" ) // "While you were away" — the one panel on the site that is about the reader. // // Everything else in the adventure section is the realm's news: the board, the // Siege, the standings. This is the owner's own adventurer, and only what has // happened to them since they last looked. It pairs with W6's push alerts and // covers the gap those deliberately leave: the alerts are four opt-in categories // chosen for being worth interrupting somebody over, while this catches // everything, for people who would rather not be interrupted at all. // // It renders on /adventure page 1 only. The panel is present tense and page 2 of // an archive is not where anybody looks for what just happened, which is the same // rule the roster and the Siege strip already follow. // awayCap bounds the panel. Six lines is a glance; a longer list is the trail on // the adventurer's own page, which is where the "all of it" link goes. const awayCap = 6 // awayView is the panel. Has is false in every case where there is nothing // honest to show — not signed in, no adventurer, first ever visit, or simply // nothing new — and the template renders nothing at all rather than an empty box // announcing that nothing happened. type awayView struct { Has bool Name string // the reader's own character Since string // "3 hours", "2 days" — how long they were gone Lines []awayLine // HasMore says there is more than the cap, without saying how much more. The // window query reads one row past the cap to learn this; an exact count would // need a second query over the same window to tell somebody a number they are // about to click past anyway. HasMore bool Token string // their adventurer page, where the rest of the trail is } // awayLine is one thing that happened, in the trail's own shape. Built from the // fact rather than from the dispatch headline for the same reason buildTimeline // is: a headline is a news sentence written to be shouted once, and six of them // stacked in a panel read as shouting. type awayLine struct { Emoji string Label string Line string When string // relative: this panel is about recency Permalink string Notable bool } // awayPanel builds the panel for whoever is asking, and stamps their visit. // // The stamp is written even when the panel comes back empty — even for a signed-in // user with no adventurer at all — and that is deliberate: a clock that only // advances when there is something to show would hand somebody their entire // backlog on the day they finally rolled a character. func (s *Server) awayPanel(r *http.Request) awayView { if s.auth == nil { return awayView{} } u := s.auth.userFromRequest(r) if u == nil { return awayView{} } now := time.Now().Unix() from, first, err := storage.AdvVisitWindow(u.Sub, now) if err != nil { slog.Error("away: visit clock failed", "sub", u.Sub, "err", err) return awayView{} } if first { // Never seen before. Their history is not news to them, and a first visit // greeted by every death their character ever suffered is a worse welcome // than no panel at all. return awayView{} } // The ownership join, re-read on every request rather than cached anywhere — // same discipline as the alert sender and the run report link. It fails closed // on an opt-out and on a player gogobee has stopped pushing, both of which mean // Pete cannot honestly say which adventurer is this reader's. lp := buyerLocalpart(u) if lp == "" { return awayView{} } name, ok := storage.AdvCharacterForOwner(lp) if !ok { return awayView{} } // One extra row is fetched past the cap purely to answer "is there more", // without a second COUNT query over the same window. events, err := storage.EventsBySubjectSince(name, from, awayCap+1) if err != nil { slog.Error("away: dispatch lookup failed", "subject", name, "err", err) return awayView{} } if len(events) == 0 { return awayView{} } v := awayView{Has: true, Name: name, Since: awaySince(now - from)} if token, ok := storage.SelfToken(lp); ok { v.Token = token } if len(events) > awayCap { v.HasMore = true events = events[:awayCap] } for _, e := range events { label, emoji := advEventMeta(e.EventType) v.Lines = append(v.Lines, awayLine{ Emoji: emoji, Label: label, Line: timelineLine(name, e), When: awayAgo(now - e.OccurredAt), Permalink: s.advPermalink(e.GUID), Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death", }) } return v } // awaySince phrases the gap the panel covers. Rounded down, and it never claims // less than an hour: the window is at least one session gap wide, and "since 34 // minutes ago" is a precision the clock behind it does not have. func awaySince(secs int64) string { switch d := time.Duration(secs) * time.Second; { case d < 2*time.Hour: return "an hour" case d < 48*time.Hour: return fmt.Sprintf("%d hours", int(d.Hours())) case d < 14*24*time.Hour: return fmt.Sprintf("%d days", int(d.Hours())/24) default: return "a while" } } // awayAgo is a compact relative stamp for one line. Deliberately not the trail's // "Jan 2, 2006": everything in this panel is recent by construction, and a date // on it would make the reader do the subtraction themselves. func awayAgo(secs int64) string { switch d := time.Duration(secs) * time.Second; { case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%dm ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh ago", int(d.Hours())) default: return fmt.Sprintf("%dd ago", int(d.Hours())/24) } }