254 lines
8.8 KiB
Go
254 lines
8.8 KiB
Go
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||
// swipe, the Supply run and whether there are recent updates. It owns no
|
||
// data: every value is read through the service that owns it, and "now"
|
||
// and "tonight" are in the household's zone, never UTC.
|
||
package home
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||
)
|
||
|
||
// Service reads the Home screen.
|
||
type Service struct {
|
||
settings *household.SettingsService
|
||
plans *plan.Service
|
||
suggest *suggest.Service
|
||
shopping *shopping.Service
|
||
notifier *notify.Service
|
||
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||
now func() time.Time
|
||
}
|
||
|
||
// NewService returns a service reading through the given services.
|
||
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||
switch {
|
||
case settings == nil:
|
||
return nil, errors.New("home: nil household settings service")
|
||
case plans == nil:
|
||
return nil, errors.New("home: nil plan service")
|
||
case suggestSvc == nil:
|
||
return nil, errors.New("home: nil suggestions service")
|
||
case shop == nil:
|
||
return nil, errors.New("home: nil shopping service")
|
||
case notifier == nil:
|
||
return nil, errors.New("home: nil notifier")
|
||
}
|
||
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||
}
|
||
|
||
// Home is the Home screen as the API shows it.
|
||
type Home struct {
|
||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||
DateLabel string `json:"dateLabel"`
|
||
// Timezone is the household's IANA zone, whose next local midnight
|
||
// is when Home is read again for the new day.
|
||
Timezone string `json:"timezone"`
|
||
// Tonight is the dinner planned for today on the current week's plan;
|
||
// nil when the week has no plan.
|
||
Tonight *Dinner `json:"tonight"`
|
||
// SideQuestCount is how many Side quests are pending on the deck.
|
||
SideQuestCount int `json:"sideQuestCount"`
|
||
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||
// else of the current week.
|
||
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||
}
|
||
|
||
// Dinner is tonight's dinner.
|
||
type Dinner struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Photo *string `json:"photo"`
|
||
// Origin is regular or side_quest.
|
||
Origin string `json:"origin"`
|
||
MinutesTotal *int64 `json:"minutes_total"`
|
||
}
|
||
|
||
// ShoppingStatus is whether a week's Supply run is ready.
|
||
type ShoppingStatus struct {
|
||
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||
WeekStart string `json:"weekStart"`
|
||
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||
Label string `json:"label"`
|
||
// Ready is true when the week has a Supply run.
|
||
Ready bool `json:"ready"`
|
||
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||
// until it is, and when there is no Supply run.
|
||
SentToChatAt *string `json:"sentToChatAt"`
|
||
}
|
||
|
||
// RecentUpdates says whether there is something new for the bell.
|
||
type RecentUpdates struct {
|
||
// Unread is true when a notification was stored after the household
|
||
// last saw its updates (household_settings.updates_seen_at), or at all
|
||
// when it never has.
|
||
Unread bool `json:"unread"`
|
||
}
|
||
|
||
// Get reads the Home screen at the service's "now". It only reads: it
|
||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||
settings, err := s.settings.Get(ctx)
|
||
if err != nil {
|
||
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||
}
|
||
zone, err := household.LoadZone(settings.Timezone)
|
||
if err != nil {
|
||
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||
}
|
||
now := s.now().In(zone)
|
||
today := household.Today(now, zone)
|
||
thisWeek := household.WeekStart(today)
|
||
|
||
tonight, err := s.tonight(ctx, thisWeek, today)
|
||
if err != nil {
|
||
return Home{}, err
|
||
}
|
||
sideQuests, err := s.suggest.PendingCount(ctx)
|
||
if err != nil {
|
||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||
}
|
||
list, err := s.shoppingStatus(ctx, thisWeek)
|
||
if err != nil {
|
||
return Home{}, err
|
||
}
|
||
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||
if err != nil {
|
||
return Home{}, err
|
||
}
|
||
return Home{
|
||
DateLabel: now.Format("Monday, Jan 2"),
|
||
Timezone: settings.Timezone,
|
||
Tonight: tonight,
|
||
SideQuestCount: sideQuests,
|
||
ShoppingList: list,
|
||
RecentUpdates: RecentUpdates{Unread: unread},
|
||
}, nil
|
||
}
|
||
|
||
// tonight returns the dinner planned on today in the plan of the week
|
||
// starting thisWeek, or nil when the week has no plan.
|
||
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||
p, err := s.plans.Get(ctx, thisWeek)
|
||
if errors.Is(err, plan.ErrNotFound) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("home: read plan: %w", err)
|
||
}
|
||
for _, d := range p.Days {
|
||
if d.Date == today.String() {
|
||
return &Dinner{
|
||
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||
}, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||
}
|
||
|
||
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||
// it has one, else of the current week's, ready or not.
|
||
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||
y, m, d := thisWeek.Date()
|
||
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||
l, err := s.shopping.Get(ctx, week)
|
||
if errors.Is(err, shopping.ErrListNotFound) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||
}
|
||
return weekStatus(week, true, l.SentToChatAt)
|
||
}
|
||
return weekStatus(thisWeek, false, nil)
|
||
}
|
||
|
||
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||
label, err := household.WeekLabel(week)
|
||
if err != nil {
|
||
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||
}
|
||
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||
}
|
||
|
||
// unread reports whether a notification was stored after seenAt, the
|
||
// stored updates_seen_at, or at all when it is nil.
|
||
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||
var since *time.Time
|
||
if seenAt != nil {
|
||
var ts store.Timestamp
|
||
if err := ts.Scan(*seenAt); err != nil {
|
||
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||
}
|
||
since = &ts.Time
|
||
}
|
||
unread, err := s.notifier.StoredSince(ctx, since)
|
||
if err != nil {
|
||
return false, fmt.Errorf("home: %w", err)
|
||
}
|
||
return unread, nil
|
||
}
|
||
|
||
// RecentLimit is how many events the Recent updates sheet lists.
|
||
const RecentLimit = 10
|
||
|
||
// Update is one event in the Recent updates sheet.
|
||
type Update struct {
|
||
// Type is the event type, e.g. plan_ready.
|
||
Type string `json:"type"`
|
||
Title string `json:"title"`
|
||
Body string `json:"body"`
|
||
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||
URL string `json:"url"`
|
||
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||
// milliseconds; it is what POST /updates/seen takes back.
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// Updates returns the last RecentLimit distinct notification events, newest
|
||
// first, whatever channels they went to.
|
||
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("home: %w", err)
|
||
}
|
||
out := make([]Update, len(events))
|
||
for i, ev := range events {
|
||
out[i] = Update{
|
||
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||
|
||
// MarkUpdatesSeen records that the household has seen its updates through
|
||
// through, the created_at of the newest update it was shown, so
|
||
// recentUpdates.unread is false until an event is stored after it. A time
|
||
// before the one already recorded changes nothing; one after now is
|
||
// ErrSeenInFuture, since it would hide events not yet stored.
|
||
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||
if through.After(s.now()) {
|
||
return ErrSeenInFuture
|
||
}
|
||
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||
return fmt.Errorf("home: %w", err)
|
||
}
|
||
return nil
|
||
}
|