Register rotation_check: daily at review_time, claimed as
rotation_check:YYYY-MM-DD, it calls plan.MarkTried for yesterday.
MarkTried now emits rotation_review:{dinnerId}:{planDate} ("How was
{dinner}? Keep it in rotation?", URL /) for each Rotation check it
opens, in the same transaction. Nothing promotes a dinner to Regular.
Task: 58095ae5-ed4b-4494-b8f0-206c4c0a3f1a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
9.0 KiB
Go
265 lines
9.0 KiB
Go
package plan
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// Rotation check decisions (the rotation_reviews.decision column), the
|
|
// household's answer to "Keep it in rotation?" (D-021).
|
|
const (
|
|
// DecisionKeep makes the tried dinner a Regular.
|
|
DecisionKeep = "keep"
|
|
// DecisionDrop ("Not for us") archives it; its name stays among the
|
|
// passed names, so it is never suggested again.
|
|
DecisionDrop = "drop"
|
|
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
|
|
DecisionNotMade = "not_made"
|
|
)
|
|
|
|
// decisionStatus is the status each decision moves a tried dinner to. Keep
|
|
// is the only way a tried dinner becomes a Regular: nothing automatic
|
|
// promotes one (D-021).
|
|
var decisionStatus = map[string]string{
|
|
DecisionKeep: dinners.StatusRegular,
|
|
DecisionDrop: dinners.StatusArchived,
|
|
DecisionNotMade: dinners.StatusQuestLog,
|
|
}
|
|
|
|
// ErrReviewNotFound is a decision on a dinner that has never had a
|
|
// Rotation check: it was never tried.
|
|
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
|
|
|
|
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
|
|
// all decided, such as a second decision.
|
|
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
|
|
|
|
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
|
|
// is no longer tried, such as one archived meanwhile.
|
|
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
|
|
|
|
// InvalidDecisionError is a decision that is not keep, drop or not_made.
|
|
type InvalidDecisionError struct {
|
|
Value string
|
|
}
|
|
|
|
func (e *InvalidDecisionError) Error() string {
|
|
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
|
|
}
|
|
|
|
// ReviewDinner is the dinner of a Rotation check.
|
|
type ReviewDinner struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Photo *string `json:"photo"`
|
|
}
|
|
|
|
// Review is a pending Rotation check: the dinner tried and the plan date
|
|
// it was planned on.
|
|
type Review struct {
|
|
Dinner ReviewDinner `json:"dinner"`
|
|
// PlanDate is household-local, YYYY-MM-DD.
|
|
PlanDate string `json:"plan_date"`
|
|
}
|
|
|
|
// Decided is a Rotation check as decided.
|
|
type Decided struct {
|
|
Dinner ReviewDinner `json:"dinner"`
|
|
PlanDate string `json:"plan_date"`
|
|
Decision string `json:"decision"`
|
|
// DinnerStatus is the status the decision moved the dinner to.
|
|
DinnerStatus string `json:"dinner_status"`
|
|
DecidedAt string `json:"decided_at"`
|
|
UpdatedBy string `json:"updated_by"`
|
|
}
|
|
|
|
// RotationCheckSchedule is the rotation_check schedule: every day at the
|
|
// household's review_time it runs MarkTried for the day before, so each
|
|
// Quest log dinner planned yesterday is tried and the household is asked
|
|
// "Keep it in rotation?". Its period is the fire date, claimed as
|
|
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
|
|
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
|
|
return scheduler.Schedule{
|
|
Name: scheduler.RotationCheck,
|
|
Rule: func(settings household.Settings) scheduler.Rule {
|
|
return scheduler.Daily(settings.ReviewTime)
|
|
},
|
|
PeriodKey: func(p scheduler.Period) string {
|
|
return scheduler.RotationCheck + ":" + p.Key
|
|
},
|
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
|
yesterday := addDays(p.Date, -1)
|
|
n, err := s.MarkTried(ctx, yesterday)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
|
|
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// MarkTried moves every dinner in the Quest log planned on date, the
|
|
// household-local day just past, on a plan of any status, to tried, opens
|
|
// a pending Rotation check of it for date and emits its rotation_review
|
|
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
|
// dinner already checked for date is left as it is and not notified of
|
|
// again, so a later run never moves one returned to the Quest log ("We
|
|
// didn't make it") again. It returns how many dinners it moved.
|
|
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
|
var n int
|
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
ids, err := q.ListTriedCandidates(ctx, date.String())
|
|
if err != nil {
|
|
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
|
|
}
|
|
now := store.Now().String()
|
|
for _, id := range ids {
|
|
d, err := q.GetDinner(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("plan: read dinner %s: %w", id, err)
|
|
}
|
|
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
|
if err != nil {
|
|
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
|
}
|
|
if moved != 1 {
|
|
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
|
|
}
|
|
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
|
|
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
|
|
}); err != nil {
|
|
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
|
}
|
|
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
|
|
return fmt.Errorf("plan: %w", err)
|
|
}
|
|
}
|
|
n = len(ids)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n > 0 {
|
|
s.notifier.Wake()
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// PendingReviews returns the pending Rotation checks, oldest plan date
|
|
// first.
|
|
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
|
|
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
|
|
}
|
|
out := make([]Review, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DecideReview applies decision to the pending Rotation check of dinnerID
|
|
// and closes it, recording updatedBy: keep makes the dinner a Regular,
|
|
// drop archives it and not_made returns it to the Quest log; its origin is
|
|
// kept; a drop records the dinner's name on the check, so renaming the
|
|
// dinner later never takes it off the passed names. An unknown dinner is
|
|
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
|
|
// checks are all decided (such as a second decision) ErrNoPendingReview,
|
|
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
|
|
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
|
|
status, ok := decisionStatus[decision]
|
|
if !ok {
|
|
return Decided{}, &InvalidDecisionError{Value: decision}
|
|
}
|
|
if updatedBy == "" {
|
|
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
|
|
}
|
|
var out Decided
|
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
d, err := q.GetDinner(ctx, dinnerID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrDinnerNotFound
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
|
}
|
|
review, err := q.GetPendingRotationReview(ctx, d.ID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
checks, err := q.CountRotationReviews(ctx, d.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
|
|
}
|
|
if checks == 0 {
|
|
return ErrReviewNotFound
|
|
}
|
|
return ErrNoPendingReview
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
|
|
}
|
|
now := store.Now().String()
|
|
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
|
|
Status: status,
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
UpdatedAt: now,
|
|
ID: d.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
|
|
}
|
|
if moved != 1 {
|
|
return ErrDinnerNotTried
|
|
}
|
|
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
|
|
Decision: sql.NullString{String: decision, Valid: true},
|
|
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
|
|
Now: sql.NullString{String: now, Valid: true},
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
ID: review.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
|
|
}
|
|
if closed != 1 {
|
|
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
|
|
}
|
|
out = Decided{
|
|
Dinner: reviewDinner(d),
|
|
PlanDate: review.PlanDate,
|
|
Decision: decision,
|
|
DinnerStatus: status,
|
|
DecidedAt: now,
|
|
UpdatedBy: updatedBy,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Decided{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func reviewDinner(d db.Dinner) ReviewDinner {
|
|
out := ReviewDinner{ID: d.ID, Name: d.Name}
|
|
if d.PhotoID.Valid {
|
|
u := dinners.PhotoURL(d.PhotoID.String)
|
|
out.Photo = &u
|
|
}
|
|
return out
|
|
}
|