198 lines
6.7 KiB
Go
198 lines
6.7 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"`
|
|||
|
|
// 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".
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
deck, err := s.suggest.Deck(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"),
|
|||
|
|
Tonight: tonight,
|
|||
|
|
SideQuestCount: len(deck.Suggestions),
|
|||
|
|
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
|
|||
|
|
}
|