CI / ci (push) Successful in 8m4s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
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
|
||
}
|