237 lines
7.8 KiB
Go
237 lines
7.8 KiB
Go
package chat
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"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/store"
|
||
)
|
||
|
||
// This file is the one place the notification texts are written. The
|
||
// builders make the notify.Event of each event type, whose title and body
|
||
// the push channel shows as they are; Text turns its payload into the Google
|
||
// Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||
// Tikka masala + 4 more. Open the plan →".
|
||
|
||
// TestText is the message POST /settings/chat-webhook/test sends.
|
||
const TestText = "MealQuest · test message"
|
||
|
||
// listedNames is how many names a message lists before "+ N more".
|
||
const listedNames = 3
|
||
|
||
// actions is the closing call to action of each event's Chat message.
|
||
var actions = map[notify.EventType]string{
|
||
notify.PlanReady: "Open the plan",
|
||
notify.ListReady: "Open the Supply run",
|
||
notify.DayOfReminder: "Open the recipe",
|
||
notify.NewSuggestions: "Open Discover",
|
||
notify.RotationReview: "Open MealQuest",
|
||
}
|
||
|
||
// carded are the events whose Chat message also has a card with an "Open"
|
||
// button.
|
||
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
|
||
|
||
// PlanReady is the plan_ready event of the week starting on week, drafted
|
||
// in the week starting on thisWeek, naming its dinners in plan order. Its
|
||
// title names the week as seen from thisWeek: "Next week's plan is ready",
|
||
// "This week's plan is ready", or "The plan for the week of Oct 26 is
|
||
// ready" for any other week.
|
||
func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.PlanReady,
|
||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||
Title: planTitle(week, thisWeek),
|
||
Body: nameList(dinners),
|
||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||
Week: week,
|
||
}
|
||
}
|
||
|
||
// ListReady is the list_ready event of the Supply run of the week starting
|
||
// on week: needed items to buy for its dinners.
|
||
func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.ListReady,
|
||
Key: string(notify.ListReady) + ":" + week.String(),
|
||
Title: "This week's Supply run is ready",
|
||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
||
Week: week,
|
||
}
|
||
}
|
||
|
||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
||
// own call to action.
|
||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.DayOfReminder,
|
||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||
Title: "Tonight: " + name + " · Recipe →",
|
||
URL: "/dinner/" + dinnerID,
|
||
Week: household.WeekStart(date),
|
||
}
|
||
}
|
||
|
||
// StartReminder is the day_of_reminder event, keyed
|
||
// day_of_reminder:start:{date}, reminding the household to start the
|
||
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
|
||
// a time in the household zone: "Start the crock pot by 9:30 am".
|
||
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.DayOfReminder,
|
||
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
|
||
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
|
||
Body: name,
|
||
URL: "/dinner/" + dinnerID,
|
||
Week: household.WeekStart(date),
|
||
}
|
||
}
|
||
|
||
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
|
||
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
|
||
// one, of the week starting on week: how many new Side quests there are,
|
||
// and their names.
|
||
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.NewSuggestions,
|
||
Key: string(notify.NewSuggestions) + ":" + id,
|
||
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
|
||
Body: nameList(names),
|
||
URL: "/discover",
|
||
Week: week,
|
||
}
|
||
}
|
||
|
||
// RotationReview is the rotation_review event, keyed
|
||
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
||
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
||
// in rotation?". It opens Home, where the Rotation check card asks.
|
||
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
||
return notify.Event{
|
||
Type: notify.RotationReview,
|
||
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
||
Title: "How was " + name + "? Keep it in rotation?",
|
||
URL: "/",
|
||
Week: household.WeekStart(planDate),
|
||
}
|
||
}
|
||
|
||
// SupplyRunItem is a Supply run item still needed, as sent to Google Chat;
|
||
// Qty is empty when the recipe gives none.
|
||
type SupplyRunItem struct {
|
||
Name string
|
||
Qty string
|
||
}
|
||
|
||
// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with
|
||
// its items still needed.
|
||
type SupplyRunGroup struct {
|
||
Dinner string
|
||
Items []SupplyRunItem
|
||
}
|
||
|
||
// SupplyRunText is the Google Chat message of the Supply run named name
|
||
// (its title, or its week label until it has one): each dinner with items
|
||
// still needed in bold, its items one per line, and the call to action.
|
||
//
|
||
// Supply run · Week 42 · Oct 12 – 18
|
||
//
|
||
// *Tacos*
|
||
// • ground beef — 1 lb
|
||
// • tortillas
|
||
//
|
||
// Open the Supply run →
|
||
//
|
||
// A Supply run with nothing still needed says so.
|
||
func SupplyRunText(name string, groups []SupplyRunGroup) string {
|
||
var b strings.Builder
|
||
b.WriteString("Supply run · " + name)
|
||
listed := false
|
||
for _, g := range groups {
|
||
if len(g.Items) == 0 {
|
||
continue
|
||
}
|
||
listed = true
|
||
b.WriteString("\n\n*" + g.Dinner + "*")
|
||
for _, it := range g.Items {
|
||
b.WriteString("\n• " + it.Name)
|
||
if it.Qty != "" {
|
||
b.WriteString(" — " + it.Qty)
|
||
}
|
||
}
|
||
}
|
||
if !listed {
|
||
b.WriteString("\n\nNothing left to buy.")
|
||
}
|
||
b.WriteString("\n\n" + actions[notify.ListReady] + " →")
|
||
return b.String()
|
||
}
|
||
|
||
// planTitle is the plan_ready title of the week starting on week, seen
|
||
// from the week starting on thisWeek.
|
||
func planTitle(week, thisWeek store.LocalDate) string {
|
||
y, m, d := thisWeek.Date()
|
||
next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||
switch week {
|
||
case thisWeek:
|
||
return "This week's plan is ready"
|
||
case next:
|
||
return "Next week's plan is ready"
|
||
}
|
||
_, m, d = week.Date()
|
||
return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d)
|
||
}
|
||
|
||
// Text is the Google Chat message of p: its title, its body after a colon
|
||
// and the event's call to action with an arrow. A title ending in an arrow,
|
||
// with no body, carries its own call to action and is the message as it is.
|
||
func Text(p notify.Payload) string {
|
||
text := p.Title
|
||
if p.Body == "" && strings.HasSuffix(text, "→") {
|
||
return text
|
||
}
|
||
if p.Body != "" {
|
||
if endsSentence(text) {
|
||
text += " "
|
||
} else {
|
||
text += ": "
|
||
}
|
||
text += p.Body
|
||
}
|
||
if !endsSentence(text) {
|
||
text += "."
|
||
}
|
||
if action, ok := actions[p.Event]; ok {
|
||
text += " " + action + " →"
|
||
}
|
||
return text
|
||
}
|
||
|
||
// endsSentence reports whether s ends with sentence punctuation.
|
||
func endsSentence(s string) bool {
|
||
return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!")
|
||
}
|
||
|
||
// nameList lists the first listedNames of names, then how many more there
|
||
// are: "Tacos, Bolognese, Tikka masala + 4 more".
|
||
func nameList(names []string) string {
|
||
if len(names) <= listedNames {
|
||
return strings.Join(names, ", ")
|
||
}
|
||
return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames)
|
||
}
|
||
|
||
// count is n with the singular or plural noun.
|
||
func count(n int, one, many string) string {
|
||
if n == 1 {
|
||
return "1 " + one
|
||
}
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|